Merge remote-tracking branch 'aosp/upstream-main' into HEAD am: 041e9eeb15 am: 78ce59df93

Original change: https://android-review.googlesource.com/c/platform/external/bazelbuild-rules_android/+/2722373

Change-Id: I86420496c0a0156277d264c9749f29b10ec84a64
Signed-off-by: Automerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com>
diff --git a/.bazelci/presubmit.yml b/.bazelci/presubmit.yml
index 6df2bf1..93aab2f 100644
--- a/.bazelci/presubmit.yml
+++ b/.bazelci/presubmit.yml
@@ -9,33 +9,28 @@
     - "//android/..."
     - "//rules/..."
     - "-//src/java/com/example/sampleapp/..."
-    - "-//src/tools/ak/..."
     - "//toolchains/..."
     - "//tools/..."
+    - "-//tools/android/..." # TODO(#122): Un-exclude this once #122 is fixed.
     test_targets:
     - "//src/..."
     - "//test/..."
-    - "-//src/tools/..."
+    - "-//src/tools/enforce_min_sdk_floor/..."
     - "-//src/java/com/example/sampleapp/..."
+    test_flags:
+     # Sandboxed SDK tools depend on libraries that require Java runtime 17 or higher.
+    - "--java_runtime_version=17"
 
 tasks:
-  ubuntu1604:
-    <<: *common
-  ubuntu1804:
+  ubuntu2004:
     <<: *common
   macos:
     <<: *common
   macos_arm64:
     <<: *common
-  ubuntu1604_bzlmod:
-    name: Bzlmod ubuntu1604
-    platform: ubuntu1604
-    build_flags:
-    - "--enable_bzlmod"
-    <<: *common
-  ubuntu1804_bzlmod:
-    name: Bzlmod ubuntu1804
-    platform: ubuntu1804
+  ubuntu2004_bzlmod:
+    name: Bzlmod ubuntu2004
+    platform: ubuntu2004
     build_flags:
     - "--enable_bzlmod"
     <<: *common
@@ -50,4 +45,4 @@
     platform: macos_arm64
     build_flags:
     - "--enable_bzlmod"
-    <<: *common
\ No newline at end of file
+    <<: *common
diff --git a/MODULE.bazel b/MODULE.bazel
index 4bf2eb2..7a6c172 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -42,8 +42,13 @@
 maven.install(
     name = "rules_android_maven",
     artifacts = [
-        "com.android.tools.build:bundletool:1.6.1",
-        "com.android.tools.build:gradle:8.0.1",
+        "com.android.tools.build:bundletool:1.15.2",
+        "com.android.tools.build:gradle:8.2.0-alpha15",
+        "com.google.guava:guava:32.1.2-jre",
+        "com.google.protobuf:protobuf-java-util:3.9.2",
+        "com.google.truth:truth:1.1.5",
+        "info.picocli:picocli:4.7.4",
+        "junit:junit:4.13.2",
     ],
     repositories = [
         "https://maven.google.com",
diff --git a/ROADMAP.md b/ROADMAP.md
new file mode 100644
index 0000000..209dd84
--- /dev/null
+++ b/ROADMAP.md
@@ -0,0 +1,4 @@
+# Bazel Android Rules Roadmap
+
+See https://github.com/orgs/bazelbuild/projects/17 for the Starlark Android
+Rules roadmap.
\ No newline at end of file
diff --git a/WORKSPACE b/WORKSPACE
index fb3a90e..7aab8a6 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -1,7 +1,7 @@
 workspace(name = "build_bazel_rules_android")
 
-load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
 load("@bazel_tools//tools/build_defs/repo:utils.bzl", "maybe")
+load(":android_sdk_supplemental_repository.bzl", "android_sdk_supplemental_repository")
 
 maybe(
     android_sdk_repository,
@@ -13,6 +13,11 @@
     name = "androidndk",
 )
 
+# This can be removed once https://github.com/bazelbuild/bazel/commit/773b50f979b8f40e73cf547049bb8e1114fb670a
+# is released, or android_sdk_repository is properly Starlarkified and dexdump
+# added there.
+android_sdk_supplemental_repository(name = "androidsdk-supplemental")
+
 load("prereqs.bzl", "rules_android_prereqs")
 rules_android_prereqs()
 
diff --git a/WORKSPACE.bzlmod b/WORKSPACE.bzlmod
index 0aca21e..540b125 100644
--- a/WORKSPACE.bzlmod
+++ b/WORKSPACE.bzlmod
@@ -2,6 +2,7 @@
 
 load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
 load("@bazel_tools//tools/build_defs/repo:utils.bzl", "maybe")
+load(":android_sdk_supplemental_repository.bzl", "android_sdk_supplemental_repository")
 
 maybe(
     android_sdk_repository,
@@ -12,3 +13,8 @@
     android_ndk_repository,
     name = "androidndk",
 )
+
+# This can be removed once https://github.com/bazelbuild/bazel/commit/773b50f979b8f40e73cf547049bb8e1114fb670a
+# is released, or android_sdk_repository is properly Starlarkified and dexdump
+# added there.
+android_sdk_supplemental_repository(name = "androidsdk-supplemental")
\ No newline at end of file
diff --git a/android/rules.bzl b/android/rules.bzl
index ecaef19..ed1cd2d 100644
--- a/android/rules.bzl
+++ b/android/rules.bzl
@@ -37,6 +37,14 @@
     _android_ndk_repository = "android_ndk_repository",
 )
 load(
+    "//rules/android_sandboxed_sdk:android_sandboxed_sdk.bzl",
+    _android_sandboxed_sdk = "android_sandboxed_sdk",
+)
+load(
+    "//rules/android_sandboxed_sdk:android_sandboxed_sdk_bundle.bzl",
+    _android_sandboxed_sdk_bundle = "android_sandboxed_sdk_bundle",
+)
+load(
     "//rules:android_sdk.bzl",
     _android_sdk = "android_sdk",
 )
@@ -57,6 +65,8 @@
 android_binary = _android_binary
 android_library = _android_library
 android_ndk_repository = _android_ndk_repository
+android_sandboxed_sdk = _android_sandboxed_sdk
+android_sandboxed_sdk_bundle = _android_sandboxed_sdk_bundle
 android_sdk = _android_sdk
 android_sdk_repository = _android_sdk_repository
 android_tools_defaults_jar = _android_tools_defaults_jar
diff --git a/android_sdk_supplemental_repository.bzl b/android_sdk_supplemental_repository.bzl
new file mode 100644
index 0000000..1ac031d
--- /dev/null
+++ b/android_sdk_supplemental_repository.bzl
@@ -0,0 +1,67 @@
+# Copyright 2023 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""A repository rule for integrating the Android SDK."""
+
+def _parse_version(version):
+    # e.g.:
+    # "33.1.1" -> 330101
+    #  "4.0.0" ->  40000
+    # "33.1.1" < "4.0.0" but 330101 > 40000
+    major, minor, micro = version.split(".")
+    return (int(major) * 10000 + int(minor) * 100 + int(micro), version)
+
+def _android_sdk_supplemental_repository_impl(ctx):
+    """A repository for additional SDK content.
+
+    Needed until android_sdk_repository is fully in Starlark.
+
+    Args:
+        ctx: An implementation context.
+
+    Returns:
+        A final dict of configuration attributes and values.
+    """
+    sdk_path = ctx.attr.path or ctx.os.environ.get("ANDROID_HOME", None)
+    if not sdk_path:
+        fail("Either the ANDROID_HOME environment variable or the " +
+             "path attribute of android_sdk_supplemental_repository " +
+             "must be set.")
+
+    build_tools_dirs = ctx.path(sdk_path + "/build-tools").readdir()
+    _, highest_build_tool_version = (
+        max([_parse_version(v.basename) for v in build_tools_dirs])
+    )
+    ctx.symlink(
+        sdk_path + "/build-tools/" + highest_build_tool_version,
+        "build-tools/" + highest_build_tool_version,
+    )
+    ctx.file(
+        "BUILD",
+        """
+filegroup(
+  name  = "dexdump",
+  srcs = ["build-tools/%s/dexdump"],
+  visibility = ["//visibility:public"],
+)
+""" % highest_build_tool_version,
+    )
+
+android_sdk_supplemental_repository = repository_rule(
+    attrs = {
+        "path": attr.string(),
+    },
+    local = True,
+    implementation = _android_sdk_supplemental_repository_impl,
+)
diff --git a/defs.bzl b/defs.bzl
index 9f07ef3..8dfd421 100644
--- a/defs.bzl
+++ b/defs.bzl
@@ -21,6 +21,7 @@
 load("@robolectric//bazel:robolectric.bzl", "robolectric_repositories")
 load("@rules_java//java:repositories.bzl", "rules_java_dependencies", "rules_java_toolchains")
 load("@rules_jvm_external//:defs.bzl", "maven_install")
+load("@rules_jvm_external//:specs.bzl", "maven")
 load("@rules_proto//proto:repositories.bzl", "rules_proto_dependencies", "rules_proto_toolchains")
 load("@rules_python//python:repositories.bzl", "py_repositories")
 
@@ -33,8 +34,27 @@
     maven_install(
         name = "rules_android_maven",
         artifacts = [
-            "com.android.tools.build:bundletool:1.6.1",
-            "com.android.tools.build:gradle:8.0.1",
+            "androidx.privacysandbox.tools:tools:1.0.0-alpha05",
+            maven.artifact(
+                group = "androidx.privacysandbox.tools",
+                artifact = "tools-apipackager",
+                version = "1.0.0-alpha05",
+                exclusions = [
+                    # Alpha05 pulls in the lite version of protobuf library,
+                    # which doesn't have the JSON utils we need and clashes with
+                    # com.google.protobuf:protobuf-java-util.
+                    # This was fixed in AOSP, so this can be removed once
+                    # the packager releases a new version (>alpha05).
+                    "com.google.protobuf:protobuf-javalite",
+                ],
+            ),
+            "com.android.tools.build:bundletool:1.15.2",
+            "com.android.tools.build:gradle:8.2.0-alpha15",
+            "com.google.guava:guava:32.1.2-jre",
+            "com.google.protobuf:protobuf-java-util:3.9.2",
+            "com.google.truth:truth:1.1.5",
+            "info.picocli:picocli:4.7.4",
+            "junit:junit:4.13.2",
         ],
         repositories = [
             "https://maven.google.com",
@@ -64,10 +84,10 @@
     )
 
     go_repository(
-      name = "org_golang_x_sync",
-      importpath = "golang.org/x/sync",
-      sum = "h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=",
-      version = "v0.0.0-20210220032951-036812b2e83c",
+        name = "org_golang_x_sync",
+        importpath = "golang.org/x/sync",
+        sum = "h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=",
+        version = "v0.0.0-20210220032951-036812b2e83c",
     )
 
     robolectric_repositories()
@@ -78,4 +98,4 @@
     rules_proto_dependencies()
     rules_proto_toolchains()
 
-    py_repositories()
\ No newline at end of file
+    py_repositories()
diff --git a/kokoro/presubmit/kokoro_presubmit.sh b/kokoro/presubmit/kokoro_presubmit.sh
index d4f15d7..1c183f4 100644
--- a/kokoro/presubmit/kokoro_presubmit.sh
+++ b/kokoro/presubmit/kokoro_presubmit.sh
@@ -62,6 +62,11 @@
   "--experimental_google_legacy_api"
   "--experimental_enable_android_migration_apis"
   "--build_tests_only"
+  # Java tests use language version at least 11, but they might depend on
+  # libraries that were built for Java 17.
+  "--java_language_version=11"
+  "--java_runtime_version=17"
+  "--test_output=errors"
 )
 
 # Go to rules_android workspace and run relevant tests.
@@ -73,7 +78,9 @@
 
 "$bazel" test "${COMMON_ARGS[@]}" //src/common/golang/... \
   //src/tools/ak/... \
+  //src/tools/javatests/... \
   //src/tools/jdeps/... \
+  //src/tools/java/... \
   //test/...
 
 # Go to basic app workspace in the source tree
diff --git a/mobile_install/BUILD b/mobile_install/BUILD
new file mode 100644
index 0000000..3c89c82
--- /dev/null
+++ b/mobile_install/BUILD
@@ -0,0 +1,26 @@
+# Description:
+#   Blaze mobile-install aspect package.
+
+load("@bazel_skylib//:bzl_library.bzl", "bzl_library")
+
+package(
+    default_applicable_licenses = ["//:license"],
+    default_visibility = ["//:__subpackages__"],
+)
+
+licenses(["notice"])
+
+exports_files(["mi.bzl"])
+
+filegroup(
+    name = "all_files",
+    srcs = glob(["**"]),
+)
+
+bzl_library(
+    name = "bzl",
+    srcs = glob(["**/*.bzl"]),
+    deps = [
+        "//rules:bzl",
+    ],
+)
diff --git a/mobile_install/adapters/aar_import.bzl b/mobile_install/adapters/aar_import.bzl
index f1e5db9..bbef556 100644
--- a/mobile_install/adapters/aar_import.bzl
+++ b/mobile_install/adapters/aar_import.bzl
@@ -76,7 +76,7 @@
             dex_shards = dex(
                 ctx,
                 target[JavaInfo].runtime_output_jars,
-                target[JavaInfo].transitive_deps,
+                target[JavaInfo].transitive_compile_time_jars,
             ),
             deps = providers.collect(
                 MIAndroidDexInfo,
diff --git a/mobile_install/adapters/android_binary.bzl b/mobile_install/adapters/android_binary.bzl
index 98641e1..8ea2c04 100644
--- a/mobile_install/adapters/android_binary.bzl
+++ b/mobile_install/adapters/android_binary.bzl
@@ -82,7 +82,7 @@
                 ) +
                 (
                 ),
-                target[JavaInfo].transitive_deps,
+                target[JavaInfo].transitive_compile_time_jars,
             ),
             deps = providers.collect(MIAndroidDexInfo, ctx.rule.attr.deps),
         ),
diff --git a/mobile_install/adapters/android_library.bzl b/mobile_install/adapters/android_library.bzl
index be30a32..8681dba 100644
--- a/mobile_install/adapters/android_library.bzl
+++ b/mobile_install/adapters/android_library.bzl
@@ -83,7 +83,7 @@
                     ctx.label.name + "_resources.jar",
                     target[JavaInfo].runtime_output_jars,
                 ),
-                target[JavaInfo].transitive_deps,
+                target[JavaInfo].transitive_compile_time_jars,
             ),
             deps = providers.collect(
                 MIAndroidDexInfo,
diff --git a/mobile_install/adapters/java_import.bzl b/mobile_install/adapters/java_import.bzl
index d4b9c92..9f6e964 100644
--- a/mobile_install/adapters/java_import.bzl
+++ b/mobile_install/adapters/java_import.bzl
@@ -45,7 +45,7 @@
             dex_shards = dex(
                 ctx,
                 target[JavaInfo].runtime_output_jars,
-                target[JavaInfo].transitive_deps,
+                target[JavaInfo].transitive_compile_time_jars,
                 create_file = utils.declare_file,
             ),
             deps = providers.collect(
diff --git a/mobile_install/adapters/java_library.bzl b/mobile_install/adapters/java_library.bzl
index afeee64..7f8c902 100644
--- a/mobile_install/adapters/java_library.bzl
+++ b/mobile_install/adapters/java_library.bzl
@@ -44,7 +44,7 @@
             dex_shards = dex(
                 ctx,
                 target[JavaInfo].runtime_output_jars,
-                target[JavaInfo].transitive_deps,
+                target[JavaInfo].transitive_compile_time_jars,
             ),
             deps = providers.collect(
                 MIAndroidDexInfo,
diff --git a/mobile_install/adapters/java_lite_grpc_library.bzl b/mobile_install/adapters/java_lite_grpc_library.bzl
index 5eff4a8..7880080 100644
--- a/mobile_install/adapters/java_lite_grpc_library.bzl
+++ b/mobile_install/adapters/java_lite_grpc_library.bzl
@@ -36,7 +36,7 @@
             dex_shards = dex(
                 ctx,
                 target[JavaInfo].runtime_output_jars,
-                target[JavaInfo].transitive_deps,
+                target[JavaInfo].transitive_compile_time_jars,
             ),
             deps = providers.collect(
                 MIAndroidDexInfo,
diff --git a/mobile_install/adapters/proto_library.bzl b/mobile_install/adapters/proto_library.bzl
index 9e5d8da..fe402f0 100644
--- a/mobile_install/adapters/proto_library.bzl
+++ b/mobile_install/adapters/proto_library.bzl
@@ -38,7 +38,7 @@
             dex_shards = dex(
                 ctx,
                 [j.class_jar for j in target[JavaInfo].outputs.jars],
-                target[JavaInfo].transitive_deps,
+                target[JavaInfo].transitive_compile_time_jars,
             ),
             deps = providers.collect(MIAndroidDexInfo, ctx.rule.attr.deps),
         ),
diff --git a/mobile_install/constants.bzl b/mobile_install/constants.bzl
new file mode 100644
index 0000000..ab8306d
--- /dev/null
+++ b/mobile_install/constants.bzl
@@ -0,0 +1,25 @@
+# Copyright 2018 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Constants."""
+
+constants = struct(
+    # Immutable empty collections.
+    EMPTY_LIST = [],
+    EMPTY_DICT = dict(),
+
+    # Skylark Types
+    TYPE_DEPSET = type(depset()),
+    TYPE_DICT = type(dict()),
+    TYPE_LIST = type([]),
+)
diff --git a/mobile_install/debug.bzl b/mobile_install/debug.bzl
new file mode 100644
index 0000000..9acc44e
--- /dev/null
+++ b/mobile_install/debug.bzl
@@ -0,0 +1,44 @@
+# Copyright 2019 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Module that enables debugging for mobile-install."""
+
+def _make_output_groups(infos):
+    output_groups = dict()
+    for info in infos:
+        if hasattr(info, "info"):
+            output_group = dict(
+                mi_java_info = info.info.runtime_output_jars,
+            )
+        elif hasattr(info, "transitive_java_resources"):
+            output_group = dict(
+                mi_java_resources_info = info.transitive_java_resources,
+            )
+        elif hasattr(info, "transitive_native_libs"):
+            output_group = dict(
+                mi_aar_native_libs_info = info.transitive_native_libs,
+            )
+        elif hasattr(info, "transitive_dex_shards"):
+            output_group = dict(
+                mi_android_dex_info = depset(
+                    transitive = info.transitive_dex_shards,
+                ),
+            )
+        else:
+            fail("Unsupported provider %s" % info)
+        output_groups.update(output_group)
+    return output_groups
+
+debug = struct(
+    make_output_groups = _make_output_groups,
+)
diff --git a/mobile_install/dependency_map.bzl b/mobile_install/dependency_map.bzl
new file mode 100644
index 0000000..2769a90
--- /dev/null
+++ b/mobile_install/dependency_map.bzl
@@ -0,0 +1,63 @@
+# Copyright 2022 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""This file keeps track of the locations of binaries for Mobile-Install."""
+
+versioned_deps = struct(
+    mi_shell_app = struct(
+        head = "//tools/android:fail",
+    ),
+    android_kit = struct(
+        head = "//src/tools/ak",
+    ),
+    bootstraper = struct(
+        head = "//tools/android:fail",
+    ),
+    deploy = struct(
+        head = "//src/tools/mi/deployment:deploy_binary",
+    ),
+    deploy_info = struct(
+        head = "//src/tools/mi/deploy_info:deploy_info",
+    ),
+    forwarder = struct(
+        head = "//tools/android:fail",
+    ),
+    jar_tool = struct(
+        head = "@bazel_tools//tools/jdk:JavaBuilder_deploy.jar",
+    ),
+    make_sync = struct(
+        head = "//src/tools/mi/app_info:make_sync",
+    ),
+    merge_syncs = struct(
+        head = "//src/tools/mi/workspace:merge_syncs",
+    ),
+    pack_dexes = struct(
+        head = "//src/tools/mi/workspace:pack_dexes",
+    ),
+    pack_generic = struct(
+        head = "//src/tools/mi/workspace:pack_generic",
+    ),
+    res_v3_dummy_manifest = struct(
+        head = "//rules:res_v3_dummy_AndroidManifest.xml",
+    ),
+    res_v3_dummy_r_txt = struct(
+        head = "//rules:res_v3_dummy_R.txt",
+    ),
+    resource_extractor = struct(
+        head = "//src/tools/resource_extractor:main",
+    ),
+    sync_merger = struct(
+        head = "//src/tools/mi/app_info:sync_merger",
+    ),
+)
diff --git a/mobile_install/tools.bzl b/mobile_install/tools.bzl
new file mode 100644
index 0000000..5086779
--- /dev/null
+++ b/mobile_install/tools.bzl
@@ -0,0 +1,204 @@
+# Copyright 2018 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Tools needed by the mobile-install aspect defined as aspect attributes."""
+
+load(":dependency_map.bzl", "versioned_deps")
+
+TOOL_ATTRS = dict(
+    # Target Attrs
+    # This library should not be versioned. It needs to be built with the same
+    # config that is used to build the app. Android binds the application to a
+    # concrete achitecture during install time. If no libs are on the apk, it
+    # will select the most specific to the device is running. We want to use
+    # whatever the user builds as long as it is compatible. And since we push
+    # the native libs outside the apk to speed up transfer times, we need to
+    # use dummy libs.
+    _android_sdk = attr.label(
+        default = Label(
+            "@androidsdk//:sdk",
+        ),
+        allow_files = True,
+        cfg = "target",
+    ),
+    _flags = attr.label(
+        default = Label(
+            "//rules/flags",
+        ),
+    ),
+    _studio_deployer = attr.label(
+        default = "@androidsdk//:fail", # TODO(#119): Studio deployer jar to be released
+        allow_single_file = True,
+        cfg = "exec",
+        executable = True,
+    ),
+    _mi_shell_dummy_native_libs = attr.label(
+        default = Label(
+            "@androidsdk//:fail", # FIXME: Unused internally
+        ),
+        allow_single_file = True,
+        cfg = "target",
+    ),
+    _mi_shell_app = attr.label(
+        default = versioned_deps.mi_shell_app.head,
+        allow_files = True,
+        cfg = "target",
+        executable = True,
+    ),
+    _mi_java8_legacy_dex = attr.label(
+        default = Label("//tools/android:java8_legacy_dex"),
+        allow_single_file = True,
+        cfg = "target",
+    ),
+
+    # Host Attrs
+    _aapt2 = attr.label(
+        default = Label(
+            "@androidsdk//:aapt2_binary",
+        ),
+        allow_single_file = True,
+        cfg = "exec",
+        executable = True,
+    ),
+    _android_test_runner = attr.label(
+        default = Label(
+            "@bazel_tools//tools/jdk:TestRunner_deploy.jar",
+        ),
+        allow_single_file = True,
+        cfg = "exec",
+        executable = True,
+    ),
+    _apk_signer = attr.label(
+        default = Label("@androidsdk//:apksigner"),
+        allow_files = True,
+        cfg = "exec",
+        executable = True,
+    ),
+    _desugar_java8 = attr.label(
+        default = Label("//tools/android:desugar_java8"),
+        allow_files = True,
+        cfg = "exec",
+        executable = True,
+    ),
+    _d8 = attr.label(
+        default = Label("@bazel_tools//src/tools/android/java/com/google/devtools/build/android/r8:r8"),
+        allow_files = True,
+        cfg = "exec",
+        executable = True,
+    ),
+    _host_java_runtime = attr.label(
+        default = Label("//tools/jdk:current_host_java_runtime"),
+        cfg = "exec",
+    ),
+    _java_jdk = attr.label(
+        default = Label("//tools/jdk:current_java_runtime"),
+        allow_files = True,
+        cfg = "exec",
+    ),
+    _resource_busybox = attr.label(
+        default = Label("@bazel_tools//src/tools/android/java/com/google/devtools/build/android:ResourceProcessorBusyBox_deploy.jar"),
+        allow_files = True,
+        cfg = "exec",
+        executable = True,
+    ),
+    _zipalign = attr.label(
+        default = Label(
+            "@androidsdk//:zipalign_binary",
+        ),
+        allow_single_file = True,
+        cfg = "exec",
+        executable = True,
+    ),
+
+
+    # Versioned Host Attrs
+    _android_kit = attr.label(
+        default = versioned_deps.android_kit.head,
+        allow_files = True,
+        cfg = "exec",
+        executable = True,
+    ),
+    _deploy = attr.label(
+        default = versioned_deps.deploy.head,
+        allow_files = True,
+        cfg = "exec",
+        executable = True,
+    ),
+    _deploy_info = attr.label(
+        default = versioned_deps.deploy_info.head,
+        allow_files = True,
+        cfg = "exec",
+        executable = True,
+    ),
+    _jar_tool = attr.label(
+        default = versioned_deps.jar_tool.head,
+        allow_files = True,
+        cfg = "exec",
+        executable = True,
+    ),
+    _make_sync = attr.label(
+        default = versioned_deps.make_sync.head,
+        allow_files = True,
+        cfg = "exec",
+        executable = True,
+    ),
+    _merge_syncs = attr.label(
+        default = versioned_deps.merge_syncs.head,
+        allow_files = True,
+        cfg = "exec",
+        executable = True,
+    ),
+    _mi_android_java_toolchain = attr.label(
+        default = Label("//tools/jdk:toolchain_android_only"),
+    ),
+    _mi_java_toolchain = attr.label(
+        cfg = "exec",
+        default = Label("//tools/jdk:toolchain"),
+    ),
+    _mi_host_javabase = attr.label(
+        default = Label("//tools/jdk:current_host_java_runtime"),
+    ),
+    _pack_dexes = attr.label(
+        default = versioned_deps.pack_dexes.head,
+        allow_files = True,
+        cfg = "exec",
+        executable = True,
+    ),
+    _pack_generic = attr.label(
+        default = versioned_deps.pack_generic.head,
+        allow_files = True,
+        cfg = "exec",
+        executable = True,
+    ),
+    _res_v3_dummy_manifest = attr.label(
+        allow_single_file = True,
+        default = versioned_deps.res_v3_dummy_manifest.head,
+    ),
+    _res_v3_dummy_r_txt = attr.label(
+        allow_single_file = True,
+        default = versioned_deps.res_v3_dummy_r_txt.head,
+    ),
+    _resource_extractor = attr.label(
+        allow_single_file = True,
+        cfg = "exec",
+        default = versioned_deps.resource_extractor.head,
+        executable = True,
+    ),
+    _sync_merger = attr.label(
+        default = versioned_deps.sync_merger.head,
+        allow_files = True,
+        cfg = "exec",
+        executable = True,
+    ),
+
+)
diff --git a/mobile_install/transform.bzl b/mobile_install/transform.bzl
new file mode 100644
index 0000000..1371365
--- /dev/null
+++ b/mobile_install/transform.bzl
@@ -0,0 +1,157 @@
+# Copyright 2018 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Transform contains data transformation methods."""
+
+load(":constants.bzl", "constants")
+load(":utils.bzl", "utils")
+load("//rules/flags:flags.bzl", _flags = "flags")
+
+def _declare_file(ctx, filename, sibling = None):
+    return utils.isolated_declare_file(ctx, filename, sibling = sibling)
+
+def filter_jars(name, data):
+    """Filters out files that are not compiled Jars - includes header Jars.
+
+    Args:
+      name: Name of the file to filter, check uses endswith on the path.
+      data: The list of tuples where each entry contains the originating file path
+        and file to apply the filter.
+
+    Returns:
+      A list of tuples where each entry contains the originating Jar path and the
+      Jar file.
+    """
+    return [jar for jar in data if not jar.path.endswith(name)]
+
+def dex(
+        ctx,
+        data,
+        deps = constants.EMPTY_LIST,
+        num_shards = None,
+        create_file = _declare_file,
+        desugar = True):
+    """Dex a list of Jars.
+
+    Args:
+      ctx: The context.
+      data: The list of tuples where each entry contains the originating Jar
+        path and the Jar to Dex.
+      deps: The list of dependencies for the Jar being desugared.
+      num_shards: The number of shards to distribute the dexed files across,
+        this value overrides the default provided by ctx.attr._mi_dex_shards.
+      create_file: In rare occasions a custom method is required to
+        create a unique file, override the default here. The method must
+        implement the following interface:
+
+        def create_file(ctx, filename, sibling = None)
+        Args:
+          ctx: The context.
+          filename: string. The name of the file.
+          sibling: File. The location of the new file.
+
+        Returns:
+          A File.
+      desugar: A boolean that determines whether to apply desugaring.
+
+    Returns:
+      A list of tuples where each entry contains the originating Jar path and
+      the Dex shards.
+    """
+    if num_shards:
+        num_dex_shards = num_shards
+    elif _flags.get(ctx).use_custom_dex_shards:
+        num_dex_shards = _flags.get(ctx).num_dex_shards
+    else:
+        num_dex_shards = ctx.attr._mi_dex_shards
+
+    dex_files = []
+    for jar in data:
+        out_dex_shards = []
+        dirname = jar.basename + "_dex"
+        for i in range(num_shards or num_dex_shards):
+            out_dex_shards.append(create_file(
+                ctx,
+                dirname + "/" + str(i) + ".zip",
+                sibling = jar,
+            ))
+        utils.dex(ctx, jar, out_dex_shards, deps, desugar)
+        dex_files.append(out_dex_shards)
+    return dex_files
+
+def extract_jar_resources(ctx, data, create_file = _declare_file):
+    """Extracts the non-class files from the list of Jars.
+
+    Args:
+      ctx: The context
+      data: The list of tuples where each entry contains the originating Jar
+        path and the Jar with resources to extract.
+      create_file: In rare occasions a custom method is required to
+        create a unique file, override the default here. The method must
+        implement the following interface:
+
+        def create_file(ctx, filename, sibling = None)
+        Args:
+          ctx: The context.
+          filename: string. The name of the file.
+          sibling: File. The location of the new file.
+
+        Returns:
+          A File.
+
+    Returns:
+      A list of extracted resource zips.
+    """
+    resources_files = []
+    for jar in data:
+        out_resources_file = create_file(
+            ctx,
+            jar.basename + "_resources.zip",
+            sibling = jar,
+        )
+        utils.extract_jar_resources(ctx, jar, out_resources_file)
+        resources_files.append(out_resources_file)
+    return resources_files
+
+def merge_dex_shards(ctx, data, sibling):
+    """Merges all dex files in the transitive deps to a dex per shard.
+
+    Given a list of dex files (and resources.zips) this will create an
+    action per shard that runs dex_shard_merger on all dex files within that
+    shard.
+
+    Arguments:
+      ctx: The context.
+      data: A list of lists, where the inner list contains dex shards.
+      sibling: A file used to root the merged_dex shards.
+
+    Returns:
+      A list of merged dex shards.
+    """
+    merged_dex_shards = []
+    for idx, shard in enumerate(data):
+        #  To ensure resource is added at the beginning, R.zip is named as 00.zip
+        #  Thus data shards starts from 1 instead of 0 and ranges through 16
+        idx += 1
+
+        # Shards are sorted before deployment, to ensure all shards are correctly
+        # ordered 0 is padded to single digit shard counts
+        shard_name = "%s%s" % ("00"[len(str(idx)):], idx)
+        merged_dex_shard = utils.isolated_declare_file(
+            ctx,
+            "dex_shards/" + shard_name + ".zip",
+            sibling = sibling,
+        )
+        utils.merge_dex_shards(ctx, shard, merged_dex_shard)
+        merged_dex_shards.append(merged_dex_shard)
+    return merged_dex_shards
diff --git a/rules/BUILD b/rules/BUILD
index 3648349..f31dd0a 100644
--- a/rules/BUILD
+++ b/rules/BUILD
@@ -26,6 +26,7 @@
     srcs = [
         "aapt.bzl",
         "acls.bzl",
+        "android_neverlink_aspect.bzl",
         "attrs.bzl",
         "bundletool.bzl",
         "busybox.bzl",
@@ -51,3 +52,37 @@
         "//rules/flags:bzl",
     ],
 )
+
+bzl_library(
+    name = "android_binary_bzl",
+    srcs = [
+        "android_binary.bzl",
+    ],
+    visibility = ["//:__subpackages__"],
+    deps = [
+        "//rules/android_binary_internal:bzl",
+    ],
+)
+
+bzl_library(
+    name = "bzl",
+    srcs = [
+        "android_ndk_repository.bzl",
+        "android_sdk.bzl",
+        "android_sdk_repository.bzl",
+        "android_tools_defaults_jar.bzl",
+        "baseline_profiles.bzl",
+        "dex.bzl",
+        "dex_desugar_aspect.bzl",
+        "rules.bzl",
+    ],
+    visibility = ["//mobile_install:__pkg__"],
+    deps = [
+        ":android_binary_bzl",
+        ":common_bzl",
+        "//rules/aar_import:bzl",
+        "//rules/android_library:bzl",
+        "//rules/android_sandboxed_sdk:bzl",
+        "//rules/flags:bzl",
+    ],
+)
diff --git a/rules/aar_import/BUILD b/rules/aar_import/BUILD
index b57f593..fd6bded 100644
--- a/rules/aar_import/BUILD
+++ b/rules/aar_import/BUILD
@@ -14,6 +14,7 @@
 bzl_library(
     name = "bzl",
     srcs = glob(["*.bzl"]),
+    visibility = ["//rules:__pkg__"],
     deps = [
         "//rules:common_bzl",
         "//rules/flags:bzl",
diff --git a/rules/aar_import/attrs.bzl b/rules/aar_import/attrs.bzl
index 022231b..c8b2659 100644
--- a/rules/aar_import/attrs.bzl
+++ b/rules/aar_import/attrs.bzl
@@ -70,4 +70,5 @@
     ),
     _attrs.DATA_CONTEXT,
     _attrs.ANDROID_TOOLCHAIN_ATTRS,
+    _attrs.AUTOMATIC_EXEC_GROUPS_ENABLED,
 )
diff --git a/rules/aar_import/impl.bzl b/rules/aar_import/impl.bzl
index d551085..0b149bb 100644
--- a/rules/aar_import/impl.bzl
+++ b/rules/aar_import/impl.bzl
@@ -34,6 +34,7 @@
 )
 load(
     "//rules:utils.bzl",
+    "ANDROID_TOOLCHAIN_TYPE",
     _get_android_toolchain = "get_android_toolchain",
     _utils = "utils",
 )
@@ -81,6 +82,7 @@
             ),
         mnemonic = "AarFileExtractor",
         progress_message = "Extracting %s from %s" % (filename, aar.basename),
+        toolchain = ANDROID_TOOLCHAIN_TYPE,
     )
 
 def _extract_resources(
@@ -100,6 +102,7 @@
         outputs = [out_resources_dir, out_assets_dir],
         mnemonic = "AarResourcesExtractor",
         progress_message = "Extracting resources and assets from %s" % aar.basename,
+        toolchain = None,
     )
 
 def _extract_native_libs(
@@ -119,6 +122,7 @@
         outputs = [output_zip],
         mnemonic = "AarNativeLibsFilter",
         progress_message = "Filtering AAR native libs by architecture",
+        toolchain = None,
     )
 
 def _process_resources(
@@ -192,6 +196,7 @@
         outputs = [out_jars_tree_artifact, out_jars_params_file],
         mnemonic = "AarEmbeddedJarsExtractor",
         progress_message = "Extracting classes.jar and libs/*.jar from %s" % aar.basename,
+        toolchain = None,
     )
 
 def _merge_jars(
@@ -212,6 +217,7 @@
         outputs = [out_jar],
         mnemonic = "AarJarsMerger",
         progress_message = "Merging AAR embedded jars",
+        toolchain = None,
     )
 
 def _extract_and_merge_jars(
@@ -383,6 +389,7 @@
         outputs = [validation_output],
         mnemonic = "ValidateAAR",
         progress_message = "Validating aar_import %s" % str(ctx.label),
+        toolchain = None,
     )
     return validation_output
 
@@ -431,6 +438,7 @@
         outputs = [out_proguard],
         mnemonic = "AarEmbeddedProguardExtractor",
         progress_message = "Extracting proguard spec from %s" % aar.basename,
+        toolchain = None,
     )
     transitive_proguard_specs = []
     for p in _utils.collect_providers(ProguardSpecProvider, ctx.attr.deps, ctx.attr.exports):
diff --git a/rules/acls.bzl b/rules/acls.bzl
index 4fa01c4..9cd6f3f 100644
--- a/rules/acls.bzl
+++ b/rules/acls.bzl
@@ -42,6 +42,7 @@
 load("//rules/acls:android_instrumentation_binary_starlark_resources.bzl", "ANDROID_INSTRUMENTATION_BINARY_STARLARK_RESOURCES_FALLBACK", "ANDROID_INSTRUMENTATION_BINARY_STARLARK_RESOURCES_ROLLOUT")
 load("//rules/acls:android_binary_starlark_javac.bzl", "ANDROID_BINARY_STARLARK_JAVAC_FALLBACK", "ANDROID_BINARY_STARLARK_JAVAC_ROLLOUT")
 load("//rules/acls:android_binary_starlark_split_transition.bzl", "ANDROID_BINARY_STARLARK_SPLIT_TRANSITION_FALLBACK", "ANDROID_BINARY_STARLARK_SPLIT_TRANSITION_ROLLOUT")
+load("//rules/acls:android_binary_with_sandboxed_sdks_allowlist.bzl", "ANDROID_BINARY_WITH_SANDBOXED_SDKS_ALLOWLIST")
 load("//rules/acls:android_feature_splits_dogfood.bzl", "ANDROID_FEATURE_SPLITS_DOGFOOD")
 load("//rules/acls:android_library_resources_without_srcs.bzl", "ANDROID_LIBRARY_RESOURCES_WITHOUT_SRCS", "ANDROID_LIBRARY_RESOURCES_WITHOUT_SRCS_GENERATOR_FUNCTIONS")
 load("//rules/acls:android_library_starlark_resource_outputs.bzl", "ANDROID_LIBRARY_STARLARK_RESOURCE_OUTPUTS_FALLBACK", "ANDROID_LIBRARY_STARLARK_RESOURCE_OUTPUTS_ROLLOUT")
@@ -78,7 +79,10 @@
 load("//rules/acls:shared_library_resource_linking.bzl", "SHARED_LIBRARY_RESOURCE_LINKING_ALLOWLIST")
 load("//rules/acls:android_binary_starlark_dex_desugar_proguard.bzl", "ANDROID_BINARY_STARLARK_DEX_DESUGAR_PROGUARD_FALLBACK", "ANDROID_BINARY_STARLARK_DEX_DESUGAR_PROGUARD_ROLLOUT")
 load("//rules/acls:android_binary_min_sdk_version_attribute.bzl", "ANDROID_BINARY_MIN_SDK_VERSION_ATTRIBUTE_ALLOWLIST")
+load("//rules/acls:android_binary_raw_access_to_resource_paths_allowlist.bzl", "ANDROID_BINARY_RAW_ACCESS_TO_RESOURCE_PATHS_ALLOWLIST")
+load("//rules/acls:android_binary_resource_name_obfuscation_opt_out_allowlist.bzl", "ANDROID_BINARY_RESOURCE_NAME_OBFUSCATION_OPT_OUT_ALLOWLIST")
 load("//rules/acls:proguard_apply_mapping.bzl", "ALLOW_PROGUARD_APPLY_MAPPING")
+load("//rules/acls:r8.bzl", "USE_R8")
 
 def _in_aar_import_deps_checker(fqn):
     return not matches(fqn, AAR_IMPORT_DEPS_CHECKER_FALLBACK_DICT) and matches(fqn, AAR_IMPORT_DEPS_CHECKER_ROLLOUT_DICT)
@@ -110,6 +114,9 @@
 def _in_android_binary_starlark_split_transition(fqn):
     return not matches(fqn, ANDROID_BINARY_STARLARK_SPLIT_TRANSITION_FALLBACK_DICT) and matches(fqn, ANDROID_BINARY_STARLARK_SPLIT_TRANSITION_ROLLOUT_DICT)
 
+def _in_android_binary_with_sandboxed_sdks_allowlist(fqn):
+    return matches(fqn, ANDROID_BINARY_WITH_SANDBOXED_SDKS_ALLOWLIST_DICT)
+
 def _in_android_feature_splits_dogfood(fqn):
     return matches(fqn, ANDROID_FEATURE_SPLITS_DOGFOOD_DICT)
 
@@ -226,9 +233,18 @@
 def _in_android_binary_min_sdk_version_attribute_allowlist(fqn):
     return matches(fqn, ANDROID_BINARY_MIN_SDK_VERSION_ATTRIBUTE_DICT)
 
+def _in_android_binary_raw_access_to_resource_paths_allowlist(fqn):
+    return matches(fqn, ANDROID_BINARY_RAW_ACCESS_TO_RESOURCE_PATHS_ALLOWLIST_DICT)
+
+def _in_android_binary_resource_name_obfuscation_opt_out_allowlist(fqn):
+    return matches(fqn, ANDROID_BINARY_RESOURCE_NAME_OBFUSCATION_OPT_OUT_ALLOWLIST_DICT)
+
 def _in_allow_proguard_apply_mapping(fqn):
     return matches(fqn, ALLOW_PROGUARD_APPLY_MAPPING_DICT)
 
+def _use_r8(fqn):
+    return matches(fqn, USE_R8_DICT)
+
 def make_dict(lst):
     """Do not use this method outside of acls directory."""
     return {t: True for t in lst}
@@ -249,6 +265,7 @@
 ANDROID_BINARY_STARLARK_JAVAC_FALLBACK_DICT = make_dict(ANDROID_BINARY_STARLARK_JAVAC_FALLBACK)
 ANDROID_BINARY_STARLARK_SPLIT_TRANSITION_ROLLOUT_DICT = make_dict(ANDROID_BINARY_STARLARK_SPLIT_TRANSITION_ROLLOUT)
 ANDROID_BINARY_STARLARK_SPLIT_TRANSITION_FALLBACK_DICT = make_dict(ANDROID_BINARY_STARLARK_SPLIT_TRANSITION_FALLBACK)
+ANDROID_BINARY_WITH_SANDBOXED_SDKS_ALLOWLIST_DICT = make_dict(ANDROID_BINARY_WITH_SANDBOXED_SDKS_ALLOWLIST)
 ANDROID_FEATURE_SPLITS_DOGFOOD_DICT = make_dict(ANDROID_FEATURE_SPLITS_DOGFOOD)
 ANDROID_LIBRARY_RESOURCES_WITHOUT_SRCS_DICT = make_dict(ANDROID_LIBRARY_RESOURCES_WITHOUT_SRCS)
 ANDROID_LIBRARY_RESOURCES_WITHOUT_SRCS_GENERATOR_FUNCTIONS_DICT = make_dict(ANDROID_LIBRARY_RESOURCES_WITHOUT_SRCS_GENERATOR_FUNCTIONS)
@@ -307,7 +324,10 @@
 ANDROID_BINARY_STARLARK_DEX_DESUGAR_PROGUARD_ROLLOUT_DICT = make_dict(ANDROID_BINARY_STARLARK_DEX_DESUGAR_PROGUARD_ROLLOUT)
 ANDROID_BINARY_STARLARK_DEX_DESUGAR_PROGUARD_FALLBACK_DICT = make_dict(ANDROID_BINARY_STARLARK_DEX_DESUGAR_PROGUARD_FALLBACK)
 ANDROID_BINARY_MIN_SDK_VERSION_ATTRIBUTE_DICT = make_dict(ANDROID_BINARY_MIN_SDK_VERSION_ATTRIBUTE_ALLOWLIST)
+ANDROID_BINARY_RAW_ACCESS_TO_RESOURCE_PATHS_ALLOWLIST_DICT = make_dict(ANDROID_BINARY_RAW_ACCESS_TO_RESOURCE_PATHS_ALLOWLIST)
+ANDROID_BINARY_RESOURCE_NAME_OBFUSCATION_OPT_OUT_ALLOWLIST_DICT = make_dict(ANDROID_BINARY_RESOURCE_NAME_OBFUSCATION_OPT_OUT_ALLOWLIST)
 ALLOW_PROGUARD_APPLY_MAPPING_DICT = make_dict(ALLOW_PROGUARD_APPLY_MAPPING)
+USE_R8_DICT = make_dict(USE_R8)
 
 def matches(fqn, dct):
     # Labels with workspace names ("@workspace//pkg:target") are not supported.
@@ -362,6 +382,7 @@
     in_android_instrumentation_binary_starlark_resources = _in_android_instrumentation_binary_starlark_resources,
     in_android_binary_starlark_javac = _in_android_binary_starlark_javac,
     in_android_binary_starlark_split_transition = _in_android_binary_starlark_split_transition,
+    in_android_binary_with_sandboxed_sdks_allowlist = _in_android_binary_with_sandboxed_sdks_allowlist,
     in_android_feature_splits_dogfood = _in_android_feature_splits_dogfood,
     in_android_library_starlark_resource_outputs_rollout = _in_android_library_starlark_resource_outputs_rollout,
     in_android_library_resources_without_srcs = _in_android_library_resources_without_srcs,
@@ -396,7 +417,10 @@
     in_shared_library_resource_linking_allowlist = _in_shared_library_resource_linking_allowlist,
     in_android_binary_starlark_dex_desugar_proguard = _in_android_binary_starlark_dex_desugar_proguard,
     in_android_binary_min_sdk_version_attribute_allowlist = _in_android_binary_min_sdk_version_attribute_allowlist,
+    in_android_binary_raw_access_to_resource_paths_allowlist = _in_android_binary_raw_access_to_resource_paths_allowlist,
+    in_android_binary_resource_name_obfuscation_opt_out_allowlist = _in_android_binary_resource_name_obfuscation_opt_out_allowlist,
     in_allow_proguard_apply_mapping = _in_allow_proguard_apply_mapping,
+    use_r8 = _use_r8,
 )
 
 # Visible for testing
diff --git a/rules/acls/android_binary_raw_access_to_resource_paths_allowlist.bzl b/rules/acls/android_binary_raw_access_to_resource_paths_allowlist.bzl
new file mode 100644
index 0000000..3829883
--- /dev/null
+++ b/rules/acls/android_binary_raw_access_to_resource_paths_allowlist.bzl
@@ -0,0 +1,17 @@
+# Copyright 2023 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Allow list for android_binary targets with raw access to resource paths in the APK ."""
+ANDROID_BINARY_RAW_ACCESS_TO_RESOURCE_PATHS_ALLOWLIST = [
+]
diff --git a/rules/acls/android_binary_resource_name_obfuscation_opt_out_allowlist.bzl b/rules/acls/android_binary_resource_name_obfuscation_opt_out_allowlist.bzl
new file mode 100644
index 0000000..aa5fb0b
--- /dev/null
+++ b/rules/acls/android_binary_resource_name_obfuscation_opt_out_allowlist.bzl
@@ -0,0 +1,16 @@
+# Copyright 2023 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Allow list of android_binary targets that need to opt out of the AAPT2 resource name obfuscation optimization."""
+ANDROID_BINARY_RESOURCE_NAME_OBFUSCATION_OPT_OUT_ALLOWLIST = []
diff --git a/rules/acls/android_binary_starlark_dex_desugar_proguard.bzl b/rules/acls/android_binary_starlark_dex_desugar_proguard.bzl
index 1f7ec8b..ade3550 100644
--- a/rules/acls/android_binary_starlark_dex_desugar_proguard.bzl
+++ b/rules/acls/android_binary_starlark_dex_desugar_proguard.bzl
@@ -16,8 +16,9 @@
 
 # keep sorted
 ANDROID_BINARY_STARLARK_DEX_DESUGAR_PROGUARD_ROLLOUT = [
-    "//test/rules/android_binary_internal:__subpackages__",
+    "//:__subpackages__",
 ]
 
 # keep sorted
-ANDROID_BINARY_STARLARK_DEX_DESUGAR_PROGUARD_FALLBACK = []
+ANDROID_BINARY_STARLARK_DEX_DESUGAR_PROGUARD_FALLBACK = [
+]
diff --git a/rules/acls/android_binary_with_sandboxed_sdks_allowlist.bzl b/rules/acls/android_binary_with_sandboxed_sdks_allowlist.bzl
new file mode 100644
index 0000000..b926437
--- /dev/null
+++ b/rules/acls/android_binary_with_sandboxed_sdks_allowlist.bzl
@@ -0,0 +1,20 @@
+# Copyright 2023 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Allow list of android_binary_with_sandboxed_sdks rule."""
+
+# keep sorted
+ANDROID_BINARY_WITH_SANDBOXED_SDKS_ALLOWLIST = [
+    "//:__subpackages__",
+]
diff --git a/rules/acls/r8.bzl b/rules/acls/r8.bzl
new file mode 100644
index 0000000..bbc75ac
--- /dev/null
+++ b/rules/acls/r8.bzl
@@ -0,0 +1,20 @@
+# Copyright 2023 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Allowlist for R8"""
+
+# keep sorted
+USE_R8 = [
+    "//:__subpackages__",
+]
diff --git a/rules/android_application/BUILD b/rules/android_application/BUILD
index 47e783a..dadeefb 100644
--- a/rules/android_application/BUILD
+++ b/rules/android_application/BUILD
@@ -1,5 +1,6 @@
 # The android_application rule.
 
+load("@rules_python//python:defs.bzl", "py_binary")
 load("@bazel_skylib//:bzl_library.bzl", "bzl_library")
 
 licenses(["notice"])
@@ -43,4 +44,3 @@
     output_group = "python_zip_file",
     visibility = ["//visibility:public"],
 )
-
diff --git a/rules/android_binary.bzl b/rules/android_binary.bzl
index be9e7b0..b754e61 100644
--- a/rules/android_binary.bzl
+++ b/rules/android_binary.bzl
@@ -17,6 +17,7 @@
 load(":common.bzl", "common")
 load(":migration_tag_DONOTUSE.bzl", "add_migration_tag")
 load("//rules/android_binary_internal:rule.bzl", "android_binary_internal_macro")
+load("//rules:acls.bzl", "acls")
 
 def android_binary(**attrs):
     """Bazel android_binary rule.
@@ -37,9 +38,22 @@
 
     attrs.pop("$enable_manifest_merging", None)
 
+    # dex_shards is deprecated and unused. This only existed for mobile-install classic which has
+    # been replaced by mobile-install v2
+    attrs.pop("dex_shards", None)
+
     # resource_apks is not used by the native android_binary
     attrs.pop("resource_apks", None)
 
+    fqn = "//%s:%s" % (native.package_name(), attrs["name"])
+    if acls.use_r8(fqn):
+        # Do not pass proguard specs to the native android_binary so that it does
+        # not try to use proguard and instead uses the dex files from the
+        # AndroidDexInfo provider from android_binary_internal.
+        # This also disables resource shrinking from native android_binary (reguardless of the
+        # shrink_resources attr).
+        attrs["proguard_specs"] = []
+
     native.android_binary(
         application_resources = android_binary_internal_name,
         **add_migration_tag(attrs)
diff --git a/rules/android_binary_internal/attrs.bzl b/rules/android_binary_internal/attrs.bzl
index 2a3e614..0fadb06 100644
--- a/rules/android_binary_internal/attrs.bzl
+++ b/rules/android_binary_internal/attrs.bzl
@@ -103,6 +103,9 @@
             incremental_dexing = _attrs.tristate.create(
                 default = _attrs.tristate.auto,
             ),
+            proguard_generate_mapping = attr.bool(default = False),
+            proguard_optimization_passes = attr.int(),
+            proguard_apply_mapping = attr.label(allow_single_file = True),
             _java_toolchain = attr.label(
                 default = Label("//tools/jdk:toolchain_android_only"),
             ),
@@ -113,10 +116,31 @@
                 default = "@bazel_tools//tools/cpp:current_cc_toolchain",
                 aspects = [split_config_aspect],
             ),
+            _optimizing_dexer = attr.label(
+                cfg = "exec",
+                allow_single_file = True,
+                default = configuration_field(
+                    fragment = "android",
+                    name = "optimizing_dexer",
+                ),
+            ),
+            _desugared_java8_legacy_apis = attr.label(
+                default = Label("//tools/android:desugared_java8_legacy_apis"),
+                allow_single_file = True,
+            ),
+            _bytecode_optimizer = attr.label(
+                default = configuration_field(
+                    fragment = "java",
+                    name = "bytecode_optimizer",
+                ),
+                cfg = "exec",
+                executable = True,
+            ),
         ),
         _attrs.COMPILATION,
         _attrs.DATA_CONTEXT,
         _attrs.ANDROID_TOOLCHAIN_ATTRS,
+        _attrs.AUTOMATIC_EXEC_GROUPS_ENABLED,
     ),
     # TODO(b/167599192): don't override manifest attr to remove .xml file restriction.
     manifest = attr.label(
diff --git a/rules/android_binary_internal/impl.bzl b/rules/android_binary_internal/impl.bzl
index ef77e2a..23de739 100644
--- a/rules/android_binary_internal/impl.bzl
+++ b/rules/android_binary_internal/impl.bzl
@@ -14,18 +14,27 @@
 
 """Implementation."""
 
+load(":r8.bzl", "process_r8", "process_resource_shrinking_r8")
 load("//rules:acls.bzl", "acls")
 load("//rules:baseline_profiles.bzl", _baseline_profiles = "baseline_profiles")
 load("//rules:common.bzl", "common")
 load("//rules:data_binding.bzl", "data_binding")
 load("//rules:java.bzl", "java")
+load("//rules:proguard.bzl", "proguard", proguard_testing = "testing")
 load(
     "//rules:processing_pipeline.bzl",
     "ProviderInfo",
     "processing_pipeline",
 )
 load("//rules:resources.bzl", _resources = "resources")
-load("//rules:utils.bzl", "compilation_mode", "get_android_toolchain", "utils")
+load(
+    "//rules:utils.bzl",
+    "ANDROID_TOOLCHAIN_TYPE",
+    "compilation_mode",
+    "get_android_sdk",
+    "get_android_toolchain",
+    "utils",
+)
 load(
     "//rules:native_deps.bzl",
     _process_native_deps = "process",
@@ -76,12 +85,15 @@
         resource_apks = resource_apks,
         instruments = ctx.attr.instruments,
         aapt = get_android_toolchain(ctx).aapt2.files_to_run,
-        android_jar = ctx.attr._android_sdk[AndroidSdkInfo].android_jar,
+        android_jar = get_android_sdk(ctx).android_jar,
         legacy_merger = ctx.attr._android_manifest_merge_tool.files_to_run,
         xsltproc = ctx.attr._xsltproc_tool.files_to_run,
         instrument_xslt = ctx.file._add_g3itr_xslt,
         busybox = get_android_toolchain(ctx).android_resources_busybox.files_to_run,
         host_javabase = ctx.attr._host_javabase,
+        # The AndroidApplicationResourceInfo will be added to the list of providers in finalize()
+        # if R8-based resource shrinking is not performed.
+        add_application_resource_info_to_providers = False,
     )
     return ProviderInfo(
         name = "packaged_resources_ctx",
@@ -124,6 +136,15 @@
         ),
     )
 
+def _process_proto(_unused_ctx, **_unused_ctxs):
+    return ProviderInfo(
+        name = "proto_ctx",
+        value = struct(
+            providers = [],
+            class_jar = None,
+        ),
+    )
+
 def _process_data_binding(ctx, java_package, packaged_resources_ctx, **_unused_ctxs):
     if ctx.attr.enable_data_binding and not acls.in_databinding_allowed(str(ctx.label)):
         fail("This target is not allowed to use databinding and enable_data_binding is True.")
@@ -194,7 +215,7 @@
         ),
     )
 
-def _process_dex(ctx, stamp_ctx, packaged_resources_ctx, jvm_ctx, deploy_ctx, **_unused_ctxs):
+def _process_dex(ctx, stamp_ctx, packaged_resources_ctx, jvm_ctx, proto_ctx, deploy_ctx, **_unused_ctxs):
     providers = []
     classes_dex_zip = None
     dex_info = None
@@ -205,10 +226,12 @@
     if acls.in_android_binary_starlark_dex_desugar_proguard(str(ctx.label)):
         java_info = java_common.merge([jvm_ctx.java_info, stamp_ctx.java_info]) if stamp_ctx.java_info else jvm_ctx.java_info
         runtime_jars = java_info.runtime_output_jars + [packaged_resources_ctx.class_jar]
+        if proto_ctx.class_jar:
+            runtime_jars.append(proto_ctx.class_jar)
         forbidden_dexopts = ctx.fragments.android.get_target_dexopts_that_prevent_incremental_dexing
         java8_legacy_dex, java8_legacy_dex_map = _dex.get_java8_legacy_dex_and_map(
             ctx,
-            android_jar = ctx.attr._android_sdk[AndroidSdkInfo].android_jar,
+            android_jar = get_android_sdk(ctx).android_jar,
             binary_jar = deploy_jar,
             build_customized_files = is_binary_optimized,
         )
@@ -235,6 +258,7 @@
                 desugar_dict = deploy_ctx.desugar_dict,
                 dexbuilder = get_android_toolchain(ctx).dexbuilder.files_to_run,
                 dexmerger = get_android_toolchain(ctx).dexmerger.files_to_run,
+                toolchain_type = ANDROID_TOOLCHAIN_TYPE,
             )
 
         if ctx.fragments.android.desugar_java8_libs and classes_dex_zip.extension == "zip":
@@ -264,7 +288,7 @@
         ),
     )
 
-def _process_deploy_jar(ctx, stamp_ctx, packaged_resources_ctx, jvm_ctx, build_info_ctx, **_unused_ctxs):
+def _process_deploy_jar(ctx, stamp_ctx, packaged_resources_ctx, jvm_ctx, build_info_ctx, proto_ctx, **_unused_ctxs):
     deploy_jar, desugar_dict = None, {}
 
     if acls.in_android_binary_starlark_dex_desugar_proguard(str(ctx.label)):
@@ -274,6 +298,9 @@
         incremental_dexopts = _dex.incremental_dexopts(ctx.attr.dexopts, ctx.fragments.android.get_dexopts_supported_in_incremental_dexing)
         dex_archives = info.dex_archives_dict.get("".join(incremental_dexopts), depset()).to_list()
         binary_runtime_jars = java_info.runtime_output_jars + [packaged_resources_ctx.class_jar]
+        if proto_ctx.class_jar:
+            binary_runtime_jars.append(proto_ctx.class_jar)
+
         if ctx.fragments.android.desugar_java8:
             desugared_jars = []
             desugar_dict = {d.jar: d.desugared_jar for d in dex_archives}
@@ -288,6 +315,7 @@
                     bootclasspath = java_toolchain[java_common.JavaToolchainInfo].bootclasspath.to_list(),
                     min_sdk_version = ctx.attr.min_sdk_version,
                     desugar_exec = get_android_toolchain(ctx).desugar.files_to_run,
+                    toolchain_type = ANDROID_TOOLCHAIN_TYPE,
                 )
                 desugared_jars.append(desugared_jar)
                 desugar_dict[jar] = desugared_jar
@@ -298,7 +326,7 @@
 
             runtime_jars = depset(desugared_jars)
         else:
-            runtime_jars = depset(binary_runtime_jars, transitive = [java_info.transitive_runtime_jar])
+            runtime_jars = depset(binary_runtime_jars, transitive = [java_info.transitive_runtime_jars])
 
         output = ctx.actions.declare_file(ctx.label.name + "_migrated_deploy.jar")
         deploy_jar = java.create_deploy_jar(
@@ -310,6 +338,21 @@
             deploy_manifest_lines = build_info_ctx.deploy_manifest_lines,
         )
 
+        if _is_instrumentation(ctx):
+            filtered_deploy_jar = ctx.actions.declare_file(ctx.label.name + "_migrated_filtered.jar")
+            filter_jar = ctx.attr.instruments[AndroidPreDexJarInfo].pre_dex_jar
+            common.filter_zip_exclude(
+                ctx,
+                output = filtered_deploy_jar,
+                input = deploy_jar,
+                filter_zips = [filter_jar],
+                filter_types = [".class"],
+                # These files are generated by databinding in both the target and the instrumentation
+                # app with different contents. We want to keep the one from the target app.
+                filters = ["/BR\\.class$", "/databinding/[^/]+Binding\\.class$"],
+            )
+            deploy_jar = filtered_deploy_jar
+
     return ProviderInfo(
         name = "deploy_ctx",
         value = struct(
@@ -338,12 +381,43 @@
 
     return manifest_merger == "legacy"
 
-def finalize(ctx, providers, validation_outputs, **unused_ctxs):
+def finalize(
+        _unused_ctx,
+        providers,
+        validation_outputs,
+        packaged_resources_ctx,
+        resource_shrinking_r8_ctx,
+        **_unused_ctxs):
+    """Final step of the android_binary_internal processor pipeline.
+
+    Args:
+      _unused_ctx: The context.
+      providers: The list of providers for the android_binary_internal rule.
+      validation_outputs: Validation outputs for the rule.
+      packaged_resources_ctx: The packaged resources from the resource processing step.
+      resource_shrinking_r8_ctx: The context from the R8 resource shrinking step.
+      **_unused_ctxs: Other contexts.
+
+    Returns:
+      The list of providers the android_binary_internal rule should return.
+    """
     providers.append(
         OutputGroupInfo(
             _validation = depset(validation_outputs),
         ),
     )
+
+    # Add the AndroidApplicationResourceInfo provider from resource shrinking if it was performed.
+    # TODO(ahumesky): This can be cleaned up after the rules are fully migrated to Starlark.
+    # Packaging will be the final step in the pipeline, and that step can be responsible for picking
+    # between the two different contexts. Then this finalize can return back to its "simple" form.
+    if resource_shrinking_r8_ctx.android_application_resource_info_with_shrunk_resource_apk:
+        providers.append(
+            resource_shrinking_r8_ctx.android_application_resource_info_with_shrunk_resource_apk,
+        )
+    else:
+        providers.append(packaged_resources_ctx.android_application_resource)
+
     return providers
 
 def _is_test_binary(ctx):
@@ -355,7 +429,19 @@
     Returns:
       Boolean indicating whether the target is a test target.
     """
-    return ctx.attr.testonly or ctx.attr.instruments or str(ctx.label).find("/javatests/") >= 0
+    return ctx.attr.testonly or _is_instrumentation(ctx) or str(ctx.label).find("/javatests/") >= 0
+
+def _is_instrumentation(ctx):
+    """Whether this android_binary target is an instrumentation binary.
+
+    Args:
+      ctx: The context.
+
+    Returns:
+      Boolean indicating whether the target is an instrumentation target.
+
+    """
+    return bool(ctx.attr.instruments)
 
 def _process_baseline_profiles(ctx, dex_ctx, **_unused_ctxs):
     providers = []
@@ -383,6 +469,92 @@
         value = struct(providers = providers),
     )
 
+def _process_optimize(ctx, deploy_ctx, packaged_resources_ctx, **_unused_ctxs):
+    if not acls.in_android_binary_starlark_dex_desugar_proguard(str(ctx.label)):
+        return ProviderInfo(
+            name = "optimize_ctx",
+            value = struct(),
+        )
+
+    # Validate attributes and lockdown lists
+    if ctx.file.proguard_apply_mapping and not acls.in_allow_proguard_apply_mapping(ctx.label):
+        fail("proguard_apply_mapping is not supported")
+    if ctx.file.proguard_apply_mapping and not ctx.files.proguard_specs:
+        fail("proguard_apply_mapping can only be used when proguard_specs is set")
+
+    proguard_specs = proguard.get_proguard_specs(
+        ctx,
+        packaged_resources_ctx.resource_proguard_config,
+        proguard_specs_for_manifest = [packaged_resources_ctx.resource_minsdk_proguard_config] if packaged_resources_ctx.resource_minsdk_proguard_config else [],
+    )
+    has_proguard_specs = bool(proguard_specs)
+    proguard_output = struct()
+
+    proguard_output_map = None
+    generate_proguard_map = (
+        ctx.attr.proguard_generate_mapping or
+        _resources.is_resource_shrinking_enabled(
+            ctx.attr.shrink_resources,
+            ctx.fragments.android.use_android_resource_shrinking,
+        )
+    )
+    desugar_java8_libs_generates_map = ctx.fragments.android.desugar_java8
+    optimizing_dexing = bool(ctx.attr._optimizing_dexer)
+
+    # TODO(b/261110876): potentially add codepaths below to support rex (postprocessingRewritesMap)
+    if generate_proguard_map:
+        # Determine the output of the Proguard map from shrinking the app. This depends on the
+        # additional steps which can process the map before the final Proguard map artifact is
+        # generated.
+        if not has_proguard_specs:
+            # When no shrinking happens a generating rule for the output map artifact is still needed.
+            proguard_output_map = proguard.get_proguard_output_map(ctx)
+        elif optimizing_dexing:
+            proguard_output_map = proguard.get_proguard_temp_artifact(ctx, "pre_dexing.map")
+        elif desugar_java8_libs_generates_map:
+            # Proguard map from shrinking will be merged with desugared library proguard map.
+            proguard_output_map = _dex.get_dx_artifact(ctx, "_proguard_output_for_desugared_library.map")
+        else:
+            # Proguard map from shrinking is the final output.
+            proguard_output_map = proguard.get_proguard_output_map(ctx)
+
+    proguard_output_jar = ctx.actions.declare_file(ctx.label.name + "_migrated_proguard.jar")
+    proguard_seeds = ctx.actions.declare_file(ctx.label.name + "_migrated_proguard.seeds")
+    proguard_usage = ctx.actions.declare_file(ctx.label.name + "_migrated_proguard.usage")
+
+    proguard_output = proguard.apply_proguard(
+        ctx,
+        input_jar = deploy_ctx.deploy_jar,
+        proguard_specs = proguard_specs,
+        proguard_optimization_passes = getattr(ctx.attr, "proguard_optimization_passes", None),
+        proguard_output_jar = proguard_output_jar,
+        proguard_mapping = ctx.file.proguard_apply_mapping,
+        proguard_output_map = proguard_output_map,
+        proguard_seeds = proguard_seeds,
+        proguard_usage = proguard_usage,
+        proguard_tool = get_android_sdk(ctx).proguard,
+    )
+
+    providers = []
+    if proguard_output:
+        providers.append(proguard_testing.ProguardOutputInfo(
+            input_jar = deploy_ctx.deploy_jar,
+            output_jar = proguard_output.output_jar,
+            mapping = proguard_output.mapping,
+            seeds = proguard_output.seeds,
+            usage = proguard_output.usage,
+            library_jar = proguard_output.library_jar,
+            config = proguard_output.config,
+        ))
+
+    return ProviderInfo(
+        name = "optimize_ctx",
+        value = struct(
+            proguard_output = proguard_output,
+            providers = providers,
+        ),
+    )
+
 # Order dependent, as providers will not be available to downstream processors
 # that may depend on the provider. Iteration order for a dictionary is based on
 # insertion.
@@ -396,9 +568,13 @@
     DataBindingProcessor = _process_data_binding,
     JvmProcessor = _process_jvm,
     BuildInfoProcessor = _process_build_info,
+    ProtoProcessor = _process_proto,
     DeployJarProcessor = _process_deploy_jar,
+    OptimizeProcessor = _process_optimize,
     DexProcessor = _process_dex,
     BaselineProfilesProcessor = _process_baseline_profiles,
+    R8Processor = process_r8,
+    ResourecShrinkerR8Processor = process_resource_shrinking_r8,
 )
 
 _PROCESSING_PIPELINE = processing_pipeline.make_processing_pipeline(
diff --git a/rules/android_binary_internal/r8.bzl b/rules/android_binary_internal/r8.bzl
new file mode 100644
index 0000000..a4be61d
--- /dev/null
+++ b/rules/android_binary_internal/r8.bzl
@@ -0,0 +1,211 @@
+# Copyright 2023 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""R8 processor steps for android_binary_internal."""
+
+load("//rules:acls.bzl", "acls")
+load("//rules:proguard.bzl", "proguard")
+load(
+    "//rules:utils.bzl",
+    "ANDROID_TOOLCHAIN_TYPE",
+    "get_android_sdk",
+    "get_android_toolchain",
+)
+load(
+    "//rules:processing_pipeline.bzl",
+    "ProviderInfo",
+)
+load("//rules:common.bzl", "common")
+load("//rules:java.bzl", "java")
+load("//rules:resources.bzl", _resources = "resources")
+
+def process_r8(ctx, jvm_ctx, packaged_resources_ctx, build_info_ctx, **_unused_ctxs):
+    """Runs R8 for desugaring, optimization, and dexing.
+
+    Args:
+      ctx: Rule contxt.
+      jvm_ctx: Context from the java processor.
+      packaged_resources_ctx: Context from resource processing.
+      build_info_ctx: Context from build info processor.
+      **_unused_ctxs: Unused context.
+
+    Returns:
+      The r8_ctx ProviderInfo.
+    """
+    local_proguard_specs = ctx.files.proguard_specs
+    if not acls.use_r8(str(ctx.label)) or not local_proguard_specs:
+        return ProviderInfo(
+            name = "r8_ctx",
+            value = struct(
+                providers = [],
+            ),
+        )
+
+    # The R8 processor step creates its own deploy jar instead of
+    # The deploy jar from the deploy_jar processor is not used because as of now, whether it
+    # actually produces a deploy jar is determinted by a separate set of ACLs, and also does
+    # desugaring differently than with R8.
+    deploy_jar = ctx.actions.declare_file(ctx.label.name + "_deploy.jar")
+    java.create_deploy_jar(
+        ctx,
+        output = deploy_jar,
+        runtime_jars = depset(
+            direct = jvm_ctx.java_info.runtime_output_jars + [packaged_resources_ctx.class_jar],
+            transitive = [jvm_ctx.java_info.transitive_runtime_jars],
+        ),
+        java_toolchain = common.get_java_toolchain(ctx),
+        build_target = ctx.label.name,
+        deploy_manifest_lines = build_info_ctx.deploy_manifest_lines,
+    )
+
+    dexes_zip = ctx.actions.declare_file(ctx.label.name + "_dexes.zip")
+
+    android_jar = get_android_sdk(ctx).android_jar
+    proguard_specs = proguard.get_proguard_specs(ctx, packaged_resources_ctx.resource_proguard_config)
+
+    args = ctx.actions.args()
+    args.add("--release")
+    args.add("--output", dexes_zip)
+    args.add_all(proguard_specs, before_each = "--pg-conf")
+    args.add("--lib", android_jar)
+    args.add(deploy_jar)  # jar to optimize + desugar + dex
+
+    java.run(
+        ctx = ctx,
+        host_javabase = common.get_host_javabase(ctx),
+        executable = get_android_toolchain(ctx).r8.files_to_run,
+        arguments = [args],
+        inputs = [android_jar, deploy_jar] + proguard_specs,
+        outputs = [dexes_zip],
+        mnemonic = "AndroidR8",
+        progress_message = "R8 Optimizing, Desugaring, and Dexing %{label}",
+    )
+
+    android_dex_info = AndroidDexInfo(
+        deploy_jar = deploy_jar,
+        final_classes_dex_zip = dexes_zip,
+        # R8 preserves the Java resources (i.e. non-Java-class files) in its output zip, so no need
+        # to provide a Java resources zip.
+        java_resource_jar = None,
+    )
+
+    return ProviderInfo(
+        name = "r8_ctx",
+        value = struct(
+            final_classes_dex_zip = dexes_zip,
+            providers = [android_dex_info],
+        ),
+    )
+
+def process_resource_shrinking_r8(ctx, r8_ctx, packaged_resources_ctx, **_unused_ctxs):
+    """Runs resource shrinking.
+
+    Args:
+      ctx: Rule contxt.
+      r8_ctx: Context from the R8 processor.
+      packaged_resources_ctx: Context from resource processing.
+      **_unused_ctxs: Unused context.
+
+    Returns:
+      The r8_ctx ProviderInfo.
+    """
+    local_proguard_specs = ctx.files.proguard_specs
+    if (not acls.use_r8(str(ctx.label)) or
+        not local_proguard_specs or
+        not _resources.is_resource_shrinking_enabled(
+            ctx.attr.shrink_resources,
+            ctx.fragments.android.use_android_resource_shrinking,
+        )):
+        return ProviderInfo(
+            name = "resource_shrinking_r8_ctx",
+            value = struct(
+                android_application_resource_info_with_shrunk_resource_apk = None,
+                providers = [],
+            ),
+        )
+
+    android_toolchain = get_android_toolchain(ctx)
+
+    # 1. Convert the resource APK to proto format (resource shrinker operates on a proto apk)
+    proto_resource_apk = ctx.actions.declare_file(ctx.label.name + "_proto_resource_apk.ap_")
+    ctx.actions.run(
+        arguments = [ctx.actions.args()
+            .add("convert")
+            .add(packaged_resources_ctx.resources_apk)  # input apk
+            .add("-o", proto_resource_apk)  # output apk
+            .add("--output-format", "proto")],
+        executable = android_toolchain.aapt2.files_to_run,
+        inputs = [packaged_resources_ctx.resources_apk],
+        mnemonic = "Aapt2ConvertToProtoForResourceShrinkerR8",
+        outputs = [proto_resource_apk],
+        toolchain = ANDROID_TOOLCHAIN_TYPE,
+    )
+
+    # 2. Run the resource shrinker
+    proto_resource_apk_shrunk = ctx.actions.declare_file(
+        ctx.label.name + "_proto_resource_apk_shrunk.ap_",
+    )
+    java.run(
+        ctx = ctx,
+        host_javabase = common.get_host_javabase(ctx),
+        executable = android_toolchain.resource_shrinker.files_to_run,
+        arguments = [ctx.actions.args()
+            .add("--input", proto_resource_apk)
+            .add("--dex_input", r8_ctx.final_classes_dex_zip)
+            .add("--output", proto_resource_apk_shrunk)],
+        inputs = [proto_resource_apk, r8_ctx.final_classes_dex_zip],
+        outputs = [proto_resource_apk_shrunk],
+        mnemonic = "ResourceShrinkerForR8",
+        progress_message = "Shrinking resources %{label}",
+    )
+
+    # 3. Convert back to a binary APK
+    resource_apk_shrunk = ctx.actions.declare_file(ctx.label.name + "_resource_apk_shrunk.ap_")
+    ctx.actions.run(
+        arguments = [ctx.actions.args()
+            .add("convert")
+            .add(proto_resource_apk_shrunk)  # input apk
+            .add("-o", resource_apk_shrunk)  # output apk
+            .add("--output-format", "binary")],
+        executable = android_toolchain.aapt2.files_to_run,
+        inputs = [proto_resource_apk_shrunk],
+        mnemonic = "Aapt2ConvertBackToBinaryForResourceShrinkerR8",
+        outputs = [resource_apk_shrunk],
+        toolchain = ANDROID_TOOLCHAIN_TYPE,
+    )
+
+    aari = packaged_resources_ctx.android_application_resource
+
+    # Replace the resource apk in the AndroidApplicationResourceInfo provider from resource
+    # processing.
+    new_aari = AndroidApplicationResourceInfo(
+        resource_apk = resource_apk_shrunk,
+        resource_java_src_jar = aari.resource_java_src_jar,
+        resource_java_class_jar = aari.resource_java_class_jar,
+        manifest = aari.manifest,
+        resource_proguard_config = aari.resource_proguard_config,
+        main_dex_proguard_config = aari.main_dex_proguard_config,
+        r_txt = aari.r_txt,
+        resources_zip = aari.resources_zip,
+        databinding_info = aari.databinding_info,
+        should_compile_java_srcs = aari.should_compile_java_srcs,
+    )
+
+    return ProviderInfo(
+        name = "resource_shrinking_r8_ctx",
+        value = struct(
+            android_application_resource_info_with_shrunk_resource_apk = new_aari,
+            providers = [],
+        ),
+    )
diff --git a/rules/android_binary_internal/rule.bzl b/rules/android_binary_internal/rule.bzl
index 3ed451a..39f498c 100644
--- a/rules/android_binary_internal/rule.bzl
+++ b/rules/android_binary_internal/rule.bzl
@@ -21,7 +21,7 @@
     _attrs = "attrs",
 )
 
-_DEFAULT_ALLOWED_ATTRS = ["name", "visibility", "tags", "testonly", "transitive_configs", "$enable_manifest_merging", "features"]
+_DEFAULT_ALLOWED_ATTRS = ["name", "visibility", "tags", "testonly", "transitive_configs", "$enable_manifest_merging", "features", "exec_properties"]
 
 _DEFAULT_PROVIDES = [AndroidApplicationResourceInfo, OutputGroupInfo]
 
@@ -47,6 +47,7 @@
         provides = provides,
         toolchains = [
             "//toolchains/android:toolchain_type",
+            "//toolchains/android_sdk:toolchain_type",
             "@bazel_tools//tools/jdk:toolchain_type",
         ] + additional_toolchains,
         _skylark_testable = True,
diff --git a/rules/android_library/BUILD b/rules/android_library/BUILD
index 4eaeaf3..2aae34b 100644
--- a/rules/android_library/BUILD
+++ b/rules/android_library/BUILD
@@ -14,6 +14,7 @@
 bzl_library(
     name = "bzl",
     srcs = glob(["*.bzl"]),
+    visibility = ["//rules:__pkg__"],
     deps = [
         "//rules:common_bzl",
         "//rules/flags:bzl",
diff --git a/rules/android_library/attrs.bzl b/rules/android_library/attrs.bzl
index 8102aa1..651e68d 100644
--- a/rules/android_library/attrs.bzl
+++ b/rules/android_library/attrs.bzl
@@ -233,4 +233,5 @@
     _attrs.COMPILATION,
     _attrs.DATA_CONTEXT,
     _attrs.ANDROID_TOOLCHAIN_ATTRS,
+    _attrs.AUTOMATIC_EXEC_GROUPS_ENABLED,
 )
diff --git a/rules/android_neverlink_aspect.bzl b/rules/android_neverlink_aspect.bzl
new file mode 100644
index 0000000..1dab304
--- /dev/null
+++ b/rules/android_neverlink_aspect.bzl
@@ -0,0 +1,69 @@
+# Copyright 2023 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Aspect to collect neverlink libraries in the transitive closure.
+
+Used for determining the -libraryjars argument for Proguard. The compile-time classpath is
+unsufficient here as those are ijars.
+"""
+
+load(
+    "//rules:utils.bzl",
+    "utils",
+)
+
+StarlarkAndroidNeverlinkInfo = provider(
+    doc = "Contains all neverlink libraries in the transitive closure.",
+    fields = {
+        "transitive_neverlink_libraries": "Depset of transitive neverlink jars",
+    },
+)
+
+_ATTRS = ["deps", "exports", "runtime_deps", "binary_under_test", "$instrumentation_test_runner"]
+
+def _android_neverlink_aspect_impl(target, ctx):
+    # Only run on Android targets
+    if "android" not in getattr(ctx.rule.attr, "constraints", "") and not ctx.rule.kind.startswith("android_"):
+        return []
+
+    deps = []
+    for attr in _ATTRS:
+        if type(getattr(ctx.rule.attr, attr, None)) == "list":
+            deps.extend(getattr(ctx.rule.attr, attr))
+
+    direct_runtime_jars = depset(
+        target[JavaInfo].runtime_output_jars,
+        transitive = [target[AndroidLibraryResourceClassJarProvider].jars] if AndroidLibraryResourceClassJarProvider in target else [],
+    )
+
+    neverlink_libs = _collect_transitive_neverlink_libs(ctx, deps, direct_runtime_jars)
+
+    return [StarlarkAndroidNeverlinkInfo(transitive_neverlink_libraries = neverlink_libs)]
+
+def _collect_transitive_neverlink_libs(ctx, deps, runtime_jars):
+    neverlink_runtime_jars = []
+    for provider in utils.collect_providers(StarlarkAndroidNeverlinkInfo, deps):
+        neverlink_runtime_jars.append(provider.transitive_neverlink_libraries)
+
+    if getattr(ctx.rule.attr, "neverlink", False):
+        neverlink_runtime_jars.append(runtime_jars)
+        for java_info in utils.collect_providers(JavaInfo, deps):
+            neverlink_runtime_jars.append(java_info.transitive_runtime_jars)
+
+    return depset([], transitive = neverlink_runtime_jars)
+
+android_neverlink_aspect = aspect(
+    implementation = _android_neverlink_aspect_impl,
+    attr_aspects = _ATTRS,
+)
diff --git a/rules/android_sandboxed_sdk/BUILD b/rules/android_sandboxed_sdk/BUILD
new file mode 100644
index 0000000..f204a07
--- /dev/null
+++ b/rules/android_sandboxed_sdk/BUILD
@@ -0,0 +1,25 @@
+# Android Sandboxed SDK rules.
+
+load("@bazel_skylib//:bzl_library.bzl", "bzl_library")
+
+licenses(["notice"])
+
+exports_files([
+    "android_sandboxed_sdk.bzl",
+    "android_sandboxed_sdk_bundle.bzl",
+])
+
+filegroup(
+    name = "all_files",
+    srcs = glob(["**"]),
+)
+
+bzl_library(
+    name = "bzl",
+    srcs = glob(["*.bzl"]),
+    visibility = ["//rules:__pkg__"],
+    deps = [
+        "//rules:android_binary_bzl",
+        "//rules:common_bzl",
+    ],
+)
diff --git a/rules/android_sandboxed_sdk/android_binary_with_sandboxed_sdks_macro.bzl b/rules/android_sandboxed_sdk/android_binary_with_sandboxed_sdks_macro.bzl
new file mode 100644
index 0000000..3214f34
--- /dev/null
+++ b/rules/android_sandboxed_sdk/android_binary_with_sandboxed_sdks_macro.bzl
@@ -0,0 +1,220 @@
+# Copyright 2023 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Bazel rule for defining an Android binary that depends on sandboxed SDKs."""
+
+load(":providers.bzl", "AndroidSandboxedSdkBundleInfo")
+load("//rules:acls.bzl", "acls")
+load("//rules:bundletool.bzl", _bundletool = "bundletool")
+load("//rules:common.bzl", _common = "common")
+load(
+    "//rules:utils.bzl",
+    _get_android_toolchain = "get_android_toolchain",
+)
+load("//rules:java.bzl", _java = "java")
+
+def _gen_sdk_dependencies_manifest_impl(ctx):
+    manifest = ctx.actions.declare_file(ctx.label.name + "_sdk_dep_manifest.xml")
+
+    module_configs = [
+        bundle[AndroidSandboxedSdkBundleInfo].sdk_info.sdk_module_config
+        for bundle in ctx.attr.sdk_bundles
+    ]
+
+    args = ctx.actions.args()
+    args.add("generate-sdk-dependencies-manifest")
+    args.add("--manifest-package", ctx.attr.package)
+    args.add("--sdk-module-configs", ",".join([config.path for config in module_configs]))
+    args.add("--debug-keystore", ctx.file.debug_key.path)
+    args.add("--debug-keystore-pass", "android")
+    args.add("--debug-keystore-alias", "androiddebugkey")
+    args.add("--output-manifest", manifest)
+    _java.run(
+        ctx = ctx,
+        host_javabase = _common.get_host_javabase(ctx),
+        executable = _get_android_toolchain(ctx).sandboxed_sdk_toolbox.files_to_run,
+        arguments = [args],
+        inputs = module_configs + [ctx.file.debug_key],
+        outputs = [manifest],
+        mnemonic = "GenSdkDepManifest",
+        progress_message = "Generate SDK dependencies manifest %s" % manifest.short_path,
+    )
+
+    return [
+        DefaultInfo(
+            files = depset([manifest]),
+        ),
+    ]
+
+_gen_sdk_dependencies_manifest = rule(
+    attrs = dict(
+        package = attr.string(),
+        sdk_bundles = attr.label_list(
+            providers = [
+                [AndroidSandboxedSdkBundleInfo],
+            ],
+        ),
+        debug_key = attr.label(
+            allow_single_file = True,
+            default = Label("//tools/android:debug_keystore"),
+        ),
+        _host_javabase = attr.label(
+            cfg = "exec",
+            default = Label("//tools/jdk:current_java_runtime"),
+        ),
+    ),
+    executable = False,
+    implementation = _gen_sdk_dependencies_manifest_impl,
+    toolchains = [
+        "//toolchains/android:toolchain_type",
+    ],
+)
+
+def _android_binary_with_sandboxed_sdks_impl(ctx):
+    sdk_apks = []
+    for idx, sdk_bundle_target in enumerate(ctx.attr.sdk_bundles):
+        apk_out = ctx.actions.declare_file("%s/sdk_dep_apks/%s.apk" % (
+            ctx.label.name,
+            idx,
+        ))
+        _bundletool.build_sdk_apks(
+            ctx,
+            out = apk_out,
+            aapt2 = _get_android_toolchain(ctx).aapt2.files_to_run,
+            sdk_bundle = sdk_bundle_target[AndroidSandboxedSdkBundleInfo].asb,
+            debug_key = ctx.file.debug_key,
+            bundletool = _get_android_toolchain(ctx).bundletool.files_to_run,
+            host_javabase = _common.get_host_javabase(ctx),
+        )
+        sdk_apks.append(apk_out)
+
+    app_apk = ctx.attr.internal_android_binary[ApkInfo].signed_apk
+    adb = _get_android_toolchain(ctx).adb.files_to_run.executable
+    substitutions = {
+        "%adb%": adb.short_path,
+        "%app_apk%": app_apk.short_path,
+        "%sdk_apks%": ",".join([apk.short_path for apk in sdk_apks]),
+    }
+
+    install_script = ctx.actions.declare_file("%s_install_script.sh" % ctx.label.name)
+    ctx.actions.expand_template(
+        template = ctx.file._install_script_template,
+        output = install_script,
+        substitutions = substitutions,
+        is_executable = True,
+    )
+
+    return [
+        DefaultInfo(
+            executable = install_script,
+            files = depset([app_apk] + sdk_apks),
+            runfiles = ctx.runfiles([
+                adb,
+                app_apk,
+            ] + sdk_apks),
+        ),
+    ]
+
+_android_binary_with_sandboxed_sdks = rule(
+    attrs = dict(
+        internal_android_binary = attr.label(
+            providers = [
+                [ApkInfo],
+            ],
+        ),
+        debug_key = attr.label(
+            allow_single_file = True,
+            default = Label("//tools/android:debug_keystore"),
+        ),
+        sdk_bundles = attr.label_list(
+            providers = [
+                [AndroidSandboxedSdkBundleInfo],
+            ],
+        ),
+        _install_script_template = attr.label(
+            allow_single_file = True,
+            default = ":install_script.sh_template",
+        ),
+        _host_javabase = attr.label(
+            cfg = "exec",
+            default = Label("//tools/jdk:current_java_runtime"),
+        ),
+    ),
+    executable = True,
+    implementation = _android_binary_with_sandboxed_sdks_impl,
+    toolchains = [
+        "//toolchains/android:toolchain_type",
+    ],
+)
+
+def android_binary_with_sandboxed_sdks_macro(
+        _android_binary,
+        _android_library,
+        **attrs):
+    """android_binary_with_sandboxed_sdks.
+
+    Args:
+      _android_binary: The android_binary rule to use.
+      _android_library: The android_library rule to use.
+      **attrs: android_binary attributes.
+    """
+
+    name = attrs.pop("name", None)
+    fully_qualified_name = "//%s:%s" % (native.package_name(), name)
+    if (not acls.in_android_binary_with_sandboxed_sdks_allowlist(fully_qualified_name)):
+        fail("%s is not allowed to use the android_binary_with_sandboxed_sdks macro." %
+             fully_qualified_name)
+
+    sdk_bundles = attrs.pop("sdk_bundles", None)
+    debug_keystore = getattr(attrs, "debug_keystore", None)
+
+    bin_package = _java.resolve_package_from_label(
+        Label(fully_qualified_name),
+        getattr(attrs, "custom_package", None),
+    )
+
+    # Generate a manifest that lists all the SDK dependencies with <uses-sdk-library> tags.
+    sdk_dependencies_manifest_name = "%s_sdk_dependencies_manifest" % name
+    _gen_sdk_dependencies_manifest(
+        name = sdk_dependencies_manifest_name,
+        package = "%s.internalsdkdependencies" % bin_package,
+        sdk_bundles = sdk_bundles,
+    )
+
+    # Use the manifest in a normal android_library. This will later be added as a dependency to the
+    # binary, so the manifest is merged with the app's.
+    sdk_dependencies_lib_name = "%s_sdk_dependencies_lib" % name
+    _android_library(
+        name = sdk_dependencies_lib_name,
+        exports_manifest = 1,
+        manifest = ":%s" % sdk_dependencies_manifest_name,
+    )
+    deps = attrs.pop("deps", [])
+    deps.append(":%s" % sdk_dependencies_lib_name)
+
+    # Generate the android_binary as normal, passing the extra flags.
+    bin_label = Label("%s_app_bin" % fully_qualified_name)
+    _android_binary(
+        name = bin_label.name,
+        deps = deps,
+        **attrs
+    )
+
+    # This final rule will call Bundletool to generate the SDK APKs and provide the install script.
+    _android_binary_with_sandboxed_sdks(
+        name = name,
+        sdk_bundles = sdk_bundles,
+        debug_key = debug_keystore,
+        internal_android_binary = bin_label,
+    )
diff --git a/rules/android_sandboxed_sdk/android_sandboxed_sdk.bzl b/rules/android_sandboxed_sdk/android_sandboxed_sdk.bzl
new file mode 100644
index 0000000..87f0311
--- /dev/null
+++ b/rules/android_sandboxed_sdk/android_sandboxed_sdk.bzl
@@ -0,0 +1,54 @@
+# Copyright 2023 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""android_sandboxed_sdk rule.
+
+This file exists to inject the correct version of android_binary.
+"""
+
+load(":android_sandboxed_sdk_macro.bzl", _android_sandboxed_sdk_macro = "android_sandboxed_sdk_macro")
+load("//rules:android_binary.bzl", _android_binary = "android_binary")
+
+def android_sandboxed_sdk(
+        name,
+        sdk_modules_config,
+        deps,
+        min_sdk_version = 21,
+        custom_package = None):
+    """Rule to build an Android Sandboxed SDK.
+
+    A sandboxed SDK is a collection of libraries that can run independently in the Privacy Sandbox
+    or in a separate split APK of an app. See:
+    https://developer.android.com/design-for-safety/privacy-sandbox.
+
+    Args:
+      name: Unique name of this target.
+      sdk_modules_config: Module config for this SDK. For full definition see
+        https://github.com/google/bundletool/blob/master/src/main/proto/sdk_modules_config.proto
+      deps: Set of android libraries that make up this SDK.
+      min_sdk_version: Min SDK version for the SDK.
+      custom_package: Java package for which java sources will be generated. By default the package
+        is inferred from the directory where the BUILD file containing the rule is. You can specify
+        a different package but this is highly discouraged since it can introduce classpath
+        conflicts with other libraries that will only be detected at runtime.
+    """
+
+    _android_sandboxed_sdk_macro(
+        name = name,
+        sdk_modules_config = sdk_modules_config,
+        deps = deps,
+        min_sdk_version = min_sdk_version,
+        custom_package = custom_package,
+        android_binary = _android_binary,
+    )
diff --git a/rules/android_sandboxed_sdk/android_sandboxed_sdk_bundle.bzl b/rules/android_sandboxed_sdk/android_sandboxed_sdk_bundle.bzl
new file mode 100644
index 0000000..102459a
--- /dev/null
+++ b/rules/android_sandboxed_sdk/android_sandboxed_sdk_bundle.bzl
@@ -0,0 +1,103 @@
+# Copyright 2023 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Rule for creating an Android Sandboxed SDK Bundle (ASB)."""
+
+load(":providers.bzl", "AndroidSandboxedSdkBundleInfo", "AndroidSandboxedSdkInfo")
+load(
+    "//rules:aapt.bzl",
+    _aapt = "aapt",
+)
+load(
+    "//rules:bundletool.bzl",
+    _bundletool = "bundletool",
+)
+load(
+    "//rules:common.bzl",
+    _common = "common",
+)
+load(
+    "//rules:utils.bzl",
+    _get_android_toolchain = "get_android_toolchain",
+)
+
+_ATTRS = dict(
+    sdk = attr.label(
+        providers = [
+            [AndroidSandboxedSdkInfo],
+        ],
+    ),
+    _host_javabase = attr.label(
+        cfg = "exec",
+        default = Label("//tools/jdk:current_java_runtime"),
+    ),
+)
+
+def _impl(ctx):
+    host_javabase = _common.get_host_javabase(ctx)
+
+    # Convert internal APK to proto resources.
+    internal_proto_apk = ctx.actions.declare_file(ctx.label.name + "_internal_proto_apk")
+    _aapt.convert(
+        ctx,
+        out = internal_proto_apk,
+        input = ctx.attr.sdk[AndroidSandboxedSdkInfo].internal_apk_info.unsigned_apk,
+        to_proto = True,
+        aapt = _get_android_toolchain(ctx).aapt2.files_to_run,
+    )
+
+    # Invoke module builder to create a base.zip that bundletool accepts.
+    module_zip = ctx.actions.declare_file(ctx.label.name + "_module.zip")
+    _bundletool.build_sdk_module(
+        ctx,
+        out = module_zip,
+        internal_apk = internal_proto_apk,
+        bundletool_module_builder =
+            _get_android_toolchain(ctx).bundletool_module_builder.files_to_run,
+        host_javabase = host_javabase,
+    )
+
+    # Invoke bundletool and create the bundle.
+    _bundletool.build_sdk_bundle(
+        ctx,
+        out = ctx.outputs.asb,
+        module = module_zip,
+        sdk_modules_config = ctx.attr.sdk[AndroidSandboxedSdkInfo].sdk_module_config,
+        bundletool = _get_android_toolchain(ctx).bundletool.files_to_run,
+        host_javabase = host_javabase,
+    )
+
+    return [
+        AndroidSandboxedSdkBundleInfo(
+            asb = ctx.outputs.asb,
+            sdk_info = ctx.attr.sdk[AndroidSandboxedSdkInfo],
+        ),
+    ]
+
+android_sandboxed_sdk_bundle = rule(
+    attrs = _ATTRS,
+    executable = False,
+    implementation = _impl,
+    provides = [
+        AndroidSandboxedSdkBundleInfo,
+    ],
+    outputs = {
+        "asb": "%{name}.asb",
+    },
+    toolchains = [
+        "//toolchains/android:toolchain_type",
+        "//toolchains/android_sdk:toolchain_type",
+    ],
+    fragments = ["android"],
+)
diff --git a/rules/android_sandboxed_sdk/android_sandboxed_sdk_macro.bzl b/rules/android_sandboxed_sdk/android_sandboxed_sdk_macro.bzl
new file mode 100644
index 0000000..6d8deb1
--- /dev/null
+++ b/rules/android_sandboxed_sdk/android_sandboxed_sdk_macro.bzl
@@ -0,0 +1,131 @@
+# Copyright 2023 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Bazel rule for defining an Android Sandboxed SDK."""
+
+load(":providers.bzl", "AndroidSandboxedSdkInfo")
+load(
+    "//rules:common.bzl",
+    _common = "common",
+)
+load(
+    "//rules:utils.bzl",
+    _get_android_toolchain = "get_android_toolchain",
+)
+load("//rules:java.bzl", _java = "java")
+
+_ATTRS = dict(
+    sdk_modules_config = attr.label(
+        allow_single_file = [".pb.json"],
+    ),
+    internal_android_binary = attr.label(),
+    sdk_deploy_jar = attr.label(
+        allow_single_file = [".jar"],
+    ),
+    _host_javabase = attr.label(
+        cfg = "exec",
+        default = Label("//tools/jdk:current_java_runtime"),
+    ),
+)
+
+def _impl(ctx):
+    sdk_api_descriptors = ctx.actions.declare_file(ctx.label.name + "_sdk_api_descriptors.jar")
+
+    args = ctx.actions.args()
+    args.add("extract-api-descriptors")
+    args.add("--sdk-deploy-jar", ctx.file.sdk_deploy_jar)
+    args.add("--output-sdk-api-descriptors", sdk_api_descriptors)
+    _java.run(
+        ctx = ctx,
+        host_javabase = _common.get_host_javabase(ctx),
+        executable = _get_android_toolchain(ctx).sandboxed_sdk_toolbox.files_to_run,
+        arguments = [args],
+        inputs = [ctx.file.sdk_deploy_jar],
+        outputs = [sdk_api_descriptors],
+        mnemonic = "ExtractApiDescriptors",
+        progress_message = "Extract SDK API descriptors %s" % sdk_api_descriptors.short_path,
+    )
+    return [
+        DefaultInfo(
+            files = depset([sdk_api_descriptors]),
+        ),
+        AndroidSandboxedSdkInfo(
+            internal_apk_info = ctx.attr.internal_android_binary[ApkInfo],
+            sdk_module_config = ctx.file.sdk_modules_config,
+            sdk_api_descriptors = sdk_api_descriptors,
+        ),
+    ]
+
+_android_sandboxed_sdk = rule(
+    attrs = _ATTRS,
+    executable = False,
+    implementation = _impl,
+    provides = [
+        AndroidSandboxedSdkInfo,
+    ],
+    toolchains = [
+        "//toolchains/android:toolchain_type",
+    ],
+)
+
+def android_sandboxed_sdk_macro(
+        name,
+        sdk_modules_config,
+        deps,
+        min_sdk_version = 21,
+        custom_package = None,
+        android_binary = None):
+    """Macro for an Android Sandboxed SDK.
+
+    Args:
+      name: Unique name of this target.
+      sdk_modules_config: Module config for this SDK.
+      deps: Set of android libraries that make up this SDK.
+      min_sdk_version: Min SDK version for the SDK.
+      custom_package: Java package for resources,
+      android_binary: android_binary rule used to create the intermediate SDK APK.
+    """
+    fully_qualified_name = "//%s:%s" % (native.package_name(), name)
+    package = _java.resolve_package_from_label(Label(fully_qualified_name), custom_package)
+
+    manifest_label = Label("%s_gen_manifest" % fully_qualified_name)
+    native.genrule(
+        name = manifest_label.name,
+        outs = [name + "/AndroidManifest.xml"],
+        cmd = """cat > $@ <<EOF
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="{package}">
+    <uses-sdk android:minSdkVersion="{min_sdk_version}"/>
+    <application />
+</manifest>
+EOF
+""".format(package = package, min_sdk_version = min_sdk_version),
+    )
+
+    bin_fqn = "%s_bin" % fully_qualified_name
+    bin_label = Label(bin_fqn)
+    android_binary(
+        name = bin_label.name,
+        manifest = str(manifest_label),
+        deps = deps,
+    )
+
+    sdk_deploy_jar = Label("%s_deploy.jar" % bin_fqn)
+    _android_sandboxed_sdk(
+        name = name,
+        sdk_modules_config = sdk_modules_config,
+        internal_android_binary = bin_label,
+        sdk_deploy_jar = sdk_deploy_jar,
+    )
diff --git a/rules/android_sandboxed_sdk/providers.bzl b/rules/android_sandboxed_sdk/providers.bzl
new file mode 100644
index 0000000..0771d99
--- /dev/null
+++ b/rules/android_sandboxed_sdk/providers.bzl
@@ -0,0 +1,37 @@
+# Copyright 2023 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Providers for Android Sandboxed SDK rules."""
+
+AndroidSandboxedSdkInfo = provider(
+    doc = "Provides information about a sandboxed Android SDK.",
+    fields = dict(
+        internal_apk_info = "ApkInfo for SDKs dexes and resources. Note: it cannot " +
+                            "be installed on a device as is. It needs to be further processed by " +
+                            "other sandboxed SDK rules.",
+        sdk_module_config = "The SDK Module config. For the full definition see " +
+                            "https://github.com/google/bundletool/blob/master/src/main/proto/sdk_modules_config.proto",
+        sdk_api_descriptors = "Jar file with the SDK API Descriptors. This can later be used to " +
+                              "generate sources for communicating with this SDK from the app " +
+                              "process.",
+    ),
+)
+
+AndroidSandboxedSdkBundleInfo = provider(
+    doc = "Provides information about a sandboxed Android SDK Bundle (ASB).",
+    fields = dict(
+        sdk_info = "AndroidSandboxedSdkInfo with information about the SDK.",
+        asb = "Path to the final ASB, unsigned.",
+    ),
+)
diff --git a/rules/attrs.bzl b/rules/attrs.bzl
index b93c2e8..6a03d2d 100644
--- a/rules/attrs.bzl
+++ b/rules/attrs.bzl
@@ -337,6 +337,10 @@
 
 ANDROID_TOOLS_DEFAULTS_JAR_ATTRS = _add(_ANDROID_SDK)
 
+_AUTOMATIC_EXEC_GROUPS_ENABLED = dict(
+    _use_auto_exec_groups = attr.bool(default = True),
+)
+
 attrs = struct(
     ANDROID_SDK = _ANDROID_SDK,
     COMPILATION = _COMPILATION,
@@ -346,4 +350,5 @@
     tristate = _tristate,
     add = _add,
     replace = _replace,
+    AUTOMATIC_EXEC_GROUPS_ENABLED = _AUTOMATIC_EXEC_GROUPS_ENABLED,
 )
diff --git a/rules/baseline_profiles.bzl b/rules/baseline_profiles.bzl
index 7909d49..581631b 100644
--- a/rules/baseline_profiles.bzl
+++ b/rules/baseline_profiles.bzl
@@ -16,7 +16,7 @@
 Defines baseline profiles processing.
 """
 
-load("//rules:utils.bzl", "get_android_toolchain")
+load("//rules:utils.bzl", "ANDROID_TOOLCHAIN_TYPE", "get_android_toolchain")
 
 def _process(ctx, final_classes_dex, transitive_profiles):
     """ Merges/compiles all the baseline profiles propagated from android_library and aar_import.
@@ -48,6 +48,7 @@
         inputs = transitive_profiles,
         outputs = [merged_profile],
         use_default_shell_env = True,
+        toolchain = ANDROID_TOOLCHAIN_TYPE,
     )
 
     # Profgen
@@ -70,6 +71,7 @@
         inputs = profgen_inputs,
         outputs = [output_profile, output_profile_meta],
         use_default_shell_env = True,
+        toolchain = ANDROID_TOOLCHAIN_TYPE,
     )
 
     # Zip ART profiles
@@ -86,6 +88,7 @@
         inputs = [output_profile, output_profile_meta],
         outputs = [output_profile_zip],
         use_default_shell_env = True,
+        toolchain = ANDROID_TOOLCHAIN_TYPE,
     )
     return BaselineProfileProvider(
         transitive_profiles,
diff --git a/rules/bundletool.bzl b/rules/bundletool.bzl
index cfc3e48..7988bac 100644
--- a/rules/bundletool.bzl
+++ b/rules/bundletool.bzl
@@ -27,77 +27,6 @@
     "tvdpi": 213,
 }
 
-def _proto_apk_to_module(
-        ctx,
-        out = None,
-        proto_apk = None,
-        zip = None,
-        unzip = None):
-    # TODO(timpeut): rewrite this as a standalone golang tool
-    ctx.actions.run_shell(
-        command = """
-set -e
-
-IN_DIR=$(mktemp -d)
-OUT_DIR=$(mktemp -d)
-CUR_PWD=$(pwd)
-UNZIP=%s
-ZIP=%s
-INPUT=%s
-OUTPUT=%s
-
-"${UNZIP}" -qq "${INPUT}" -d "${IN_DIR}"
-cd "${IN_DIR}"
-
-if [ -f resources.pb ]; then
-  mv resources.pb "${OUT_DIR}/"
-fi
-
-if [ -f AndroidManifest.xml ]; then
-  mkdir "${OUT_DIR}/manifest"
-  mv AndroidManifest.xml "${OUT_DIR}/manifest/"
-fi
-
-NUM_DEX=`ls -1 *.dex 2>/dev/null | wc -l`
-if [ $NUM_DEX != 0 ]; then
-  mkdir "${OUT_DIR}/dex"
-  mv *.dex "${OUT_DIR}/dex/"
-fi
-
-if [ -d res ]; then
-  mv res "${OUT_DIR}/res"
-fi
-
-if [ -d assets ]; then
-  mv assets "${OUT_DIR}/"
-fi
-
-if [ -d lib ]; then
-  mv lib "${OUT_DIR}/"
-fi
-
-UNKNOWN=`ls -1 * 2>/dev/null | wc -l`
-if [ $UNKNOWN != 0 ]; then
-  mkdir "${OUT_DIR}/root"
-  mv * "${OUT_DIR}/root/"
-fi
-
-cd "${OUT_DIR}"
-"${CUR_PWD}/${ZIP}" "${CUR_PWD}/${OUTPUT}" -Drq0 .
-""" % (
-            unzip.executable.path,
-            zip.executable.path,
-            proto_apk.path,
-            out.path,
-        ),
-        tools = [zip, unzip],
-        arguments = [],
-        inputs = [proto_apk],
-        outputs = [out],
-        mnemonic = "Rebundle",
-        progress_message = "Rebundle to %s" % out.short_path,
-    )
-
 def _build(
         ctx,
         out = None,
@@ -131,66 +60,118 @@
         progress_message = "Building bundle %s" % out.short_path,
     )
 
-def _extract_config(
+def _build_device_json(
+        ctx,
+        out,
+        abis,
+        locales,
+        density,
+        sdk_version):
+    json_content = json.encode(struct(
+        supportedAbis = abis,
+        supportedLocales = locales,
+        screenDensity = _density_mapping[density],
+        sdkVersion = int(sdk_version),
+    ))
+    ctx.actions.write(out, json_content)
+
+def _build_sdk_apks(
         ctx,
         out = None,
-        aab = None,
+        aapt2 = None,
+        sdk_bundle = None,
+        debug_key = None,
         bundletool = None,
         host_javabase = None):
-    # Need to execute as a shell script as the tool outputs to stdout
-    cmd = """
-set -e
-contents=`%s -jar %s dump config --bundle %s`
-echo "$contents" > %s
-""" % (
-        host_javabase[java_common.JavaRuntimeInfo].java_executable_exec_path,
-        bundletool.executable.path,
-        aab.path,
-        out.path,
+    apks_out = ctx.actions.declare_directory(ctx.label.name + "_sdk_apks")
+    args = ctx.actions.args()
+    args.add("build-sdk-apks")
+    args.add("--aapt2", aapt2.executable.path)
+    args.add("--sdk-bundle", sdk_bundle)
+    args.add("--ks", debug_key)
+    args.add("--ks-pass=pass:android")
+    args.add("--ks-key-alias=androiddebugkey")
+    args.add("--key-pass=pass:android")
+    args.add("--output-format=DIRECTORY")
+    args.add("--output", apks_out.path)
+    _java.run(
+        ctx = ctx,
+        host_javabase = host_javabase,
+        executable = bundletool,
+        arguments = [args],
+        inputs = [
+            sdk_bundle,
+            debug_key,
+        ],
+        tools = [aapt2],
+        outputs = [apks_out],
+        mnemonic = "BuildSdkApksDir",
+        progress_message = "Building SDK APKs directory %s" % apks_out.short_path,
     )
 
+    # Now move standalone APK out of bundletool output dir.
     ctx.actions.run_shell(
-        inputs = [aab],
+        command = """
+set -e
+APKS_OUT_DIR=%s
+DEBUG_APK_PATH=%s
+
+mv "${APKS_OUT_DIR}/standalones/standalone.apk" "${DEBUG_APK_PATH}"
+""" % (
+            apks_out.path,
+            out.path,
+        ),
+        tools = [],
+        arguments = [],
+        inputs = [apks_out],
         outputs = [out],
-        tools = depset([bundletool.executable], transitive = [host_javabase[java_common.JavaRuntimeInfo].files]),
-        mnemonic = "ExtractBundleConfig",
-        progress_message = "Extract bundle config to %s" % out.short_path,
-        command = cmd,
+        mnemonic = "ExtractDebugSdkApk",
+        progress_message = "Extract debug SDK APK to %s" % out.short_path,
     )
 
-def _extract_manifest(
+def _build_sdk_bundle(
         ctx,
         out = None,
-        aab = None,
         module = None,
-        xpath = None,
+        sdk_modules_config = None,
         bundletool = None,
         host_javabase = None):
-    # Need to execute as a shell script as the tool outputs to stdout
-    extra_flags = []
-    if module:
-        extra_flags.append("--module " + module)
-    if xpath:
-        extra_flags.append("--xpath " + xpath)
-    cmd = """
-set -e
-contents=`%s -jar %s dump manifest --bundle %s %s`
-echo "$contents" > %s
-""" % (
-        host_javabase[java_common.JavaRuntimeInfo].java_executable_exec_path,
-        bundletool.executable.path,
-        aab.path,
-        " ".join(extra_flags),
-        out.path,
+    args = ctx.actions.args()
+    args.add("build-sdk-bundle")
+
+    args.add("--sdk-modules-config", sdk_modules_config)
+    args.add("--modules", module)
+    args.add("--output", out)
+    _java.run(
+        ctx = ctx,
+        host_javabase = host_javabase,
+        executable = bundletool,
+        arguments = [args],
+        inputs = [
+            module,
+            sdk_modules_config,
+        ],
+        outputs = [out],
+        mnemonic = "BuildASB",
+        progress_message = "Building SDK bundle %s" % out.short_path,
     )
 
-    ctx.actions.run_shell(
-        inputs = [aab],
+def _build_sdk_module(
+        ctx,
+        out = None,
+        internal_apk = None,
+        bundletool_module_builder = None,
+        host_javabase = None):
+    args = ctx.actions.args()
+    args.add("--internal_apk_path", internal_apk)
+    args.add("--output_module_path", out)
+    ctx.actions.run(
+        inputs = [internal_apk],
         outputs = [out],
-        tools = depset([bundletool.executable], transitive = [host_javabase[java_common.JavaRuntimeInfo].files]),
-        mnemonic = "ExtractBundleManifest",
-        progress_message = "Extract bundle manifest to %s" % out.short_path,
-        command = cmd,
+        executable = bundletool_module_builder,
+        arguments = [args],
+        mnemonic = "BuildSdkModule",
+        progress_message = "Building ASB zip module %s" % out.short_path,
     )
 
 def _bundle_to_apks(
@@ -261,24 +242,145 @@
         progress_message = "Converting bundle to .apks: %s" % out.short_path,
     )
 
-def _build_device_json(
+def _extract_config(
         ctx,
-        out,
-        abis,
-        locales,
-        density,
-        sdk_version):
-    json_content = json.encode(struct(
-        supportedAbis = abis,
-        supportedLocales = locales,
-        screenDensity = _density_mapping[density],
-        sdkVersion = int(sdk_version),
-    ))
-    ctx.actions.write(out, json_content)
+        out = None,
+        aab = None,
+        bundletool = None,
+        host_javabase = None):
+    # Need to execute as a shell script as the tool outputs to stdout
+    cmd = """
+set -e
+contents=`%s -jar %s dump config --bundle %s`
+echo "$contents" > %s
+""" % (
+        host_javabase[java_common.JavaRuntimeInfo].java_executable_exec_path,
+        bundletool.executable.path,
+        aab.path,
+        out.path,
+    )
+
+    ctx.actions.run_shell(
+        inputs = [aab],
+        outputs = [out],
+        tools = depset([bundletool.executable], transitive = [host_javabase[java_common.JavaRuntimeInfo].files]),
+        mnemonic = "ExtractBundleConfig",
+        progress_message = "Extract bundle config to %s" % out.short_path,
+        command = cmd,
+    )
+
+def _extract_manifest(
+        ctx,
+        out = None,
+        aab = None,
+        module = None,
+        xpath = None,
+        bundletool = None,
+        host_javabase = None):
+    # Need to execute as a shell script as the tool outputs to stdout
+    extra_flags = []
+    if module:
+        extra_flags.append("--module " + module)
+    if xpath:
+        extra_flags.append("--xpath " + xpath)
+    cmd = """
+set -e
+contents=`%s -jar %s dump manifest --bundle %s %s`
+echo "$contents" > %s
+""" % (
+        host_javabase[java_common.JavaRuntimeInfo].java_executable_exec_path,
+        bundletool.executable.path,
+        aab.path,
+        " ".join(extra_flags),
+        out.path,
+    )
+
+    ctx.actions.run_shell(
+        inputs = [aab],
+        outputs = [out],
+        tools = depset([bundletool.executable], transitive = [host_javabase[java_common.JavaRuntimeInfo].files]),
+        mnemonic = "ExtractBundleManifest",
+        progress_message = "Extract bundle manifest to %s" % out.short_path,
+        command = cmd,
+    )
+
+def _proto_apk_to_module(
+        ctx,
+        out = None,
+        proto_apk = None,
+        zip = None,
+        unzip = None):
+    # TODO(timpeut): migrate this to Bundletool module builder.
+    ctx.actions.run_shell(
+        command = """
+set -e
+
+IN_DIR=$(mktemp -d)
+OUT_DIR=$(mktemp -d)
+CUR_PWD=$(pwd)
+UNZIP=%s
+ZIP=%s
+INPUT=%s
+OUTPUT=%s
+
+"${UNZIP}" -qq "${INPUT}" -d "${IN_DIR}"
+cd "${IN_DIR}"
+
+if [ -f resources.pb ]; then
+  mv resources.pb "${OUT_DIR}/"
+fi
+
+if [ -f AndroidManifest.xml ]; then
+  mkdir "${OUT_DIR}/manifest"
+  mv AndroidManifest.xml "${OUT_DIR}/manifest/"
+fi
+
+NUM_DEX=`ls -1 *.dex 2>/dev/null | wc -l`
+if [ $NUM_DEX != 0 ]; then
+  mkdir "${OUT_DIR}/dex"
+  mv *.dex "${OUT_DIR}/dex/"
+fi
+
+if [ -d res ]; then
+  mv res "${OUT_DIR}/res"
+fi
+
+if [ -d assets ]; then
+  mv assets "${OUT_DIR}/"
+fi
+
+if [ -d lib ]; then
+  mv lib "${OUT_DIR}/"
+fi
+
+UNKNOWN=`ls -1 * 2>/dev/null | wc -l`
+if [ $UNKNOWN != 0 ]; then
+  mkdir "${OUT_DIR}/root"
+  mv * "${OUT_DIR}/root/"
+fi
+
+cd "${OUT_DIR}"
+"${CUR_PWD}/${ZIP}" "${CUR_PWD}/${OUTPUT}" -Drq0 .
+""" % (
+            unzip.executable.path,
+            zip.executable.path,
+            proto_apk.path,
+            out.path,
+        ),
+        tools = [zip, unzip],
+        arguments = [],
+        inputs = [proto_apk],
+        outputs = [out],
+        mnemonic = "Rebundle",
+        progress_message = "Rebundle to %s" % out.short_path,
+    )
 
 bundletool = struct(
     build = _build,
     build_device_json = _build_device_json,
+    build_sdk_apks = _build_sdk_apks,
+    build_sdk_bundle = _build_sdk_bundle,
+    build_sdk_module = _build_sdk_module,
     bundle_to_apks = _bundle_to_apks,
     extract_config = _extract_config,
     extract_manifest = _extract_manifest,
diff --git a/rules/common.bzl b/rules/common.bzl
index c54012c..8b6359d 100644
--- a/rules/common.bzl
+++ b/rules/common.bzl
@@ -14,7 +14,7 @@
 
 """Bazel common library for the Android rules."""
 
-load(":utils.bzl", "get_android_toolchain", _log = "log")
+load(":utils.bzl", "ANDROID_TOOLCHAIN_TYPE", "get_android_toolchain", _log = "log")
 load("//rules/android_common:reexport_android_common.bzl", _native_android_common = "native_android_common")
 
 # Suffix attached to the Starlark portion of android_binary target
@@ -59,6 +59,7 @@
         outputs = [out_zip],
         mnemonic = "FilterZipInclude",
         progress_message = "Filtering %s" % in_zip.short_path,
+        toolchain = ANDROID_TOOLCHAIN_TYPE,
     )
 
 def _filter_zip_exclude(
@@ -111,6 +112,7 @@
         outputs = [output],
         mnemonic = "FilterZipExclude",
         progress_message = "Filtering %s" % input.short_path,
+        toolchain = ANDROID_TOOLCHAIN_TYPE,
     )
 
 def _create_signer_properties(ctx, oldest_key):
diff --git a/rules/data_binding.bzl b/rules/data_binding.bzl
index e853058..f935436 100644
--- a/rules/data_binding.bzl
+++ b/rules/data_binding.bzl
@@ -14,7 +14,7 @@
 
 """Bazel Android Data Binding."""
 
-load(":utils.bzl", _utils = "utils")
+load(":utils.bzl", "ANDROID_TOOLCHAIN_TYPE", _utils = "utils")
 
 # Data Binding context attributes.
 _JAVA_ANNOTATION_PROCESSOR_ADDITIONAL_INPUTS = \
@@ -92,6 +92,7 @@
         progress_message = (
             "GenerateDataBindingBaseClasses %s" % class_info.short_path
         ),
+        toolchain = ANDROID_TOOLCHAIN_TYPE,
     )
     return srcjar, class_info
 
diff --git a/rules/desugar.bzl b/rules/desugar.bzl
index c498e00..9d4420f 100644
--- a/rules/desugar.bzl
+++ b/rules/desugar.bzl
@@ -22,7 +22,8 @@
         bootclasspath = [],
         min_sdk_version = 0,
         library_desugaring = True,
-        desugar_exec = None):
+        desugar_exec = None,
+        toolchain_type = None):
     """Desugars a JAR.
 
     Args:
@@ -60,6 +61,7 @@
         mnemonic = "Desugar",
         progress_message = "Desugaring " + input.short_path + " for Android",
         execution_requirements = {"supports-workers": "1"},
+        toolchain = toolchain_type,
     )
 
 desugar = struct(
diff --git a/rules/dex.bzl b/rules/dex.bzl
index 5ba3fe3..d9d9273 100644
--- a/rules/dex.bzl
+++ b/rules/dex.bzl
@@ -14,7 +14,7 @@
 
 """Bazel Dex Commands."""
 
-load(":utils.bzl", "get_android_toolchain", "utils")
+load(":utils.bzl", "ANDROID_TOOLCHAIN_TYPE", "get_android_toolchain", "utils")
 load(":providers.bzl", "StarlarkAndroidDexInfo")
 load("@bazel_skylib//lib:collections.bzl", "collections")
 load("//rules:attrs.bzl", _attrs = "attrs")
@@ -31,7 +31,8 @@
         java_info = None,
         desugar_dict = {},
         dexbuilder = None,
-        dexmerger = None):
+        dexmerger = None,
+        toolchain_type = None):
     classes_dex_zip = _get_dx_artifact(ctx, "classes.dex.zip")
     info = _merge_infos(utils.collect_providers(StarlarkAndroidDexInfo, deps))
 
@@ -52,6 +53,7 @@
             incremental_dexopts = incremental_dexopts,
             min_sdk_version = min_sdk_version,
             dex_exec = dexbuilder,
+            toolchain_type = toolchain_type,
         )
         dex_archives.append(dex_archive)
 
@@ -63,6 +65,7 @@
         main_dex_list = main_dex_list,
         dexopts = dexopts,
         dexmerger = dexmerger,
+        toolchain_type = toolchain_type,
     )
 
     return classes_dex_zip
@@ -88,6 +91,7 @@
         mnemonic = "AppendJava8LegacyDex",
         use_default_shell_env = True,
         progress_message = "Adding Java8 legacy library for %s" % ctx.label,
+        toolchain = ANDROID_TOOLCHAIN_TYPE,
     )
 
 def _to_dexed_classpath(dex_archives_dict = {}, classpath = [], runtime_jars = []):
@@ -109,7 +113,8 @@
         output = None,
         incremental_dexopts = [],
         min_sdk_version = 0,
-        dex_exec = None):
+        dex_exec = None,
+        toolchain_type = None):
     """Dexes a JAR.
 
     Args:
@@ -143,6 +148,7 @@
         mnemonic = "DexBuilder",
         progress_message = "Dexing " + input.path + " with applicable dexopts " + str(incremental_dexopts),
         execution_requirements = execution_requirements,
+        toolchain = toolchain_type,
     )
 
 def _get_dx_artifact(ctx, basename):
@@ -195,6 +201,7 @@
             arguments = [args],
             mnemonic = "BuildLegacyDex",
             progress_message = "Building Java8 legacy library for %s" % ctx.label,
+            toolchain = ANDROID_TOOLCHAIN_TYPE,
         )
 
         return java8_legacy_dex, java8_legacy_dex_map
@@ -212,7 +219,8 @@
         multidex_strategy = "minimal",
         main_dex_list = None,
         dexopts = [],
-        dexmerger = None):
+        dexmerger = None,
+        toolchain_type = None):
     args = ctx.actions.args()
     args.add("--multidex", multidex_strategy)
     args.add_all(inputs, before_each = "--input")
@@ -230,6 +238,7 @@
         outputs = [output],
         mnemonic = "DexMerger",
         progress_message = "Assembling dex files into " + output.short_path,
+        toolchain = toolchain_type,
     )
 
 def _merger_dexopts(tokenized_dexopts, dexopts_supported_in_dex_merger):
diff --git a/rules/idl.bzl b/rules/idl.bzl
index c77b137..6ab76c9 100644
--- a/rules/idl.bzl
+++ b/rules/idl.bzl
@@ -16,7 +16,7 @@
 
 load(":java.bzl", _java = "java")
 load(":path.bzl", _path = "path")
-load(":utils.bzl", _log = "log")
+load(":utils.bzl", "ANDROID_TOOLCHAIN_TYPE", _log = "log")
 
 _AIDL_TOOLCHAIN_MISSING_ERROR = (
     "IDL sources provided without the Android IDL toolchain."
@@ -82,6 +82,7 @@
         outputs = [out_idl_java_src],
         mnemonic = "AndroidIDLGenerate",
         progress_message = "Android IDL generation %s" % idl_src.path,
+        toolchain = ANDROID_TOOLCHAIN_TYPE,
     )
 
 def _get_idl_import_root_path(
diff --git a/rules/intellij.bzl b/rules/intellij.bzl
index c870b1b..e0c0f95 100644
--- a/rules/intellij.bzl
+++ b/rules/intellij.bzl
@@ -32,7 +32,10 @@
     args.add("--manifest_proto", manifest_proto)
     args.add("--output_class_jar", out_jar)
     args.add("--output_source_jar", out_srcjar)
-    args.add("--temp_dir", out_jar.dirname)
+
+    # tmp directory is removed by the idl tool before each invocation so create a unique dir.
+    # See src/main/java/com/google/devtools/build/lib/rules/android/AndroidIdlHelper.java
+    args.add("--temp_dir", "%s/%s_idl_tmp" % (out_jar.dirname, ctx.label.name))
     args.add_all(idl_java_srcs)
 
     _java.run(
diff --git a/rules/java.bzl b/rules/java.bzl
index 03ca1e9..57c343a 100644
--- a/rules/java.bzl
+++ b/rules/java.bzl
@@ -392,6 +392,7 @@
 
     ctx.actions.run(
         executable = java_toolchain[java_common.JavaToolchainInfo].single_jar,
+        toolchain = "@bazel_tools//tools/jdk:toolchain_type",
         arguments = [args],
         inputs = inputs,
         outputs = [output],
@@ -433,6 +434,7 @@
 
     java_runtime = host_javabase[java_common.JavaRuntimeInfo]
     args["executable"] = java_runtime.java_executable_exec_path
+    args["toolchain"] = "@bazel_tools//tools/jdk:toolchain_type"
 
     # inputs can be a list or a depset of File
     inputs = args.get("inputs", default = [])
diff --git a/rules/native_deps.bzl b/rules/native_deps.bzl
index e5fe000..53d61ec 100644
--- a/rules/native_deps.bzl
+++ b/rules/native_deps.bzl
@@ -55,58 +55,13 @@
         name = name + "-hwasan"
     return name
 
-def process_java_infos(_ctx, deps):
-    """Collects JavaInfos for process()
-
-    Args:
-        _ctx: Unused ctx (need this for uniformity)
-        deps: List of deps
-
-    Returns:
-        List of JavaInfo.cc_link_params_info for all deps
-    """
-    return [dep[JavaInfo].cc_link_params_info for dep in deps if JavaInfo in dep]
-
-def process_android_cc_link_params_infos(_ctx, deps):
-    """Collects AndroidCcLinkParamsInfo for process()
-
-    Args:
-        _ctx: Unused ctx (need this for uniformity)
-        deps: List of deps
-
-    Returns:
-        List of AndroidCcLinkParamsInfo.link_params for all deps
-    """
-    return [dep[AndroidCcLinkParamsInfo].link_params for dep in deps if AndroidCcLinkParamsInfo in dep]
-
-def process_cc_infos(_ctx, deps):
-    """Collects CcInfos for process()
-
-    Args:
-        _ctx: Unused ctx (need this for uniformity)
-        deps: List of deps
-
-    Returns:
-        List of CcInfo's for all deps
-    """
-    return [dep[CcInfo] for dep in deps if CcInfo in dep]
-
-DEFAULT_NATIVE_DEP_SUBPROCESSORS = dict(
-    NativeDepsProcessJavaInfos = process_java_infos,
-    NativeDepsProcessAndroidCcLinkParamsInfos = process_android_cc_link_params_infos,
-    NativeDepsProcessCcInfos = process_cc_infos,
-)
-
-def process(ctx, filename, subprocessors = DEFAULT_NATIVE_DEP_SUBPROCESSORS):
+def process(ctx, filename):
     """ Links native deps into a shared library
 
     Args:
       ctx: The context.
       filename: String. The name of the artifact containing the name of the
             linked shared library
-      subprocessors: Dict of function pointers, each element of which handles native
-            dependency collection on a per-provider basis. Defaults to basic collection of JavaInfo,
-            AndroidCcLinkParamsInfo, and CcInfo providers.
 
     Returns:
         Tuple of (libs, libs_name) where libs is a depset of all native deps
@@ -129,16 +84,14 @@
             owner = ctx.label,
             user_link_flags = ["-Wl,-soname=lib" + actual_target_name],
         )
-
-        processed_cc_infos = []
-        for subproc in subprocessors.values():
-            processed_cc_infos.extend(subproc(ctx, deps))
         cc_info = cc_common.merge_cc_infos(
             cc_infos = _concat(
                 [CcInfo(linking_context = cc_common.create_linking_context(
                     linker_inputs = depset([linker_input]),
                 ))],
-                processed_cc_infos,
+                [dep[JavaInfo].cc_link_params_info for dep in deps if JavaInfo in dep],
+                [dep[AndroidCcLinkParamsInfo].link_params for dep in deps if AndroidCcLinkParamsInfo in dep],
+                [dep[CcInfo] for dep in deps if CcInfo in dep],
             ),
         )
         libraries = []
diff --git a/rules/proguard.bzl b/rules/proguard.bzl
index 22daf8b..6273c5c 100644
--- a/rules/proguard.bzl
+++ b/rules/proguard.bzl
@@ -14,8 +14,10 @@
 
 """Bazel Android Proguard library for the Android rules."""
 
+load(":android_neverlink_aspect.bzl", "StarlarkAndroidNeverlinkInfo")
 load(":common.bzl", "common")
-load(":utils.bzl", "utils")
+load(":java.bzl", "java")
+load(":utils.bzl", "ANDROID_TOOLCHAIN_TYPE", "get_android_sdk", "utils")
 
 _ProguardSpecContextInfo = provider(
     doc = "Contains data from processing Proguard specs.",
@@ -27,6 +29,21 @@
     ),
 )
 
+_ProguardOutputInfo = provider(
+    doc = "Temporary provider to hold all proguard outputs. Will be replaced by a native  " +
+          "provider. Useful for testing.",
+    fields = dict(
+        input_jar = "The input program jar, unoptimized",
+        output_jar = "The optimized output jar",
+        mapping = "Output proguard map",
+        proto_mapping = "Output proto mapping",
+        seeds = "Output seeds",
+        usage = "Output usage",
+        library_jar = "Merged library jar",
+        config = "Output config",
+    ),
+)
+
 def _validate_proguard_spec(
         ctx,
         out_validated_proguard_spec,
@@ -45,6 +62,7 @@
         progress_message = (
             "Validating proguard configuration %s" % proguard_spec.short_path
         ),
+        toolchain = ANDROID_TOOLCHAIN_TYPE,
     )
 
 def _process_specs(
@@ -110,11 +128,11 @@
     if len(local_proguard_specs) == 0:
         return []
 
-    proguard_specs = local_proguard_specs + specs_to_include
-    for dep in proguard_deps:
-        proguard_specs.extend(dep.specs.to_list())
-
-    return sorted(proguard_specs)
+    proguard_specs = depset(
+        local_proguard_specs + specs_to_include,
+        transitive = [dep.specs for dep in proguard_deps],
+    )
+    return sorted(proguard_specs.to_list())
 
 def _get_proguard_specs(
         ctx,
@@ -165,19 +183,418 @@
         progress_message = "Adding -assumevalues spec for minSdkVersion",
     )
 
+def _optimization_action(
+        ctx,
+        output_jar,
+        program_jar,
+        library_jar,
+        proguard_specs,
+        proguard_mapping = None,
+        proguard_output_map = None,
+        proguard_seeds = None,
+        proguard_usage = None,
+        proguard_config_output = None,
+        runtype = None,
+        last_stage_output = None,
+        next_stage_output = None,
+        final = False,
+        mnemonic = None,
+        progress_message = None,
+        proguard_tool = None):
+    """Creates a Proguard optimization action.
+
+    This method is expected to be called one or more times to create Proguard optimization actions.
+    Most outputs will only be generated by the final optimization action, and should otherwise be
+    set to None. For the final action set `final = True` which will register the output_jar as an
+    output of the action.
+
+    TODO(b/286955442): Support baseline profiles.
+
+    Args:
+      ctx: The context.
+      output_jar: File. The final output jar.
+      program_jar: File. The jar to be optimized.
+      library_jar: File. The merged library jar. While the underlying tooling supports multiple
+        library jars, we merge these into a single jar before processing.
+      proguard_specs: Sequence of files. A list of proguard specs to use for the optimization.
+      proguard_mapping: File. Optional file to be used as a mapping for proguard. A mapping file
+        generated by proguard_generate_mapping to be re-used to apply the same map to a new build.
+      proguard_output_map: File. Optional file to be used to write the output map of obfuscated
+        class and member names.
+      proguard_seeds: File. Optional file used to write the "seeds", which is a list of all
+        classes and members which match a keep rule.
+      proguard_usage: File. Optional file used to write all classes and members that are removed
+        during shrinking (i.e. unused code).
+      proguard_config_output:File. Optional file used to write the entire configuration that has
+        been parsed, included files and replaced variables. Useful for debugging.
+      runtype: String. Optional string identifying this run. One of [INITIAL, OPTIMIZATION, FINAL]
+      last_stage_output: File. Optional input file to this optimization stage, which was output by
+        the previous optimization stage.
+      next_stage_output: File. Optional output file from this optimization stage, which will be
+        consunmed by the next optimization stage.
+      final: Boolean. Whether this is the final optimization stage, which will register output_jar
+        as an output of this action.
+      mnemonic: String. Action mnemonic.
+      progress_message: String. Action progress message.
+      proguard_tool: FilesToRunProvider. The proguard tool to execute.
+
+    Returns:
+      None
+    """
+
+    inputs = []
+    outputs = []
+    args = ctx.actions.args()
+
+    args.add("-forceprocessing")
+
+    args.add("-injars", program_jar)
+    inputs.append(program_jar)
+
+    args.add("-outjars", output_jar)
+    if final:
+        outputs.append(output_jar)
+
+    args.add("-libraryjars", library_jar)
+    inputs.append(library_jar)
+
+    if proguard_mapping:
+        args.add("-applymapping", proguard_mapping)
+        inputs.append(proguard_mapping)
+
+    args.add_all(proguard_specs, format_each = "@%s")
+    inputs.extend(proguard_specs)
+
+    if proguard_output_map:
+        args.add("-printmapping", proguard_output_map)
+        outputs.append(proguard_output_map)
+
+    if proguard_seeds:
+        args.add("-printseeds", proguard_seeds)
+        outputs.append(proguard_seeds)
+
+    if proguard_usage:
+        args.add("-printusage", proguard_usage)
+        outputs.append(proguard_usage)
+
+    if proguard_config_output:
+        args.add("-printconfiguration", proguard_config_output)
+        outputs.append(proguard_config_output)
+
+    if runtype:
+        args.add("-runtype " + runtype)
+
+    if last_stage_output:
+        args.add("-laststageoutput", last_stage_output)
+        inputs.append(last_stage_output)
+
+    if next_stage_output:
+        args.add("-nextstageoutput", next_stage_output)
+        outputs.append(next_stage_output)
+
+    ctx.actions.run(
+        outputs = outputs,
+        inputs = inputs,
+        executable = proguard_tool,
+        arguments = [args],
+        mnemonic = mnemonic,
+        progress_message = progress_message,
+        toolchain = None,  # TODO(timpeut): correctly set this based off which optimizer is selected
+    )
+
 def _get_proguard_temp_artifact_with_prefix(ctx, label, prefix, name):
     native_label_name = label.name.removesuffix(common.PACKAGED_RESOURCES_SUFFIX)
     return ctx.actions.declare_file("proguard/" + native_label_name + "/" + prefix + "_" + native_label_name + "_" + name)
 
+def _get_proguard_temp_artifact(ctx, name):
+    return _get_proguard_temp_artifact_with_prefix(ctx, ctx.label, "MIGRATED", name)
+
+def _get_proguard_output_map(ctx):
+    return ctx.actions.declare_file(ctx.label.name.removesuffix(common.PACKAGED_RESOURCES_SUFFIX) + "_proguard_MIGRATED_.map")
+
+def _apply_proguard(
+        ctx,
+        input_jar = None,
+        proguard_specs = [],
+        proguard_optimization_passes = None,
+        proguard_mapping = None,
+        proguard_output_jar = None,
+        proguard_output_map = None,
+        proguard_seeds = None,
+        proguard_usage = None,
+        proguard_tool = None):
+    """Top-level method to apply proguard to a jar.
+
+    Args:
+      ctx: The context
+      input_jar: File. The input jar to optimized.
+      proguard_specs: List of Files. The proguard specs to use for optimization.
+      proguard_optimization_passes: Integer. The number of proguard passes to apply.
+      proguard_mapping: File. The proguard mapping to apply.
+      proguard_output_jar: File. The output optimized jar.
+      proguard_output_map: File. The output proguard map.
+      proguard_seeds: File. The output proguard seeds.
+      proguard_usage: File. The output proguard usage.
+      proguard_tool: FilesToRun. The proguard executable.
+
+    Returns:
+      A struct of proguard outputs, corresponding to the fields in ProguardOutputInfo.
+    """
+    if not proguard_specs:
+        # Fail at execution time if these artifacts are requested, to avoid issue where outputs are
+        # declared without having any proguard specs. This can happen if specs is a select() that
+        # resolves to an empty list.
+        _fail_action(
+            ctx,
+            proguard_output_jar,
+            proguard_output_map,
+            proguard_seeds,
+            proguard_usage,
+        )
+        return None
+
+    library_jar_list = [get_android_sdk(ctx).android_jar]
+    if ctx.fragments.android.desugar_java8:
+        library_jar_list.append(ctx.file._desugared_java8_legacy_apis)
+    neverlink_infos = utils.collect_providers(StarlarkAndroidNeverlinkInfo, ctx.attr.deps)
+    library_jars = depset(library_jar_list, transitive = [info.transitive_neverlink_libraries for info in neverlink_infos])
+
+    return _create_optimization_actions(
+        ctx,
+        proguard_specs,
+        proguard_seeds,
+        proguard_usage,
+        proguard_mapping,
+        proguard_output_jar,
+        proguard_optimization_passes,
+        proguard_output_map,
+        input_jar,
+        library_jars,
+        proguard_tool,
+    )
+
+def _get_proguard_output(
+        ctx,
+        proguard_output_jar,
+        proguard_seeds,
+        proguard_usage,
+        proguard_output_map,
+        combined_library_jar):
+    """Helper method to get a struct of all proguard outputs."""
+    config_output = _get_proguard_temp_artifact(ctx, "_proguard.config")
+
+    return struct(
+        output_jar = proguard_output_jar,
+        mapping = proguard_output_map,
+        seeds = proguard_seeds,
+        usage = proguard_usage,
+        library_jar = combined_library_jar,
+        config = config_output,
+    )
+
+def _create_optimization_actions(
+        ctx,
+        proguard_specs = None,
+        proguard_seeds = None,
+        proguard_usage = None,
+        proguard_mapping = None,
+        proguard_output_jar = None,
+        num_passes = None,
+        proguard_output_map = None,
+        input_jar = None,
+        library_jars = depset(),
+        proguard_tool = None):
+    """Helper method to create all optimizaction actions based on the target configuration."""
+    if not proguard_specs:
+        fail("Missing proguard_specs in create_optimization_actions")
+
+    # Merge all library jars into a single jar
+    combined_library_jar = _get_proguard_temp_artifact(ctx, "_migrated_combined_library_jars.jar")
+    java.singlejar(
+        ctx,
+        library_jars,
+        combined_library_jar,
+        java_toolchain = common.get_java_toolchain(ctx),
+    )
+
+    # Filter library jar with program jar
+    filtered_library_jar = _get_proguard_temp_artifact(ctx, "_migrated_combined_library_jars_filtered.jar")
+    common.filter_zip_exclude(
+        ctx,
+        filtered_library_jar,
+        combined_library_jar,
+        filter_zips = [input_jar],
+    )
+
+    outputs = _get_proguard_output(
+        ctx,
+        proguard_output_jar,
+        proguard_seeds,
+        proguard_usage,
+        proguard_output_map,
+        combined_library_jar,
+    )
+
+    # TODO(timpeut): Validate that optimizer target selection is correct
+    mnemonic = ctx.fragments.java.bytecode_optimizer_mnemonic
+    optimizer_target = ctx.executable._bytecode_optimizer
+
+    # If num_passes is not specified run a single optimization action
+    if not num_passes:
+        _optimization_action(
+            ctx,
+            outputs.output_jar,
+            input_jar,
+            filtered_library_jar,
+            proguard_specs,
+            proguard_mapping = proguard_mapping,
+            proguard_output_map = outputs.mapping,
+            proguard_seeds = outputs.seeds,
+            proguard_usage = outputs.usage,
+            proguard_config_output = outputs.config,
+            final = True,
+            mnemonic = mnemonic,
+            progress_message = "Trimming binary with %s: %s" % (mnemonic, ctx.label),
+            proguard_tool = proguard_tool,
+        )
+        return outputs
+
+    # num_passes has been specified, create multiple proguard actions
+    split_bytecode_optimization_passes = ctx.fragments.java.split_bytecode_optimization_pass
+    bytecode_optimization_pass_actions = ctx.fragments.java.bytecode_optimization_pass_actions
+    last_stage_output = _get_proguard_temp_artifact(ctx, "_proguard_preoptimization.jar")
+    _optimization_action(
+        ctx,
+        outputs.output_jar,
+        input_jar,
+        filtered_library_jar,
+        proguard_specs,
+        proguard_mapping = proguard_mapping,
+        proguard_output_map = None,
+        proguard_seeds = outputs.seeds,
+        proguard_usage = None,
+        proguard_config_output = None,
+        final = False,
+        runtype = "INITIAL",
+        next_stage_output = last_stage_output,
+        mnemonic = mnemonic,
+        progress_message = "Trimming binary with %s: Verification/Shrinking Pass" % mnemonic,
+        proguard_tool = proguard_tool,
+    )
+    for i in range(1, num_passes + 1):
+        if split_bytecode_optimization_passes and bytecode_optimization_pass_actions < 2:
+            last_stage_output = _create_single_optimization_action(
+                ctx,
+                outputs.output_jar,
+                input_jar,
+                filtered_library_jar,
+                proguard_specs,
+                proguard_mapping,
+                i,
+                "_INITIAL",
+                mnemonic,
+                last_stage_output,
+                optimizer_target,
+            )
+            last_stage_output = _create_single_optimization_action(
+                ctx,
+                outputs.output_jar,
+                input_jar,
+                filtered_library_jar,
+                proguard_specs,
+                proguard_mapping,
+                i,
+                "_FINAL",
+                mnemonic,
+                last_stage_output,
+                optimizer_target,
+            )
+        else:
+            for j in range(1, bytecode_optimization_pass_actions + 1):
+                last_stage_output = _create_single_optimization_action(
+                    ctx,
+                    outputs.output_jar,
+                    input_jar,
+                    filtered_library_jar,
+                    proguard_specs,
+                    proguard_mapping,
+                    i,
+                    "_ACTION_%s_OF_%s_" % (j, bytecode_optimization_pass_actions),
+                    mnemonic,
+                    last_stage_output,
+                    optimizer_target,
+                )
+
+    _optimization_action(
+        ctx,
+        outputs.output_jar,
+        input_jar,
+        filtered_library_jar,
+        proguard_specs,
+        proguard_mapping = proguard_mapping,
+        proguard_output_map = outputs.mapping,
+        proguard_seeds = None,
+        proguard_usage = outputs.usage,
+        proguard_config_output = outputs.config,
+        final = True,
+        runtype = "FINAL",
+        last_stage_output = last_stage_output,
+        mnemonic = mnemonic,
+        progress_message = "Trimming binary with %s: Obfuscation and Final Output Pass" % mnemonic,
+        proguard_tool = proguard_tool,
+    )
+    return outputs
+
+def _create_single_optimization_action(
+        ctx,
+        output_jar,
+        program_jar,
+        library_jar,
+        proguard_specs,
+        proguard_mapping,
+        optimization_pass_num,
+        runtype_suffix,
+        mnemonic,
+        last_stage_output,
+        proguard_tool):
+    next_stage_output = _get_proguard_temp_artifact(ctx, "_%s_optimization%s_%s.jar" % (mnemonic, runtype_suffix, optimization_pass_num))
+    _optimization_action(
+        ctx,
+        output_jar,
+        program_jar,
+        library_jar,
+        proguard_specs,
+        proguard_mapping = proguard_mapping,
+        mnemonic = mnemonic,
+        final = False,
+        runtype = "OPTIMIZATION" + runtype_suffix,
+        last_stage_output = last_stage_output,
+        next_stage_output = next_stage_output,
+        progress_message = "Trimming binary with %s: Optimization%s Pass %d" % (mnemonic, runtype_suffix, optimization_pass_num),
+        proguard_tool = proguard_tool,
+    )
+    return next_stage_output
+
+def _fail_action(ctx, *outputs):
+    ctx.actions.run_shell(
+        outputs = outputs,
+        command = "echo \"Unable to run proguard without `proguard_specs`\"; exit 1;",
+    )
+
 proguard = struct(
+    apply_proguard = _apply_proguard,
     process_specs = _process_specs,
     generate_min_sdk_version_assumevalues = _generate_min_sdk_version_assumevalues,
+    get_proguard_output_map = _get_proguard_output_map,
     get_proguard_specs = _get_proguard_specs,
+    get_proguard_temp_artifact = _get_proguard_temp_artifact,
     get_proguard_temp_artifact_with_prefix = _get_proguard_temp_artifact_with_prefix,
 )
 
 testing = struct(
     validate_proguard_spec = _validate_proguard_spec,
     collect_transitive_proguard_specs = _collect_transitive_proguard_specs,
+    optimization_action = _optimization_action,
     ProguardSpecContextInfo = _ProguardSpecContextInfo,
+    ProguardOutputInfo = _ProguardOutputInfo,
 )
diff --git a/rules/resources.bzl b/rules/resources.bzl
index 23f4edd..aec519f 100644
--- a/rules/resources.bzl
+++ b/rules/resources.bzl
@@ -119,6 +119,7 @@
 _PACKAGED_VALIDATION_RESULT = "validation_result"
 _RESOURCE_MINSDK_PROGUARD_CONFIG = "resource_minsdk_proguard_config"
 _RESOURCE_PROGUARD_CONFIG = "resource_proguard_config"
+_ANDROID_APPLICATION_RESOURCE = "android_application_resource"
 
 _ResourcesPackageContextInfo = provider(
     "Packaged resources context object",
@@ -132,6 +133,7 @@
         _RESOURCE_MINSDK_PROGUARD_CONFIG: "Resource minSdkVersion proguard config",
         _RESOURCE_PROGUARD_CONFIG: "Resource proguard config",
         _PROVIDERS: "The list of all providers to propagate.",
+        _ANDROID_APPLICATION_RESOURCE: "The AndroidApplicationResourceInfo provider.",
     },
 )
 
@@ -210,6 +212,7 @@
         outputs = [out_manifest],
         mnemonic = "AddG3ITRStarlark",
         progress_message = "Adding G3ITR to test manifest for %s" % ctx.label,
+        toolchain = None,
     )
 
 def _get_legacy_mergee_manifests(resources_infos):
@@ -273,6 +276,7 @@
 """,
         arguments = [manifest_args, args, manifest_params.path],
         outputs = [manifest_params],
+        toolchain = None,
     )
     args = ctx.actions.args()
     args.add(manifest_params, format = "--flagfile=%s")
@@ -284,6 +288,7 @@
         outputs = [out_merged_manifest],
         mnemonic = "StarlarkLegacyAndroidManifestMerger",
         progress_message = "Merging Android Manifests",
+        toolchain = None,
     )
 
 def _make_databinding_outputs(
@@ -343,6 +348,7 @@
         inputs = [compiled_resources],
         tools = [zip_tool],
         arguments = [compiled_resources.path, out_compiled_resources.path, zip_tool.executable.path],
+        toolchain = None,
         command = """#!/bin/bash
 set -e
 
@@ -461,7 +467,8 @@
         xsltproc = None,
         instrument_xslt = None,
         busybox = None,
-        host_javabase = None):
+        host_javabase = None,
+        add_application_resource_info_to_providers = True):
     """Package resources for top-level rules.
 
     Args:
@@ -513,6 +520,7 @@
       minsdk_proguard_config: Optional file. Proguard config for the minSdkVersion to include in the
         returned resource context.
       aapt: FilesToRunProvider. The aapt executable or FilesToRunProvider.
+      has_local_proguard_specs: If the target has proguard specs.
       android_jar: File. The Android jar.
       legacy_merger: FilesToRunProvider. The legacy manifest merger executable.
       xsltproc: FilesToRunProvider. The xsltproc executable or
@@ -522,6 +530,8 @@
       busybox: FilesToRunProvider. The ResourceBusyBox executable or
         FilesToRunprovider
       host_javabase: A Target. The host javabase.
+      add_application_resource_info_to_providers: boolean. Whether to add the
+          AndroidApplicationResourceInfo provider to the list of providers for this processor.
 
     Returns:
       A ResourcesPackageContextInfo containing packaged resource artifacts and
@@ -774,7 +784,7 @@
         transitive_resource_apks = depset(),
     ))
 
-    packaged_resources_ctx[_PROVIDERS].append(AndroidApplicationResourceInfo(
+    android_application_resource_info = AndroidApplicationResourceInfo(
         resource_apk = resource_apk,
         resource_java_src_jar = r_java,
         resource_java_class_jar = class_jar,
@@ -785,7 +795,11 @@
         resources_zip = resource_files_zip,
         databinding_info = data_binding_layout_info,
         should_compile_java_srcs = should_compile_java_srcs,
-    ))
+    )
+    packaged_resources_ctx[_ANDROID_APPLICATION_RESOURCE] = android_application_resource_info
+    if add_application_resource_info_to_providers:
+        packaged_resources_ctx[_PROVIDERS].append(android_application_resource_info)
+
     return _ResourcesPackageContextInfo(**packaged_resources_ctx)
 
 def _liteparse(ctx, out_r_pb, resource_files, android_kit):
@@ -811,6 +825,7 @@
         outputs = [out_r_pb],
         mnemonic = "ResLiteParse",
         progress_message = "Lite parse Android Resources %s" % ctx.label,
+        toolchain = None,
     )
 
 def _fastr(ctx, r_pbs, package, manifest, android_kit):
@@ -1070,6 +1085,7 @@
         arguments = [args],
         mnemonic = "BumpMinSdkFloor",
         progress_message = "Bumping up AndroidManifest min SDK %s" % str(ctx.label),
+        toolchain = None,
     )
     manifest_ctx[_PROCESSED_MANIFEST] = out_manifest
 
@@ -1120,6 +1136,7 @@
         arguments = [args],
         mnemonic = "SetDefaultMinSdkFloor",
         progress_message = "Setting AndroidManifest min SDK to default %s" % str(ctx.label),
+        toolchain = None,
     )
     manifest_ctx[_PROCESSED_MANIFEST] = out_manifest
 
@@ -1164,6 +1181,7 @@
         arguments = [args],
         mnemonic = "ValidateMinSdkFloor",
         progress_message = "Validating AndroidManifest min SDK %s" % str(ctx.label),
+        toolchain = None,
     )
     manifest_validation_ctx[_VALIDATION_OUTPUTS].append(log)
 
@@ -1894,6 +1912,7 @@
     set_default_min_sdk = _set_default_min_sdk,
 
     # Exposed for android_binary
+    is_resource_shrinking_enabled = _is_resource_shrinking_enabled,
     validate_min_sdk = _validate_min_sdk,
 
     # Exposed for android_library, aar_import, and android_binary
diff --git a/rules/rules.bzl b/rules/rules.bzl
index ecaef19..ed1cd2d 100644
--- a/rules/rules.bzl
+++ b/rules/rules.bzl
@@ -37,6 +37,14 @@
     _android_ndk_repository = "android_ndk_repository",
 )
 load(
+    "//rules/android_sandboxed_sdk:android_sandboxed_sdk.bzl",
+    _android_sandboxed_sdk = "android_sandboxed_sdk",
+)
+load(
+    "//rules/android_sandboxed_sdk:android_sandboxed_sdk_bundle.bzl",
+    _android_sandboxed_sdk_bundle = "android_sandboxed_sdk_bundle",
+)
+load(
     "//rules:android_sdk.bzl",
     _android_sdk = "android_sdk",
 )
@@ -57,6 +65,8 @@
 android_binary = _android_binary
 android_library = _android_library
 android_ndk_repository = _android_ndk_repository
+android_sandboxed_sdk = _android_sandboxed_sdk
+android_sandboxed_sdk_bundle = _android_sandboxed_sdk_bundle
 android_sdk = _android_sdk
 android_sdk_repository = _android_sdk_repository
 android_tools_defaults_jar = _android_tools_defaults_jar
diff --git a/rules/utils.bzl b/rules/utils.bzl
index e9a28cc..e54a17f 100644
--- a/rules/utils.bzl
+++ b/rules/utils.bzl
@@ -16,6 +16,8 @@
 
 load(":providers.bzl", "FailureInfo")
 
+ANDROID_TOOLCHAIN_TYPE = "//toolchains/android:toolchain_type"
+
 _CUU = "\033[A"
 _EL = "\033[K"
 _DEFAULT = "\033[0m"
@@ -414,7 +416,7 @@
     )
 
 def get_android_toolchain(ctx):
-    return ctx.toolchains["//toolchains/android:toolchain_type"]
+    return ctx.toolchains[ANDROID_TOOLCHAIN_TYPE]
 
 def get_android_sdk(ctx):
     if hasattr(ctx.fragments.android, "incompatible_use_toolchain_resolution") and ctx.fragments.android.incompatible_use_toolchain_resolution:
diff --git a/src/tools/bundletool_module_builder/BUILD b/src/tools/bundletool_module_builder/BUILD
new file mode 100644
index 0000000..142f89f
--- /dev/null
+++ b/src/tools/bundletool_module_builder/BUILD
@@ -0,0 +1,14 @@
+load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library")
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library", "go_test")
+
+package(
+    default_applicable_licenses = ["//:license"],
+    default_visibility = ["//visibility:public"],
+)
+
+licenses(["notice"])
+
+go_binary(
+    name = "bundletool_module_builder",
+    srcs = ["bundletool_module_builder.go"],
+)
diff --git a/src/tools/bundletool_module_builder/bundletool_module_builder.go b/src/tools/bundletool_module_builder/bundletool_module_builder.go
new file mode 100644
index 0000000..ded6cad
--- /dev/null
+++ b/src/tools/bundletool_module_builder/bundletool_module_builder.go
@@ -0,0 +1,88 @@
+// Copyright 2023 The Bazel Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Tool for building Bundletool modules for apps and SDKs.
+package main
+
+import (
+	"archive/zip"
+	"flag"
+	"log"
+	"os"
+	"strings"
+)
+
+var (
+	internalApkPathFlag  = flag.String("internal_apk_path", "", "Path to an APK that contains the SDK classes and resources.")
+	outputModulePathFlag = flag.String("output_module_path", "", "Path to the resulting module, ready to be sent to Bundletool.")
+)
+
+func main() {
+	flag.Parse()
+	if *internalApkPathFlag == "" {
+		log.Fatal("Missing internal APK path")
+	}
+
+	if *internalApkPathFlag == "" {
+		log.Fatal("Missing ouput module path")
+	}
+	err := unzipApkAndCreateModule(*internalApkPathFlag, *outputModulePathFlag)
+	if err != nil {
+		log.Fatal(err)
+	}
+}
+
+func unzipApkAndCreateModule(internalApkPath, outputModulePath string) error {
+	r, err := zip.OpenReader(internalApkPath)
+	if err != nil {
+		return err
+	}
+	defer r.Close()
+
+	w, err := os.Create(outputModulePath)
+	if err != nil {
+		return err
+	}
+	defer w.Close()
+	zipWriter := zip.NewWriter(w)
+	defer zipWriter.Close()
+
+	for _, f := range r.File {
+		f.Name = fileNameInOutput(f.Name)
+		if err := zipWriter.Copy(f); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func fileNameInOutput(oldName string) string {
+	switch {
+	// Passthrough files. They will just be copied into the output module.
+	case oldName == "resources.pb" ||
+		strings.HasPrefix(oldName, "res/") ||
+		strings.HasPrefix(oldName, "assets/") ||
+		strings.HasPrefix(oldName, "lib/"):
+		return oldName
+	// Manifest should be moved to manifest/ dir.
+	case oldName == "AndroidManifest.xml":
+		return "manifest/AndroidManifest.xml"
+	// Dex files need to be moved under dex/ dir.
+	case strings.HasSuffix(oldName, ".dex"):
+		return "dex/" + oldName
+	// All other files (probably JVM metadata files) should be moved to root/ dir.
+	default:
+		return "root/" + oldName
+	}
+}
diff --git a/src/tools/enforce_min_sdk_floor/BUILD b/src/tools/enforce_min_sdk_floor/BUILD
index 221fe8b..01b0578 100644
--- a/src/tools/enforce_min_sdk_floor/BUILD
+++ b/src/tools/enforce_min_sdk_floor/BUILD
@@ -1,5 +1,7 @@
 # Description:
 #   Package for tool to enforce min SDK floor on AndroidManifests
+load("@rules_python//python:defs.bzl", "py_binary", "py_test")
+
 package(default_visibility = ["//visibility:public"])
 
 licenses(["notice"])
diff --git a/src/tools/java/com/google/devtools/build/android/sandboxedsdktoolbox/BUILD b/src/tools/java/com/google/devtools/build/android/sandboxedsdktoolbox/BUILD
new file mode 100644
index 0000000..17ec508
--- /dev/null
+++ b/src/tools/java/com/google/devtools/build/android/sandboxedsdktoolbox/BUILD
@@ -0,0 +1,29 @@
+# Common tools for managing sandboxed SDKs.
+# Sandboxed SDKs are libraries that are released separately from Android apps and can run in the
+# Privacy Sandbox.
+
+package(
+    default_applicable_licenses = ["//:license"],
+    default_visibility = ["//:__subpackages__"],
+)
+
+licenses(["notice"])
+
+java_library(
+    name = "sandboxed_sdk_toolbox_lib",
+    srcs = glob(["*.java"]),
+    deps = [
+        "//src/tools/java/com/google/devtools/build/android/sandboxedsdktoolbox/apidescriptors",
+        "//src/tools/java/com/google/devtools/build/android/sandboxedsdktoolbox/sdkdependenciesmanifest",
+        "@rules_android_maven//:info_picocli_picocli",
+    ],
+)
+
+java_binary(
+    name = "sandboxed_sdk_toolbox",
+    main_class = "com.google.devtools.build.android.sandboxedsdktoolbox.SandboxedSdkToolbox",
+    visibility = ["//visibility:public"],
+    runtime_deps = [
+        ":sandboxed_sdk_toolbox_lib",
+    ],
+)
diff --git a/src/tools/java/com/google/devtools/build/android/sandboxedsdktoolbox/SandboxedSdkToolbox.java b/src/tools/java/com/google/devtools/build/android/sandboxedsdktoolbox/SandboxedSdkToolbox.java
new file mode 100644
index 0000000..b555a61
--- /dev/null
+++ b/src/tools/java/com/google/devtools/build/android/sandboxedsdktoolbox/SandboxedSdkToolbox.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2023 The Bazel Authors. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.devtools.build.android.sandboxedsdktoolbox;
+
+import com.google.devtools.build.android.sandboxedsdktoolbox.apidescriptors.ExtractApiDescriptorsCommand;
+import com.google.devtools.build.android.sandboxedsdktoolbox.sdkdependenciesmanifest.GenerateSdkDependenciesManifestCommand;
+import picocli.CommandLine;
+import picocli.CommandLine.Command;
+
+/** Entrypoint for the Sandboxed SDK Toolbox binary. */
+@Command(
+    name = "sandboxed-sdk-toolbox",
+    subcommands = {
+      ExtractApiDescriptorsCommand.class,
+      GenerateSdkDependenciesManifestCommand.class,
+    })
+public final class SandboxedSdkToolbox {
+
+  public static final CommandLine create() {
+    return new CommandLine(new SandboxedSdkToolbox());
+  }
+
+  public static final void main(String[] args) {
+    SandboxedSdkToolbox.create().execute(args);
+  }
+
+  private SandboxedSdkToolbox() {}
+}
diff --git a/src/tools/java/com/google/devtools/build/android/sandboxedsdktoolbox/apidescriptors/BUILD b/src/tools/java/com/google/devtools/build/android/sandboxedsdktoolbox/apidescriptors/BUILD
new file mode 100644
index 0000000..7fe0422
--- /dev/null
+++ b/src/tools/java/com/google/devtools/build/android/sandboxedsdktoolbox/apidescriptors/BUILD
@@ -0,0 +1,17 @@
+# Command to extract API descriptors from a sandboxed SDK.
+
+package(
+    default_applicable_licenses = ["//:license"],
+    default_visibility = ["//:__subpackages__"],
+)
+
+licenses(["notice"])
+
+java_library(
+    name = "apidescriptors",
+    srcs = glob(["*.java"]),
+    deps = [
+        "@rules_android_maven//:androidx_privacysandbox_tools_tools_apipackager",
+        "@rules_android_maven//:info_picocli_picocli",
+    ],
+)
diff --git a/src/tools/java/com/google/devtools/build/android/sandboxedsdktoolbox/apidescriptors/ExtractApiDescriptorsCommand.java b/src/tools/java/com/google/devtools/build/android/sandboxedsdktoolbox/apidescriptors/ExtractApiDescriptorsCommand.java
new file mode 100644
index 0000000..22882d1
--- /dev/null
+++ b/src/tools/java/com/google/devtools/build/android/sandboxedsdktoolbox/apidescriptors/ExtractApiDescriptorsCommand.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2023 The Bazel Authors. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.devtools.build.android.sandboxedsdktoolbox.apidescriptors;
+
+import static java.nio.file.Files.createTempDirectory;
+
+import androidx.privacysandbox.tools.apipackager.PrivacySandboxApiPackager;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+
+/** Command that Extracts API descriptors from a sandboxed SDK's classpath. */
+@Command(
+    name = "extract-api-descriptors",
+    description = "Extracts API descriptors from a sandboxed SDK's classpath.")
+public final class ExtractApiDescriptorsCommand implements Runnable {
+
+  @Option(names = "--sdk-deploy-jar", required = true)
+  Path sdkDeployJarPath;
+
+  @Option(names = "--output-sdk-api-descriptors", required = true)
+  Path outputSdkApiDescriptorsPath;
+
+  private final PrivacySandboxApiPackager packager = new PrivacySandboxApiPackager();
+
+  @Override
+  public void run() {
+    try {
+      Path sdkClasspath = unzipSdkDeployJar();
+      packager.packageSdkDescriptors(sdkClasspath, outputSdkApiDescriptorsPath);
+    } catch (IOException e) {
+      throw new UncheckedIOException("Failed to package SDK API descriptors.", e);
+    }
+  }
+
+  private Path unzipSdkDeployJar() throws IOException {
+    Path sdkClasspath = createTempDirectory("tmp-sdk-classpath");
+    try (InputStream inputStream = Files.newInputStream(sdkDeployJarPath);
+        ZipInputStream zipInputStream = new ZipInputStream(inputStream)) {
+
+      ZipEntry entry = null;
+      while ((entry = zipInputStream.getNextEntry()) != null) {
+        Path entryPath = sdkClasspath.resolve(entry.getName()).normalize();
+        if (entry.isDirectory()) {
+          continue;
+        }
+
+        if (!entryPath.startsWith(sdkClasspath)) {
+          throw new IOException(
+              String.format("Invalid entry name in SDK classpath zip: %s", entry.getName()));
+        }
+
+        Files.createDirectories(entryPath.getParent());
+        Files.copy(zipInputStream, entryPath);
+      }
+    }
+    return sdkClasspath;
+  }
+
+  private ExtractApiDescriptorsCommand() {}
+}
diff --git a/src/tools/java/com/google/devtools/build/android/sandboxedsdktoolbox/config/BUILD b/src/tools/java/com/google/devtools/build/android/sandboxedsdktoolbox/config/BUILD
new file mode 100644
index 0000000..cbc3f38
--- /dev/null
+++ b/src/tools/java/com/google/devtools/build/android/sandboxedsdktoolbox/config/BUILD
@@ -0,0 +1,17 @@
+# Utilities for SDK module config proto message.
+
+package(
+    default_applicable_licenses = ["//:license"],
+    default_visibility = ["//src:__subpackages__"],
+)
+
+licenses(["notice"])
+
+java_library(
+    name = "config",
+    srcs = glob(["*.java"]),
+    deps = [
+        "@rules_android_maven//:com_android_tools_build_bundletool",
+        "@rules_android_maven//:com_google_protobuf_protobuf_java_util",
+    ],
+)
diff --git a/src/tools/java/com/google/devtools/build/android/sandboxedsdktoolbox/config/SdkModulesConfigUtils.java b/src/tools/java/com/google/devtools/build/android/sandboxedsdktoolbox/config/SdkModulesConfigUtils.java
new file mode 100644
index 0000000..e023096
--- /dev/null
+++ b/src/tools/java/com/google/devtools/build/android/sandboxedsdktoolbox/config/SdkModulesConfigUtils.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2023 The Bazel Authors. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.devtools.build.android.sandboxedsdktoolbox.config;
+
+import com.android.bundle.SdkModulesConfigOuterClass.SdkModulesConfig;
+import com.android.tools.build.bundletool.model.RuntimeEnabledSdkVersionEncoder;
+import com.google.protobuf.util.JsonFormat;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+/** Utilities for creating and extracting information from {@link SdkModulesConfig} messages. */
+public final class SdkModulesConfigUtils {
+
+  public static SdkModulesConfig readFromJsonFile(Path configPath) {
+    SdkModulesConfig.Builder builder = SdkModulesConfig.newBuilder();
+    try {
+      JsonFormat.parser().merge(Files.newBufferedReader(configPath), builder);
+      return builder.build();
+    } catch (IOException e) {
+      throw new UncheckedIOException("Failed to parse SDK Module Config.", e);
+    }
+  }
+
+  public static long getVersionMajor(SdkModulesConfig config) {
+    return RuntimeEnabledSdkVersionEncoder.encodeSdkMajorAndMinorVersion(
+        config.getSdkVersion().getMajor(), config.getSdkVersion().getMinor());
+  }
+
+  private SdkModulesConfigUtils() {}
+}
diff --git a/src/tools/java/com/google/devtools/build/android/sandboxedsdktoolbox/sdkdependenciesmanifest/AndroidManifestWriter.java b/src/tools/java/com/google/devtools/build/android/sandboxedsdktoolbox/sdkdependenciesmanifest/AndroidManifestWriter.java
new file mode 100644
index 0000000..f840c5f
--- /dev/null
+++ b/src/tools/java/com/google/devtools/build/android/sandboxedsdktoolbox/sdkdependenciesmanifest/AndroidManifestWriter.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright 2023 The Bazel Authors. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.devtools.build.android.sandboxedsdktoolbox.sdkdependenciesmanifest;
+
+import static com.google.devtools.build.android.sandboxedsdktoolbox.config.SdkModulesConfigUtils.getVersionMajor;
+
+import com.android.bundle.SdkModulesConfigOuterClass.SdkModulesConfig;
+import com.google.common.collect.ImmutableSet;
+import java.io.BufferedOutputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.file.Path;
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.parsers.ParserConfigurationException;
+import javax.xml.transform.OutputKeys;
+import javax.xml.transform.Transformer;
+import javax.xml.transform.TransformerException;
+import javax.xml.transform.TransformerFactory;
+import javax.xml.transform.dom.DOMSource;
+import javax.xml.transform.stream.StreamResult;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+
+/** Writes an Android manifest that lists SDK dependencies for an app. */
+final class AndroidManifestWriter {
+
+  private static final String ANDROID_NAME_ATTRIBUTE = "android:name";
+  private static final String ANDROID_VERSION_MAJOR_ATTRIBUTE = "android:versionMajor";
+  private static final String ANDROID_CERTIFICATE_DIGEST_ATTRIBUTE = "android:certDigest";
+  private static final String APPLICATION_ELEMENT_NAME = "application";
+  private static final String MANIFEST_ELEMENT_NAME = "manifest";
+  private static final String MANIFEST_NAMESPACE_URI = "http://schemas.android.com/apk/res/android";
+  private static final String MANIFEST_NAMESPACE_NAME = "xmlns:android";
+  private static final String MANIFEST_PACKAGE_ATTRIBUTE = "package";
+  private static final String SDK_DEPENDENCY_ELEMENT_NAME = "uses-sdk-library";
+
+  static void writeManifest(
+      String packageName,
+      String certificateDigest,
+      ImmutableSet<SdkModulesConfig> configs,
+      Path outputPath) {
+    Document root = newEmptyDocument();
+
+    Element manifestNode = root.createElement(MANIFEST_ELEMENT_NAME);
+    manifestNode.setAttribute(MANIFEST_NAMESPACE_NAME, MANIFEST_NAMESPACE_URI);
+    manifestNode.setAttribute(MANIFEST_PACKAGE_ATTRIBUTE, packageName);
+    root.appendChild(manifestNode);
+
+    Element applicationNode = root.createElement(APPLICATION_ELEMENT_NAME);
+    manifestNode.appendChild(applicationNode);
+
+    for (SdkModulesConfig config : configs) {
+      Element sdkDependencyElement = root.createElement(SDK_DEPENDENCY_ELEMENT_NAME);
+      sdkDependencyElement.setAttribute(ANDROID_NAME_ATTRIBUTE, config.getSdkPackageName());
+      sdkDependencyElement.setAttribute(
+          ANDROID_VERSION_MAJOR_ATTRIBUTE, Long.toString(getVersionMajor(config)));
+      sdkDependencyElement.setAttribute(ANDROID_CERTIFICATE_DIGEST_ATTRIBUTE, certificateDigest);
+      applicationNode.appendChild(sdkDependencyElement);
+    }
+
+    writeDocument(root, outputPath);
+  }
+
+  private static Document newEmptyDocument() {
+    try {
+      return DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument();
+    } catch (ParserConfigurationException e) {
+      throw new IllegalStateException("Failed to create new XML document.", e);
+    }
+  }
+
+  private static void writeDocument(Document document, Path outputPath) {
+    try (BufferedOutputStream outputStream =
+        new BufferedOutputStream(new FileOutputStream(outputPath.toFile()))) {
+      Transformer transformer = TransformerFactory.newInstance().newTransformer();
+      transformer.setOutputProperty(OutputKeys.ENCODING, "utf-8");
+      transformer.setOutputProperty(OutputKeys.METHOD, "xml");
+      transformer.setOutputProperty(OutputKeys.INDENT, "yes");
+      transformer.transform(new DOMSource(document), new StreamResult(outputStream));
+    } catch (TransformerException | IOException e) {
+      throw new IllegalStateException("Failed to write manifest.", e);
+    }
+  }
+
+  private AndroidManifestWriter() {}
+}
diff --git a/src/tools/java/com/google/devtools/build/android/sandboxedsdktoolbox/sdkdependenciesmanifest/BUILD b/src/tools/java/com/google/devtools/build/android/sandboxedsdktoolbox/sdkdependenciesmanifest/BUILD
new file mode 100644
index 0000000..b2584b2
--- /dev/null
+++ b/src/tools/java/com/google/devtools/build/android/sandboxedsdktoolbox/sdkdependenciesmanifest/BUILD
@@ -0,0 +1,19 @@
+# Command for generating an SDK dependencies Android manifest.
+
+package(
+    default_applicable_licenses = ["//:license"],
+    default_visibility = ["//src:__subpackages__"],
+)
+
+licenses(["notice"])
+
+java_library(
+    name = "sdkdependenciesmanifest",
+    srcs = glob(["*.java"]),
+    deps = [
+        "//src/tools/java/com/google/devtools/build/android/sandboxedsdktoolbox/config",
+        "@rules_android_maven//:com_android_tools_build_bundletool",
+        "@rules_android_maven//:com_google_guava_guava",
+        "@rules_android_maven//:info_picocli_picocli",
+    ],
+)
diff --git a/src/tools/java/com/google/devtools/build/android/sandboxedsdktoolbox/sdkdependenciesmanifest/CertificateDigestGenerator.java b/src/tools/java/com/google/devtools/build/android/sandboxedsdktoolbox/sdkdependenciesmanifest/CertificateDigestGenerator.java
new file mode 100644
index 0000000..f8ad00b
--- /dev/null
+++ b/src/tools/java/com/google/devtools/build/android/sandboxedsdktoolbox/sdkdependenciesmanifest/CertificateDigestGenerator.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2023 The Bazel Authors. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.devtools.build.android.sandboxedsdktoolbox.sdkdependenciesmanifest;
+
+import static java.util.stream.Collectors.joining;
+
+import com.google.common.hash.Hashing;
+import com.google.common.io.ByteSource;
+import com.google.common.primitives.Bytes;
+import java.io.BufferedInputStream;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.GeneralSecurityException;
+import java.security.KeyStore;
+import java.security.cert.CertificateEncodingException;
+import java.security.cert.X509Certificate;
+
+/** Generates a SHA256 digest of a signing certificate. */
+final class CertificateDigestGenerator {
+
+  static final String generateCertificateDigest(
+      Path keystorePath, String keystorePassword, String keystoreAlias) {
+    X509Certificate certificate = readCertificate(keystorePath, keystorePassword, keystoreAlias);
+    return getCertificateDigest(certificate);
+  }
+
+  private static X509Certificate readCertificate(
+      Path keystorePath, String keystorePassword, String keystoreAlias) {
+    try (BufferedInputStream keystoreInputStream =
+        new BufferedInputStream(Files.newInputStream(keystorePath))) {
+      KeyStore keystore = KeyStore.getInstance("JKS");
+      keystore.load(keystoreInputStream, keystorePassword.toCharArray());
+      return (X509Certificate) keystore.getCertificate(keystoreAlias);
+    } catch (GeneralSecurityException | IOException e) {
+      throw new IllegalStateException("Failed to read certificate", e);
+    }
+  }
+
+  private static String getCertificateDigest(X509Certificate certificate) {
+    try {
+      return Bytes.asList(
+              ByteSource.wrap(certificate.getEncoded()).hash(Hashing.sha256()).asBytes())
+          .stream()
+          .map(b -> String.format("%02X", b))
+          .collect(joining(":"));
+    } catch (CertificateEncodingException | IOException e) {
+      throw new IllegalStateException("Failed to generate certificate digest", e);
+    }
+  }
+
+  private CertificateDigestGenerator() {}
+}
diff --git a/src/tools/java/com/google/devtools/build/android/sandboxedsdktoolbox/sdkdependenciesmanifest/GenerateSdkDependenciesManifestCommand.java b/src/tools/java/com/google/devtools/build/android/sandboxedsdktoolbox/sdkdependenciesmanifest/GenerateSdkDependenciesManifestCommand.java
new file mode 100644
index 0000000..c390f27
--- /dev/null
+++ b/src/tools/java/com/google/devtools/build/android/sandboxedsdktoolbox/sdkdependenciesmanifest/GenerateSdkDependenciesManifestCommand.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright 2023 The Bazel Authors. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.devtools.build.android.sandboxedsdktoolbox.sdkdependenciesmanifest;
+
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.devtools.build.android.sandboxedsdktoolbox.sdkdependenciesmanifest.AndroidManifestWriter.writeManifest;
+import static com.google.devtools.build.android.sandboxedsdktoolbox.sdkdependenciesmanifest.CertificateDigestGenerator.generateCertificateDigest;
+import static java.util.Arrays.stream;
+
+import com.android.bundle.SdkModulesConfigOuterClass.SdkModulesConfig;
+import com.google.common.collect.ImmutableSet;
+import com.google.devtools.build.android.sandboxedsdktoolbox.config.SdkModulesConfigUtils;
+import java.nio.file.Path;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+
+/** Command for generating SDK dependencies manifest. */
+@Command(
+    name = "generate-sdk-dependencies-manifest",
+    description =
+        "Generates an Android manifest with the <uses-sdk-library> tags from the given "
+            + "SDK bundles.")
+public final class GenerateSdkDependenciesManifestCommand implements Runnable {
+
+  @Option(names = "--manifest-package", required = true)
+  String manifestPackage;
+
+  @Option(names = "--sdk-module-configs", split = ",", required = true)
+  Path[] sdkModuleConfigPaths;
+
+  @Option(names = "--debug-keystore", required = true)
+  Path debugKeystorePath;
+
+  @Option(names = "--debug-keystore-pass", required = true)
+  String debugKeystorePassword;
+
+  @Option(names = "--debug-keystore-alias", required = true)
+  String debugKeystoreAlias;
+
+  @Option(names = "--output-manifest", required = true)
+  Path outputManifestPath;
+
+  @Override
+  public void run() {
+    ImmutableSet<SdkModulesConfig> configSet =
+        stream(sdkModuleConfigPaths)
+            .map(SdkModulesConfigUtils::readFromJsonFile)
+            .collect(toImmutableSet());
+
+    String certificateDigest =
+        generateCertificateDigest(debugKeystorePath, debugKeystorePassword, debugKeystoreAlias);
+
+    writeManifest(manifestPackage, certificateDigest, configSet, outputManifestPath);
+  }
+
+  private GenerateSdkDependenciesManifestCommand() {}
+}
diff --git a/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/apidescriptors/BUILD b/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/apidescriptors/BUILD
new file mode 100644
index 0000000..678f495
--- /dev/null
+++ b/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/apidescriptors/BUILD
@@ -0,0 +1,22 @@
+# Tests for extract-api-descriptors command.
+
+package(
+    default_applicable_licenses = ["//:license"],
+    default_visibility = ["//src:__subpackages__"],
+)
+
+licenses(["notice"])
+
+java_test(
+    name = "ExtractApiDescriptorsCommandTest",
+    size = "small",
+    srcs = ["ExtractApiDescriptorsCommandTest.java"],
+    data = [
+        "//src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/apidescriptors/testlibrary:libtestlibrary.jar",
+    ],
+    deps = [
+        "//src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/utils",
+        "@rules_android_maven//:com_google_truth_truth",
+        "@rules_android_maven//:junit_junit",
+    ],
+)
diff --git a/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/apidescriptors/ExtractApiDescriptorsCommandTest.java b/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/apidescriptors/ExtractApiDescriptorsCommandTest.java
new file mode 100644
index 0000000..43d0937
--- /dev/null
+++ b/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/apidescriptors/ExtractApiDescriptorsCommandTest.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2023 The Bazel Authors. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.devtools.build.android.sandboxedsdktoolbox.apidescriptors;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.devtools.build.android.sandboxedsdktoolbox.utils.Runner.runCommand;
+import static com.google.devtools.build.android.sandboxedsdktoolbox.utils.TestData.JAVATESTS_DIR;
+
+import com.google.common.collect.ImmutableList;
+import com.google.devtools.build.android.sandboxedsdktoolbox.utils.CommandResult;
+import java.nio.file.Path;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+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 final class ExtractApiDescriptorsCommandTest {
+  @Rule public final TemporaryFolder testFolder = new TemporaryFolder();
+
+  private static final Path TEST_LIBRARY_DEPLOY_JAR =
+      JAVATESTS_DIR.resolve(
+          Path.of(
+              "com/google/devtools/build/android/sandboxedsdktoolbox",
+              "apidescriptors/testlibrary/libtestlibrary.jar"));
+
+  @Test
+  public void extractApiDescriptors_keepsAnnotatedClassesInDescriptors() throws Exception {
+    Path outputFile = testFolder.getRoot().toPath().resolve("output.jar");
+
+    CommandResult result =
+        runCommand(
+            "extract-api-descriptors",
+            "--sdk-deploy-jar",
+            TEST_LIBRARY_DEPLOY_JAR.toString(),
+            "--output-sdk-api-descriptors",
+            outputFile.toString());
+    ImmutableList<String> outputJarEntryNames =
+        new ZipFile(outputFile.toFile()).stream().map(ZipEntry::getName).collect(toImmutableList());
+
+    assertThat(result.getStatusCode()).isEqualTo(0);
+    assertThat(result.getOutput()).isEmpty();
+    assertThat(outputJarEntryNames)
+        .containsExactly(
+            "com/google/devtools/build/android/sandboxedsdktoolbox/apidescriptors/"
+                + "testlibrary/AnnotatedClass.class");
+  }
+}
diff --git a/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/apidescriptors/testlibrary/AnnotatedClass.java b/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/apidescriptors/testlibrary/AnnotatedClass.java
new file mode 100644
index 0000000..7aeee4f
--- /dev/null
+++ b/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/apidescriptors/testlibrary/AnnotatedClass.java
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2023 The Bazel Authors. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.devtools.build.android.sandboxedsdktoolbox.apidescriptors.testlibrary;
+
+import androidx.privacysandbox.tools.PrivacySandboxService;
+
+/** Class that should be part of the SDK API descriptors, since it's properly annotated. */
+@PrivacySandboxService
+public final class AnnotatedClass {}
diff --git a/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/apidescriptors/testlibrary/BUILD b/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/apidescriptors/testlibrary/BUILD
new file mode 100644
index 0000000..e208cb5
--- /dev/null
+++ b/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/apidescriptors/testlibrary/BUILD
@@ -0,0 +1,18 @@
+# Library to test SDK API descritptor extraction.
+
+load("//rules:rules.bzl", "android_library")
+
+package(
+    default_applicable_licenses = ["//:license"],
+    default_visibility = ["//:__subpackages__"],
+)
+
+licenses(["notice"])
+
+android_library(
+    name = "testlibrary",
+    srcs = glob(["**/*.java"]),
+    deps = [
+        "@rules_android_maven//:androidx_privacysandbox_tools_tools",
+    ],
+)
diff --git a/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/apidescriptors/testlibrary/ClassThatShouldBeIgnored.java b/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/apidescriptors/testlibrary/ClassThatShouldBeIgnored.java
new file mode 100644
index 0000000..eb0dbbc
--- /dev/null
+++ b/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/apidescriptors/testlibrary/ClassThatShouldBeIgnored.java
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2023 The Bazel Authors. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.devtools.build.android.sandboxedsdktoolbox.apidescriptors.testlibrary;
+
+/**
+ * Test class that should not be present in final SDK API descritors, since it doesn't have a
+ * Privacy Sandbox tool annotation.
+ */
+public final class ClassThatShouldBeIgnored {}
diff --git a/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/sdkdependenciesmanifest/BUILD b/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/sdkdependenciesmanifest/BUILD
new file mode 100644
index 0000000..c2b38d1
--- /dev/null
+++ b/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/sdkdependenciesmanifest/BUILD
@@ -0,0 +1,26 @@
+# Tests for generate-sdk-dependencies-manifest command.
+
+package(
+    default_applicable_licenses = ["//:license"],
+    default_visibility = ["//:__subpackages__"],
+)
+
+licenses(["notice"])
+
+java_test(
+    name = "GenerateSdkDependenciesManifestCommandTest",
+    size = "small",
+    srcs = ["GenerateSdkDependenciesManifestCommandTest.java"],
+    data = [
+        "testdata/com.example.firstsdkconfig.json",
+        "testdata/com.example.secondsdkconfig.json",
+        "testdata/expected_manifest_multiple_sdks.xml",
+        "testdata/expected_manifest_single_sdk.xml",
+        "testdata/test_key",
+    ],
+    deps = [
+        "//src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/utils",
+        "@rules_android_maven//:junit_junit",
+        "@rules_android_maven//:com_google_truth_truth",
+    ],
+)
diff --git a/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/sdkdependenciesmanifest/GenerateSdkDependenciesManifestCommandTest.java b/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/sdkdependenciesmanifest/GenerateSdkDependenciesManifestCommandTest.java
new file mode 100644
index 0000000..7dc04e7
--- /dev/null
+++ b/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/sdkdependenciesmanifest/GenerateSdkDependenciesManifestCommandTest.java
@@ -0,0 +1,112 @@
+/*
+ * Copyright 2023 The Bazel Authors. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.devtools.build.android.sandboxedsdktoolbox.sdkdependenciesmanifest;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.devtools.build.android.sandboxedsdktoolbox.utils.Runner.runCommand;
+import static com.google.devtools.build.android.sandboxedsdktoolbox.utils.TestData.JAVATESTS_DIR;
+import static com.google.devtools.build.android.sandboxedsdktoolbox.utils.TestData.readFromAbsolutePath;
+
+import com.google.devtools.build.android.sandboxedsdktoolbox.utils.CommandResult;
+import java.nio.file.Path;
+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 final class GenerateSdkDependenciesManifestCommandTest {
+
+  @Rule public final TemporaryFolder testFolder = new TemporaryFolder();
+
+  private static final Path TEST_DATA_DIR =
+      JAVATESTS_DIR.resolve(
+          Path.of(
+              "com/google/devtools/build/android/sandboxedsdktoolbox",
+              "sdkdependenciesmanifest/testdata"));
+  private static final Path FIRST_SDK_CONFIG_JSON_PATH =
+      TEST_DATA_DIR.resolve("com.example.firstsdkconfig.json");
+  private static final Path SECOND_SDK_CONFIG_JSON_PATH =
+      TEST_DATA_DIR.resolve("com.example.secondsdkconfig.json");
+  /*
+   The test key was generated with this command, its password is "android"
+   keytool -genkeypair \
+     -alias androiddebugkey \
+     -dname "CN=Android Debug, O=Android, C=US" \
+     -keystore test_key \
+     -sigalg SHA256withDSA \
+     -validity 10950
+  */
+  private static final Path TEST_KEY_PATH = TEST_DATA_DIR.resolve("test_key");
+
+  @Test
+  public void generateManifest_forSingleSdkModuleConfig_success() throws Exception {
+    String manifestPackage = "com.example.generatedmanifest";
+    Path outputFile = testFolder.newFile().toPath();
+
+    CommandResult result =
+        runCommand(
+            "generate-sdk-dependencies-manifest",
+            "--manifest-package",
+            manifestPackage,
+            "--sdk-module-configs",
+            FIRST_SDK_CONFIG_JSON_PATH.toString(),
+            "--debug-keystore",
+            TEST_KEY_PATH.toString(),
+            "--debug-keystore-pass",
+            "android",
+            "--debug-keystore-alias",
+            "androiddebugkey",
+            "--output-manifest",
+            outputFile.toString());
+
+    assertThat(result.getStatusCode()).isEqualTo(0);
+    assertThat(result.getOutput()).isEmpty();
+    assertThat(readFromAbsolutePath(outputFile))
+        .isEqualTo(readFromAbsolutePath(TEST_DATA_DIR.resolve("expected_manifest_single_sdk.xml")));
+  }
+
+  @Test
+  public void generateManifest_forMultipleSdkModuleConfigs_success() throws Exception {
+    String manifestPackage = "com.example.generatedmanifest";
+    String configPaths =
+        String.format("%s,%s", FIRST_SDK_CONFIG_JSON_PATH, SECOND_SDK_CONFIG_JSON_PATH);
+    Path outputFile = testFolder.newFile().toPath();
+
+    CommandResult result =
+        runCommand(
+            "generate-sdk-dependencies-manifest",
+            "--manifest-package",
+            manifestPackage,
+            "--sdk-module-configs",
+            configPaths,
+            "--debug-keystore",
+            TEST_KEY_PATH.toString(),
+            "--debug-keystore-pass",
+            "android",
+            "--debug-keystore-alias",
+            "androiddebugkey",
+            "--output-manifest",
+            outputFile.toString());
+
+    assertThat(result.getStatusCode()).isEqualTo(0);
+    assertThat(result.getOutput()).isEmpty();
+    assertThat(readFromAbsolutePath(outputFile))
+        .isEqualTo(
+            readFromAbsolutePath(TEST_DATA_DIR.resolve("expected_manifest_multiple_sdks.xml")));
+  }
+}
diff --git a/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/sdkdependenciesmanifest/testdata/com.example.firstsdkconfig.json b/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/sdkdependenciesmanifest/testdata/com.example.firstsdkconfig.json
new file mode 100644
index 0000000..ac7561e
--- /dev/null
+++ b/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/sdkdependenciesmanifest/testdata/com.example.firstsdkconfig.json
@@ -0,0 +1,9 @@
+{
+  "sdk_package_name": "com.example.firstsdkconfig",
+  "sdk_provider_class_name": "com.testsdk.lib.FakeSdkProvider",
+  "sdk_version": {
+    "major": 2,
+    "minor": 3,
+    "patch": 1
+  }
+}
\ No newline at end of file
diff --git a/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/sdkdependenciesmanifest/testdata/com.example.secondsdkconfig.json b/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/sdkdependenciesmanifest/testdata/com.example.secondsdkconfig.json
new file mode 100644
index 0000000..7320a85
--- /dev/null
+++ b/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/sdkdependenciesmanifest/testdata/com.example.secondsdkconfig.json
@@ -0,0 +1,9 @@
+{
+  "sdk_package_name": "com.example.secondsdkconfig",
+  "sdk_provider_class_name": "com.testsdk.lib.FakeSdkProvider",
+  "sdk_version": {
+    "major": 42,
+    "minor": 1,
+    "patch": 0
+  }
+}
\ No newline at end of file
diff --git a/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/sdkdependenciesmanifest/testdata/expected_manifest_multiple_sdks.xml b/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/sdkdependenciesmanifest/testdata/expected_manifest_multiple_sdks.xml
new file mode 100644
index 0000000..1360bf1
--- /dev/null
+++ b/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/sdkdependenciesmanifest/testdata/expected_manifest_multiple_sdks.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8" standalone="no"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.generatedmanifest">
+    <application>
+        <uses-sdk-library android:certDigest="91:8E:A3:7D:7D:D0:E0:A0:14:9F:21:28:83:95:8A:F0:80:E6:F9:7B:4D:5A:39:01:76:02:E8:2D:7D:FF:A9:10" android:name="com.example.firstsdkconfig" android:versionMajor="20003"/>
+        <uses-sdk-library android:certDigest="91:8E:A3:7D:7D:D0:E0:A0:14:9F:21:28:83:95:8A:F0:80:E6:F9:7B:4D:5A:39:01:76:02:E8:2D:7D:FF:A9:10" android:name="com.example.secondsdkconfig" android:versionMajor="420001"/>
+    </application>
+</manifest>
\ No newline at end of file
diff --git a/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/sdkdependenciesmanifest/testdata/expected_manifest_single_sdk.xml b/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/sdkdependenciesmanifest/testdata/expected_manifest_single_sdk.xml
new file mode 100644
index 0000000..4bfc234
--- /dev/null
+++ b/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/sdkdependenciesmanifest/testdata/expected_manifest_single_sdk.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8" standalone="no"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.generatedmanifest">
+    <application>
+        <uses-sdk-library android:certDigest="91:8E:A3:7D:7D:D0:E0:A0:14:9F:21:28:83:95:8A:F0:80:E6:F9:7B:4D:5A:39:01:76:02:E8:2D:7D:FF:A9:10" android:name="com.example.firstsdkconfig" android:versionMajor="20003"/>
+    </application>
+</manifest>
\ No newline at end of file
diff --git a/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/sdkdependenciesmanifest/testdata/test_key b/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/sdkdependenciesmanifest/testdata/test_key
new file mode 100644
index 0000000..e0061a5
--- /dev/null
+++ b/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/sdkdependenciesmanifest/testdata/test_key
Binary files differ
diff --git a/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/utils/BUILD b/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/utils/BUILD
new file mode 100644
index 0000000..4bf26b6
--- /dev/null
+++ b/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/utils/BUILD
@@ -0,0 +1,16 @@
+# Common test utilities for SandboxedSdkToolbox.
+package(
+    default_applicable_licenses = ["//:license"],
+    default_visibility = ["//:__subpackages__"],
+)
+
+licenses(["notice"])
+
+java_library(
+    name = "utils",
+    srcs = glob(["*.java"]),
+    deps = [
+        "//src/tools/java/com/google/devtools/build/android/sandboxedsdktoolbox:sandboxed_sdk_toolbox_lib",
+        "@rules_android_maven//:info_picocli_picocli",
+    ],
+)
diff --git a/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/utils/CommandResult.java b/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/utils/CommandResult.java
new file mode 100644
index 0000000..cafd1b2
--- /dev/null
+++ b/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/utils/CommandResult.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2023 The Bazel Authors. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.devtools.build.android.sandboxedsdktoolbox.utils;
+
+/** The result from executing a SandboxedSdkToolbox command. */
+public final class CommandResult {
+  private final int statusCode;
+  private final String output;
+
+  CommandResult(int statusCode, String output) {
+    this.statusCode = statusCode;
+    this.output = output;
+  }
+
+  public int getStatusCode() {
+    return statusCode;
+  }
+
+  public String getOutput() {
+    return output;
+  }
+}
diff --git a/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/utils/Runner.java b/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/utils/Runner.java
new file mode 100644
index 0000000..bfd3ec9
--- /dev/null
+++ b/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/utils/Runner.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2023 The Bazel Authors. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.devtools.build.android.sandboxedsdktoolbox.utils;
+
+import com.google.devtools.build.android.sandboxedsdktoolbox.SandboxedSdkToolbox;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import picocli.CommandLine;
+
+/** Utilities for running SandboxedSdkToolbox commands. */
+public final class Runner {
+  public static CommandResult runCommand(String... parameters) {
+    CommandLine command = SandboxedSdkToolbox.create();
+    StringWriter stringWriter = new StringWriter();
+
+    command.setOut(new PrintWriter(stringWriter));
+    int statusCode = command.execute(parameters);
+    String output = stringWriter.toString();
+
+    return new CommandResult(statusCode, output);
+  }
+
+  private Runner() {}
+}
diff --git a/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/utils/TestData.java b/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/utils/TestData.java
new file mode 100644
index 0000000..fdf96c5
--- /dev/null
+++ b/src/tools/javatests/com/google/devtools/build/android/sandboxedsdktoolbox/utils/TestData.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2023 The Bazel Authors. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.devtools.build.android.sandboxedsdktoolbox.utils;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+/** Utilities for test data. */
+public final class TestData {
+
+  /** Path to the javatests directory in runfiles. */
+  public static final Path JAVATESTS_DIR =
+      Path.of(
+          System.getenv("TEST_SRCDIR"),
+          "/build_bazel_rules_android/src/tools/javatests/");
+
+  /** Reads the contents of a file, assuming its path is absolute. */
+  public static String readFromAbsolutePath(Path absolutePath) throws Exception {
+    return String.join("\n", Files.readAllLines(absolutePath, UTF_8));
+  }
+
+  private TestData() {}
+}
diff --git a/test/bashunit/BUILD b/test/bashunit/BUILD
new file mode 100644
index 0000000..3404758
--- /dev/null
+++ b/test/bashunit/BUILD
@@ -0,0 +1,43 @@
+load("@rules_python//python:py_test.bzl", "py_test")
+
+package(
+    default_applicable_licenses = ["//:license"],
+    default_visibility = [
+        "//test:__subpackages__",
+    ],
+)
+
+licenses(["notice"])
+
+exports_files(
+    ["unittest.bash"],
+)
+
+sh_library(
+    name = "bashunit",
+    testonly = True,
+    srcs = [
+        "unittest.bash",
+        "unittest_utils.sh",
+    ],
+)
+
+# Test bashunit with python to avoid recursion.
+py_test(
+    name = "bashunit_test",
+    size = "medium",
+    srcs = ["unittest_test.py"],
+    data = [
+        ":bashunit",
+        # This test relies on writing shell scripts that use bash runfiles
+        # to load the actual copy of unittest.bash being tested.
+        "@bazel_tools//tools/bash/runfiles",
+    ],
+    main = "unittest_test.py",
+    python_version = "PY3",
+    srcs_version = "PY3",
+    tags = [
+        "manual",  # TODO(b/266084774): Re-enable this.
+        "no_windows",  # test runs bash scripts in a subprocess
+    ],
+)
diff --git a/test/bashunit/unittest.bash b/test/bashunit/unittest.bash
new file mode 100644
index 0000000..c88ba2c
--- /dev/null
+++ b/test/bashunit/unittest.bash
@@ -0,0 +1,845 @@
+#!/bin/bash
+#
+# Copyright 2015 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# Common utility file for Bazel shell tests
+#
+# unittest.bash: a unit test framework in Bash.
+#
+# A typical test suite looks like so:
+#
+#   ------------------------------------------------------------------------
+#   #!/bin/bash
+#
+#   source path/to/unittest.bash || exit 1
+#
+#   # Test that foo works.
+#   function test_foo() {
+#     foo >$TEST_log || fail "foo failed";
+#     expect_log "blah" "Expected to see 'blah' in output of 'foo'."
+#   }
+#
+#   # Test that bar works.
+#   function test_bar() {
+#     bar 2>$TEST_log || fail "bar failed";
+#     expect_not_log "ERROR" "Unexpected error from 'bar'."
+#     ...
+#     assert_equals $x $y
+#   }
+#
+#   run_suite "Test suite for blah"
+#   ------------------------------------------------------------------------
+#
+# Each test function is considered to pass iff fail() is not called
+# while it is active.  fail() may be called directly, or indirectly
+# via other assertions such as expect_log().  run_suite must be called
+# at the very end.
+#
+# A test suite may redefine functions "set_up" and/or "tear_down";
+# these functions are executed before and after each test function,
+# respectively.  Similarly, "cleanup" and "timeout" may be redefined,
+# and these function are called upon exit (of any kind) or a timeout.
+#
+# The user can pass --test_filter to blaze test to select specific tests
+# to run with Bash globs. A union of tests matching any of the provided globs
+# will be run. Additionally the user may define TESTS=(test_foo test_bar ...) to
+# specify a subset of test functions to execute, for example, a working set
+# during debugging. By default, all functions called test_* will be executed.
+#
+# This file provides utilities for assertions over the output of a
+# command.  The output of the command under test is directed to the
+# file $TEST_log, and then the expect_log* assertions can be used to
+# test for the presence of certain regular expressions in that file.
+#
+# The test framework is responsible for restoring the original working
+# directory before each test.
+#
+# The order in which test functions are run is not defined, so it is
+# important that tests clean up after themselves.
+#
+# Each test will be run in a new subshell.
+#
+# Functions named __* are not intended for use by clients.
+#
+# This framework implements the "test sharding protocol".
+#
+
+[[ -n "$BASH_VERSION" ]] ||
+  { echo "unittest.bash only works with bash!" >&2; exit 1; }
+
+export BAZEL_SHELL_TEST=1
+
+DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
+
+# Load the environment support utilities.
+source "${DIR}/unittest_utils.sh" || { echo "unittest_utils.sh not found" >&2; exit 1; }
+
+#### Global variables:
+
+TEST_name=""                    # The name of the current test.
+
+TEST_log=$TEST_TMPDIR/log       # The log file over which the
+                                # expect_log* assertions work.  Must
+                                # be absolute to be robust against
+                                # tests invoking 'cd'!
+
+TEST_passed="true"              # The result of the current test;
+                                # failed assertions cause this to
+                                # become false.
+
+# These variables may be overridden by the test suite:
+
+TESTS=()                        # A subset or "working set" of test
+                                # functions that should be run.  By
+                                # default, all tests called test_* are
+                                # run.
+
+_TEST_FILTERS=()                # List of globs to use to filter the tests.
+                                # If non-empty, all tests matching at least one
+                                # of the globs are run and test list provided in
+                                # the arguments is ignored if present.
+
+__in_tear_down=0                # Indicates whether we are in `tear_down` phase
+                                # of test. Used to avoid re-entering `tear_down`
+                                # on failures within it.
+
+if (( $# > 0 )); then
+  (
+    IFS=':'
+    echo "WARNING: Passing test names in arguments (--test_arg) is deprecated, please use --test_filter='$*' instead." >&2
+  )
+
+  # Legacy behavior is to ignore missing regexp, but with errexit
+  # the following line fails without || true.
+  # TODO(dmarting): maybe we should revisit the way of selecting
+  # test with that framework (use Bazel's environment variable instead).
+  TESTS=($(for i in "$@"; do echo $i; done | grep ^test_ || true))
+  if (( ${#TESTS[@]} == 0 )); then
+    echo "WARNING: Arguments do not specify tests!" >&2
+  fi
+fi
+# TESTBRIDGE_TEST_ONLY contains the value of --test_filter, if any. We want to
+# preferentially use that instead of $@ to determine which tests to run.
+if [[ ${TESTBRIDGE_TEST_ONLY:-} != "" ]]; then
+  if (( ${#TESTS[@]} != 0 )); then
+    echo "WARNING: Both --test_arg and --test_filter specified, ignoring --test_arg" >&2
+    TESTS=()
+  fi
+  # Split TESTBRIDGE_TEST_ONLY on colon and store it in `_TEST_FILTERS` array.
+  IFS=':' read -r -a _TEST_FILTERS <<< "$TESTBRIDGE_TEST_ONLY"
+fi
+
+TEST_verbose="true"             # Whether or not to be verbose.  A
+                                # command; "true" or "false" are
+                                # acceptable.  The default is: true.
+
+TEST_script="$0"                # Full path to test script
+# Check if the script path is absolute, if not prefix the PWD.
+if [[ ! "$TEST_script" = /* ]]; then
+  TEST_script="${PWD}/$0"
+fi
+
+
+#### Internal functions
+
+function __show_log() {
+    echo "-- Test log: -----------------------------------------------------------"
+    [[ -e $TEST_log ]] && cat "$TEST_log" || echo "(Log file did not exist.)"
+    echo "------------------------------------------------------------------------"
+}
+
+# Usage: __pad <title> <pad-char>
+# Print $title padded to 80 columns with $pad_char.
+function __pad() {
+    local title=$1
+    local pad=$2
+    # Ignore the subshell error -- `head` closes the fd before reading to the
+    # end, therefore the subshell will get SIGPIPE while stuck in `write`.
+    {
+        echo -n "${pad}${pad} ${title} "
+        printf "%80s" " " | tr ' ' "$pad"
+    } | head -c 80 || true
+    echo
+}
+
+#### Exported functions
+
+# Usage: init_test ...
+# Deprecated.  Has no effect.
+function init_test() {
+    :
+}
+
+
+# Usage: set_up
+# Called before every test function.  May be redefined by the test suite.
+function set_up() {
+    :
+}
+
+# Usage: tear_down
+# Called after every test function.  May be redefined by the test suite.
+function tear_down() {
+    :
+}
+
+# Usage: cleanup
+# Called upon eventual exit of the test suite.  May be redefined by
+# the test suite.
+function cleanup() {
+    :
+}
+
+# Usage: timeout
+# Called upon early exit from a test due to timeout.
+function timeout() {
+    :
+}
+
+# Usage: testenv_set_up
+# Called prior to set_up. For use by testenv.sh.
+function testenv_set_up() {
+    :
+}
+
+# Usage: testenv_tear_down
+# Called after tear_down. For use by testenv.sh.
+function testenv_tear_down() {
+    :
+}
+
+# Usage: fail <message> [<message> ...]
+# Print failure message with context information, and mark the test as
+# a failure.  The context includes a stacktrace including the longest sequence
+# of calls outside this module.  (We exclude the top and bottom portions of
+# the stack because they just add noise.)  Also prints the contents of
+# $TEST_log.
+function fail() {
+    __show_log >&2
+    echo "${TEST_name} FAILED: $*." >&2
+    # Keep the original error message if we fail in `tear_down` after a failure.
+    [[ "${TEST_passed}" == "true" ]] && echo "$@" >"$TEST_TMPDIR"/__fail
+    TEST_passed="false"
+    __show_stack
+    # Cleanup as we are leaving the subshell now
+    __run_tear_down_after_failure
+    exit 1
+}
+
+function __run_tear_down_after_failure() {
+    # Skip `tear_down` after a failure in `tear_down` to prevent infinite
+    # recursion.
+    (( __in_tear_down )) && return
+    __in_tear_down=1
+    echo -e "\nTear down:\n" >&2
+    tear_down
+    testenv_tear_down
+}
+
+# Usage: warn <message>
+# Print a test warning with context information.
+# The context includes a stacktrace including the longest sequence
+# of calls outside this module.  (We exclude the top and bottom portions of
+# the stack because they just add noise.)
+function warn() {
+    __show_log >&2
+    echo "${TEST_name} WARNING: $1." >&2
+    __show_stack
+
+    if [[ -n "${TEST_WARNINGS_OUTPUT_FILE:-}" ]]; then
+      echo "${TEST_name} WARNING: $1." >> "$TEST_WARNINGS_OUTPUT_FILE"
+    fi
+}
+
+# Usage: show_stack
+# Prints the portion of the stack that does not belong to this module,
+# i.e. the user's code that called a failing assertion.  Stack may not
+# be available if Bash is reading commands from stdin; an error is
+# printed in that case.
+__show_stack() {
+    local i=0
+    local trace_found=0
+
+    # Skip over active calls within this module:
+    while (( i < ${#FUNCNAME[@]} )) && [[ ${BASH_SOURCE[i]:-} == "${BASH_SOURCE[0]}" ]]; do
+       (( ++i ))
+    done
+
+    # Show all calls until the next one within this module (typically run_suite):
+    while (( i < ${#FUNCNAME[@]} )) && [[ ${BASH_SOURCE[i]:-} != "${BASH_SOURCE[0]}" ]]; do
+        # Read online docs for BASH_LINENO to understand the strange offset.
+        # Undefined can occur in the BASH_SOURCE stack apparently when one exits from a subshell
+        echo "${BASH_SOURCE[i]:-"Unknown"}:${BASH_LINENO[i - 1]:-"Unknown"}: in call to ${FUNCNAME[i]:-"Unknown"}" >&2
+        (( ++i ))
+        trace_found=1
+    done
+
+    (( trace_found )) || echo "[Stack trace not available]" >&2
+}
+
+# Usage: expect_log <regexp> [error-message]
+# Asserts that $TEST_log matches regexp.  Prints the contents of
+# $TEST_log and the specified (optional) error message otherwise, and
+# returns non-zero.
+function expect_log() {
+    local pattern=$1
+    local message=${2:-Expected regexp "$pattern" not found}
+    grep -sq -- "$pattern" $TEST_log && return 0
+
+    fail "$message"
+    return 1
+}
+
+# Usage: expect_log_warn <regexp> [error-message]
+# Warns if $TEST_log does not match regexp.  Prints the contents of
+# $TEST_log and the specified (optional) error message on mismatch.
+function expect_log_warn() {
+    local pattern=$1
+    local message=${2:-Expected regexp "$pattern" not found}
+    grep -sq -- "$pattern" $TEST_log && return 0
+
+    warn "$message"
+    return 1
+}
+
+# Usage: expect_log_once <regexp> [error-message]
+# Asserts that $TEST_log contains one line matching <regexp>.
+# Prints the contents of $TEST_log and the specified (optional)
+# error message otherwise, and returns non-zero.
+function expect_log_once() {
+    local pattern=$1
+    local message=${2:-Expected regexp "$pattern" not found exactly once}
+    expect_log_n "$pattern" 1 "$message"
+}
+
+# Usage: expect_log_n <regexp> <count> [error-message]
+# Asserts that $TEST_log contains <count> lines matching <regexp>.
+# Prints the contents of $TEST_log and the specified (optional)
+# error message otherwise, and returns non-zero.
+function expect_log_n() {
+    local pattern=$1
+    local expectednum=${2:-1}
+    local message=${3:-Expected regexp "$pattern" not found exactly $expectednum times}
+    local count=$(grep -sc -- "$pattern" $TEST_log)
+    (( count == expectednum )) && return 0
+    fail "$message"
+    return 1
+}
+
+# Usage: expect_not_log <regexp> [error-message]
+# Asserts that $TEST_log does not match regexp.  Prints the contents
+# of $TEST_log and the specified (optional) error message otherwise, and
+# returns non-zero.
+function expect_not_log() {
+    local pattern=$1
+    local message=${2:-Unexpected regexp "$pattern" found}
+    grep -sq -- "$pattern" $TEST_log || return 0
+
+    fail "$message"
+    return 1
+}
+
+# Usage: expect_query_targets <arguments>
+# Checks that log file contains exactly the targets in the argument list.
+function expect_query_targets() {
+  for arg in "$@"; do
+    expect_log_once "^$arg$"
+  done
+
+# Checks that the number of lines started with '//' equals to the number of
+# arguments provided.
+  expect_log_n "^//[^ ]*$" $#
+}
+
+# Usage: expect_log_with_timeout <regexp> <timeout> [error-message]
+# Waits for the given regexp in the $TEST_log for up to timeout seconds.
+# Prints the contents of $TEST_log and the specified (optional)
+# error message otherwise, and returns non-zero.
+function expect_log_with_timeout() {
+    local pattern=$1
+    local timeout=$2
+    local message=${3:-Regexp "$pattern" not found in "$timeout" seconds}
+    local count=0
+    while (( count < timeout )); do
+      grep -sq -- "$pattern" "$TEST_log" && return 0
+      let count=count+1
+      sleep 1
+    done
+
+    grep -sq -- "$pattern" "$TEST_log" && return 0
+    fail "$message"
+    return 1
+}
+
+# Usage: expect_cmd_with_timeout <expected> <cmd> [timeout]
+# Repeats the command once a second for up to timeout seconds (10s by default),
+# until the output matches the expected value. Fails and returns 1 if
+# the command does not return the expected value in the end.
+function expect_cmd_with_timeout() {
+    local expected="$1"
+    local cmd="$2"
+    local timeout=${3:-10}
+    local count=0
+    while (( count < timeout )); do
+      local actual="$($cmd)"
+      [[ "$expected" == "$actual" ]] && return 0
+      (( ++count ))
+      sleep 1
+    done
+
+    [[ "$expected" == "$actual" ]] && return 0
+    fail "Expected '${expected}' within ${timeout}s, was '${actual}'"
+    return 1
+}
+
+# Usage: assert_one_of <expected_list>... <actual>
+# Asserts that actual is one of the items in expected_list
+#
+# Example:
+#     local expected=( "foo", "bar", "baz" )
+#     assert_one_of $expected $actual
+function assert_one_of() {
+    local args=("$@")
+    local last_arg_index=$((${#args[@]} - 1))
+    local actual=${args[last_arg_index]}
+    unset args[last_arg_index]
+    for expected_item in "${args[@]}"; do
+      [[ "$expected_item" == "$actual" ]] && return 0
+    done;
+
+    fail "Expected one of '${args[*]}', was '$actual'"
+    return 1
+}
+
+# Usage: assert_not_one_of <expected_list>... <actual>
+# Asserts that actual is not one of the items in expected_list
+#
+# Example:
+#     local unexpected=( "foo", "bar", "baz" )
+#     assert_not_one_of $unexpected $actual
+function assert_not_one_of() {
+    local args=("$@")
+    local last_arg_index=$((${#args[@]} - 1))
+    local actual=${args[last_arg_index]}
+    unset args[last_arg_index]
+    for expected_item in "${args[@]}"; do
+      if [[ "$expected_item" == "$actual" ]]; then
+        fail "'${args[*]}' contains '$actual'"
+        return 1
+      fi
+    done;
+
+    return 0
+}
+
+# Usage: assert_equals <expected> <actual>
+# Asserts [[ expected == actual ]].
+function assert_equals() {
+    local expected=$1 actual=$2
+    [[ "$expected" == "$actual" ]] && return 0
+
+    fail "Expected '$expected', was '$actual'"
+    return 1
+}
+
+# Usage: assert_not_equals <unexpected> <actual>
+# Asserts [[ unexpected != actual ]].
+function assert_not_equals() {
+    local unexpected=$1 actual=$2
+    [[ "$unexpected" != "$actual" ]] && return 0;
+
+    fail "Expected not '${unexpected}', was '${actual}'"
+    return 1
+}
+
+# Usage: assert_contains <regexp> <file> [error-message]
+# Asserts that file matches regexp.  Prints the contents of
+# file and the specified (optional) error message otherwise, and
+# returns non-zero.
+function assert_contains() {
+    local pattern=$1
+    local file=$2
+    local message=${3:-Expected regexp "$pattern" not found in "$file"}
+    grep -sq -- "$pattern" "$file" && return 0
+
+    cat "$file" >&2
+    fail "$message"
+    return 1
+}
+
+# Usage: assert_not_contains <regexp> <file> [error-message]
+# Asserts that file does not match regexp.  Prints the contents of
+# file and the specified (optional) error message otherwise, and
+# returns non-zero.
+function assert_not_contains() {
+    local pattern=$1
+    local file=$2
+    local message=${3:-Expected regexp "$pattern" found in "$file"}
+
+    if [[ -f "$file" ]]; then
+      grep -sq -- "$pattern" "$file" || return 0
+    else
+      fail "$file is not a file: $message"
+      return 1
+    fi
+
+    cat "$file" >&2
+    fail "$message"
+    return 1
+}
+
+function assert_contains_n() {
+    local pattern=$1
+    local expectednum=${2:-1}
+    local file=$3
+    local message=${4:-Expected regexp "$pattern" not found exactly $expectednum times}
+    local count
+    if [[ -f "$file" ]]; then
+      count=$(grep -sc -- "$pattern" "$file")
+    else
+      fail "$file is not a file: $message"
+      return 1
+    fi
+    (( count == expectednum )) && return 0
+
+    cat "$file" >&2
+    fail "$message"
+    return 1
+}
+
+# Updates the global variables TESTS if
+# sharding is enabled, i.e. ($TEST_TOTAL_SHARDS > 0).
+function __update_shards() {
+    [[ -z "${TEST_TOTAL_SHARDS-}" ]] && return 0
+
+    (( TEST_TOTAL_SHARDS > 0 )) ||
+      { echo "Invalid total shards ${TEST_TOTAL_SHARDS}" >&2; exit 1; }
+
+    (( TEST_SHARD_INDEX < 0 || TEST_SHARD_INDEX >= TEST_TOTAL_SHARDS )) &&
+      { echo "Invalid shard ${TEST_SHARD_INDEX}" >&2; exit 1; }
+
+    IFS=$'\n' read -rd $'\0' -a TESTS < <(
+        for test in "${TESTS[@]}"; do echo "$test"; done |
+            awk "NR % ${TEST_TOTAL_SHARDS} == ${TEST_SHARD_INDEX}" &&
+            echo -en '\0')
+
+    [[ -z "${TEST_SHARD_STATUS_FILE-}" ]] || touch "$TEST_SHARD_STATUS_FILE"
+}
+
+# Usage: __test_terminated <signal-number>
+# Handler that is called when the test terminated unexpectedly
+function __test_terminated() {
+    __show_log >&2
+    echo "$TEST_name FAILED: terminated by signal $1." >&2
+    TEST_passed="false"
+    __show_stack
+    timeout
+    exit 1
+}
+
+# Usage: __test_terminated_err
+# Handler that is called when the test terminated unexpectedly due to "errexit".
+function __test_terminated_err() {
+    # When a subshell exits due to signal ERR, its parent shell also exits,
+    # thus the signal handler is called recursively and we print out the
+    # error message and stack trace multiple times. We're only interested
+    # in the first one though, as it contains the most information, so ignore
+    # all following.
+    if [[ -f $TEST_TMPDIR/__err_handled ]]; then
+      exit 1
+    fi
+    __show_log >&2
+    if [[ ! -z "$TEST_name" ]]; then
+      echo -n "$TEST_name " >&2
+    fi
+    echo "FAILED: terminated because this command returned a non-zero status:" >&2
+    touch $TEST_TMPDIR/__err_handled
+    TEST_passed="false"
+    __show_stack
+    # If $TEST_name is still empty, the test suite failed before we even started
+    # to run tests, so we shouldn't call tear_down.
+    if [[ -n "$TEST_name" ]]; then
+      __run_tear_down_after_failure
+    fi
+    exit 1
+}
+
+# Usage: __trap_with_arg <handler> <signals ...>
+# Helper to install a trap handler for several signals preserving the signal
+# number, so that the signal number is available to the trap handler.
+function __trap_with_arg() {
+    func="$1" ; shift
+    for sig ; do
+        trap "$func $sig" "$sig"
+    done
+}
+
+# Usage: <node> <block>
+# Adds the block to the given node in the report file. Quotes in the in
+# arguments need to be escaped.
+function __log_to_test_report() {
+    local node="$1"
+    local block="$2"
+    if [[ ! -e "$XML_OUTPUT_FILE" ]]; then
+        local xml_header='<?xml version="1.0" encoding="UTF-8"?>'
+        echo "${xml_header}<testsuites></testsuites>" > "$XML_OUTPUT_FILE"
+    fi
+
+    # replace match on node with block and match
+    # replacement expression only needs escaping for quotes
+    perl -e "\
+\$input = @ARGV[0]; \
+\$/=undef; \
+open FILE, '+<$XML_OUTPUT_FILE'; \
+\$content = <FILE>; \
+if (\$content =~ /($node.*)\$/) { \
+  seek FILE, 0, 0; \
+  print FILE \$\` . \$input . \$1; \
+}; \
+close FILE" "$block"
+}
+
+# Usage: <total> <passed>
+# Adds the test summaries to the xml nodes.
+function __finish_test_report() {
+    local suite_name="$1"
+    local total="$2"
+    local passed="$3"
+    local failed=$((total - passed))
+
+    # Update the xml output with the suite name and total number of
+    # passed/failed tests.
+    cat "$XML_OUTPUT_FILE" | \
+      sed \
+        "s/<testsuites>/<testsuites tests=\"$total\" failures=\"0\" errors=\"$failed\">/" | \
+      sed \
+        "s/<testsuite>/<testsuite name=\"${suite_name}\" tests=\"$total\" failures=\"0\" errors=\"$failed\">/" \
+        > "${XML_OUTPUT_FILE}.bak"
+
+    rm -f "$XML_OUTPUT_FILE"
+    mv "${XML_OUTPUT_FILE}.bak" "$XML_OUTPUT_FILE"
+}
+
+# Multi-platform timestamp function
+UNAME=$(uname -s | tr 'A-Z' 'a-z')
+if [[ "$UNAME" == "linux" ]] || [[ "$UNAME" =~ msys_nt* ]]; then
+    function timestamp() {
+      echo $(($(date +%s%N)/1000000))
+    }
+else
+    function timestamp() {
+      # macOS and BSDs do not have %N, so Python is the best we can do.
+      # LC_ALL=C works around python 3.8 and 3.9 crash on macOS when the
+      # filesystem encoding is unspecified (e.g. when LANG=en_US).
+      local PYTHON=python
+      command -v python3 &> /dev/null && PYTHON=python3
+      LC_ALL=C "${PYTHON}" -c 'import time; print(int(round(time.time() * 1000)))'
+    }
+fi
+
+function get_run_time() {
+  local ts_start=$1
+  local ts_end=$2
+  run_time_ms=$((ts_end - ts_start))
+  echo $((run_time_ms / 1000)).${run_time_ms: -3}
+}
+
+# Usage: run_tests <suite-comment>
+# Must be called from the end of the user's test suite.
+# Calls exit with zero on success, non-zero otherwise.
+function run_suite() {
+  local message="$1"
+  # The name of the suite should be the script being run, which
+  # will be the filename with the ".sh" extension removed.
+  local suite_name="$(basename "$0")"
+
+  echo >&2
+  echo "$message" >&2
+  echo >&2
+
+  __log_to_test_report "<\/testsuites>" "<testsuite></testsuite>"
+
+  local total=0
+  local passed=0
+
+  atexit "cleanup"
+
+  # If the user didn't specify an explicit list of tests (e.g. a
+  # working set), use them all.
+  if (( ${#TESTS[@]} == 0 )); then
+    # Even if there aren't any tests, this needs to succeed.
+    local all_tests=()
+    IFS=$'\n' read -d $'\0' -ra all_tests < <(
+        declare -F | awk '{print $3}' | grep ^test_ || true; echo -en '\0')
+
+    if (( "${#_TEST_FILTERS[@]}" == 0 )); then
+      # Use ${array[@]+"${array[@]}"} idiom to avoid errors when running with
+      # Bash version <= 4.4 with `nounset` when `all_tests` is empty (
+      # https://github.com/bminor/bash/blob/a0c0a00fc419b7bc08202a79134fcd5bc0427071/CHANGES#L62-L63).
+      TESTS=("${all_tests[@]+${all_tests[@]}}")
+    else
+      for t in "${all_tests[@]+${all_tests[@]}}"; do
+        local matches=0
+        for f in "${_TEST_FILTERS[@]}"; do
+          # We purposely want to glob match.
+          # shellcheck disable=SC2053
+          [[ "$t" = $f ]] && matches=1 && break
+        done
+        if (( matches )); then
+          TESTS+=("$t")
+        fi
+      done
+    fi
+
+  elif [[ -n "${TEST_WARNINGS_OUTPUT_FILE:-}" ]]; then
+    if grep -q "TESTS=" "$TEST_script" ; then
+      echo "TESTS variable overridden in sh_test. Please remove before submitting" \
+        >> "$TEST_WARNINGS_OUTPUT_FILE"
+    fi
+  fi
+
+  # Reset TESTS in the common case where it contains a single empty string.
+  if [[ -z "${TESTS[*]-}" ]]; then
+    TESTS=()
+  fi
+  local original_tests_size=${#TESTS[@]}
+
+  __update_shards
+
+  if [[ "${#TESTS[@]}" -ne 0 ]]; then
+    for TEST_name in "${TESTS[@]}"; do
+      >"$TEST_log" # Reset the log.
+      TEST_passed="true"
+
+      (( ++total ))
+      if [[ "$TEST_verbose" == "true" ]]; then
+          date >&2
+          __pad "$TEST_name" '*' >&2
+      fi
+
+      local run_time="0.0"
+      rm -f "${TEST_TMPDIR}"/{__ts_start,__ts_end}
+
+      if [[ "$(type -t "$TEST_name")" == function ]]; then
+        # Save exit handlers eventually set.
+        local SAVED_ATEXIT="$ATEXIT";
+        ATEXIT=
+
+        # Run test in a subshell.
+        rm -f "${TEST_TMPDIR}"/__err_handled
+        __trap_with_arg __test_terminated INT KILL PIPE TERM ABRT FPE ILL QUIT SEGV
+
+        # Remember -o pipefail value and disable it for the subshell result
+        # collection.
+        if [[ "${SHELLOPTS}" =~ (^|:)pipefail(:|$) ]]; then
+          local __opt_switch=-o
+        else
+          local __opt_switch=+o
+        fi
+        set +o pipefail
+        (
+          set "${__opt_switch}" pipefail
+          # if errexit is enabled, make sure we run cleanup and collect the log.
+          if [[ "$-" = *e* ]]; then
+            set -E
+            trap __test_terminated_err ERR
+          fi
+          timestamp >"${TEST_TMPDIR}"/__ts_start
+          testenv_set_up
+          set_up
+          eval "$TEST_name"
+          __in_tear_down=1
+          tear_down
+          testenv_tear_down
+          timestamp >"${TEST_TMPDIR}"/__ts_end
+          test "$TEST_passed" == "true"
+        ) 2>&1 | tee "${TEST_TMPDIR}"/__log
+        # Note that tee will prevent the control flow continuing if the test
+        # spawned any processes which are still running and have not closed
+        # their stdout.
+
+        test_subshell_status=${PIPESTATUS[0]}
+        set "${__opt_switch}" pipefail
+        if (( test_subshell_status != 0 )); then
+          TEST_passed="false"
+          # Ensure that an end time is recorded in case the test subshell
+          # terminated prematurely.
+          [[ -f "$TEST_TMPDIR"/__ts_end ]] || timestamp >"$TEST_TMPDIR"/__ts_end
+        fi
+
+        # Calculate run time for the testcase.
+        local ts_start
+        ts_start=$(<"${TEST_TMPDIR}"/__ts_start)
+        local ts_end
+        ts_end=$(<"${TEST_TMPDIR}"/__ts_end)
+        run_time=$(get_run_time $ts_start $ts_end)
+
+        # Eventually restore exit handlers.
+        if [[ -n "$SAVED_ATEXIT" ]]; then
+          ATEXIT="$SAVED_ATEXIT"
+          trap "$ATEXIT" EXIT
+        fi
+      else # Bad test explicitly specified in $TESTS.
+        fail "Not a function: '$TEST_name'"
+      fi
+
+      local testcase_tag=""
+
+      local red='\033[0;31m'
+      local green='\033[0;32m'
+      local no_color='\033[0m'
+
+      if [[ "$TEST_verbose" == "true" ]]; then
+          echo >&2
+      fi
+
+      if [[ "$TEST_passed" == "true" ]]; then
+        if [[ "$TEST_verbose" == "true" ]]; then
+          echo -e "${green}PASSED${no_color}: ${TEST_name}" >&2
+        fi
+        (( ++passed ))
+        testcase_tag="<testcase name=\"${TEST_name}\" status=\"run\" time=\"${run_time}\" classname=\"\"></testcase>"
+      else
+        echo -e "${red}FAILED${no_color}: ${TEST_name}" >&2
+        # end marker in CDATA cannot be escaped, we need to split the CDATA sections
+        log=$(sed 's/]]>/]]>]]&gt;<![CDATA[/g' "${TEST_TMPDIR}"/__log)
+        fail_msg=$(cat "${TEST_TMPDIR}"/__fail 2> /dev/null || echo "No failure message")
+        # Replacing '&' with '&amp;', '<' with '&lt;', '>' with '&gt;', and '"' with '&quot;'
+        escaped_fail_msg=$(echo "$fail_msg" | sed 's/&/\&amp;/g' | sed 's/</\&lt;/g' | sed 's/>/\&gt;/g' | sed 's/"/\&quot;/g')
+        testcase_tag="<testcase name=\"${TEST_name}\" status=\"run\" time=\"${run_time}\" classname=\"\"><error message=\"${escaped_fail_msg}\"><![CDATA[${log}]]></error></testcase>"
+      fi
+
+      if [[ "$TEST_verbose" == "true" ]]; then
+          echo >&2
+      fi
+      __log_to_test_report "<\/testsuite>" "$testcase_tag"
+    done
+  fi
+
+  __finish_test_report "$suite_name" $total $passed
+  __pad "${passed} / ${total} tests passed." '*' >&2
+  if (( original_tests_size == 0 )); then
+    __pad "No tests found." '*'
+    exit 1
+  elif (( total != passed )); then
+    __pad "There were errors." '*' >&2
+    exit 1
+  elif (( total == 0 )); then
+    __pad "No tests executed due to sharding. Check your test's shard_count." '*'
+    __pad "Succeeding anyway." '*'
+  fi
+
+  exit 0
+}
diff --git a/test/bashunit/unittest_test.py b/test/bashunit/unittest_test.py
new file mode 100644
index 0000000..2ecc17f
--- /dev/null
+++ b/test/bashunit/unittest_test.py
@@ -0,0 +1,738 @@
+# Copyright 2020 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Tests for unittest.bash."""
+
+from __future__ import absolute_import
+from __future__ import division
+from __future__ import print_function
+
+import os
+import re
+import shutil
+import stat
+import subprocess
+import tempfile
+import textwrap
+import unittest
+
+# The test setup for this external test is forwarded to the internal bash test.
+# This allows the internal test to use the same runfiles to load unittest.bash.
+_TEST_PREAMBLE = """
+#!/bin/bash
+# --- begin runfiles.bash initialization ---
+if [[ -f "${RUNFILES_DIR:-/dev/null}/bazel_tools/tools/bash/runfiles/runfiles.bash" ]]; then
+  source "${RUNFILES_DIR}/bazel_tools/tools/bash/runfiles/runfiles.bash"
+else
+  echo >&2 "ERROR: cannot find @bazel_tools//tools/bash/runfiles:runfiles.bash"
+  exit 1
+fi
+# --- end runfiles.bash initialization ---
+
+echo "Writing XML to ${XML_OUTPUT_FILE}"
+
+source "$(rlocation "build_bazel_rules_android/test/bashunit/unittest.bash")" \
+  || { echo "Could not source unittest.bash" >&2; exit 1; }
+"""
+
+ANSI_ESCAPE = re.compile(r"(\x9B|\x1B\[)[0-?]*[ -\/]*[@-~]")
+
+
+def remove_ansi(line):
+  """Remove ANSI-style escape sequences from the input."""
+  return ANSI_ESCAPE.sub("", line)
+
+
+class TestResult(object):
+  """Save test results for easy checking."""
+
+  def __init__(self, asserter, return_code, output, xmlfile):
+    self._asserter = asserter
+    self._return_code = return_code
+    self._output = remove_ansi(output)
+
+    # Read in the XML result file.
+    if os.path.isfile(xmlfile):
+      with open(xmlfile, "r") as f:
+        self._xml = f.read()
+    else:
+      # Unable to read the file, errors will be reported later.
+      self._xml = ""
+
+  # Methods to assert on the state of the results.
+
+  def assertLogMessage(self, message):
+    self.assertExactlyOneMatch(self._output, message)
+
+  def assertNotLogMessage(self, message):
+    self._asserter.assertNotRegex(self._output, message)
+
+  def assertXmlMessage(self, message):
+    self.assertExactlyOneMatch(self._xml, message)
+
+  def assertNotXmlMessage(self, message):
+    self._asserter.assertNotRegex(self._xml, message)
+
+  def assertSuccess(self, suite_name):
+    self._asserter.assertEqual(0, self._return_code,
+                               f"Script failed unexpectedly:\n{self._output}")
+    self.assertLogMessage(suite_name)
+    self.assertXmlMessage("<testsuites [^/]*failures=\"0\"")
+    self.assertXmlMessage("<testsuites [^/]*errors=\"0\"")
+
+  def assertNotSuccess(self, suite_name, failures=0, errors=0):
+    self._asserter.assertNotEqual(0, self._return_code)
+    self.assertLogMessage(suite_name)
+    if failures:
+      self.assertXmlMessage(f'<testsuites [^/]*failures="{failures}"')
+    if errors:
+      self.assertXmlMessage(f'<testsuites [^/]*errors="{errors}"')
+
+  def assertTestPassed(self, test_name):
+    self.assertLogMessage(f"PASSED: {test_name}")
+
+  def assertTestFailed(self, test_name, message=""):
+    self.assertLogMessage(f"{test_name} FAILED: {message}")
+
+  def assertExactlyOneMatch(self, text, pattern):
+    self._asserter.assertRegex(text, pattern)
+    self._asserter.assertEqual(
+        len(re.findall(pattern, text)),
+        1,
+        msg=f"Found more than 1 match of '{pattern}' in '{text}'")
+
+
+class UnittestTest(unittest.TestCase):
+
+  def setUp(self):
+    """Create a working directory under our temp dir."""
+    super(UnittestTest, self).setUp()
+    self.work_dir = tempfile.mkdtemp(dir=os.environ["TEST_TMPDIR"])
+
+  def tearDown(self):
+    """Clean up the working directory."""
+    super(UnittestTest, self).tearDown()
+    shutil.rmtree(self.work_dir)
+
+  def write_file(self, filename, contents=""):
+    """Write the contents to a file in the workdir."""
+
+    filepath = os.path.join(self.work_dir, filename)
+    with open(filepath, "w") as f:
+      f.write(_TEST_PREAMBLE.strip())
+      f.write(contents)
+    os.chmod(filepath, stat.S_IEXEC | stat.S_IWRITE | stat.S_IREAD)
+
+  def find_runfiles(self):
+    if "RUNFILES_DIR" in os.environ:
+      return os.environ["RUNFILES_DIR"]
+
+    # Fall back to being based on the srcdir.
+    if "TEST_SRCDIR" in os.environ:
+      return os.environ["TEST_SRCDIR"]
+
+    # Base on the current dir
+    return f"{os.getcwd()}/.."
+
+  def execute_test(self, filename, env=None, args=()):
+    """Executes the file and stores the results."""
+
+    filepath = os.path.join(self.work_dir, filename)
+    xmlfile = os.path.join(self.work_dir, "dummy-testlog.xml")
+    test_env = {
+        "TEST_TMPDIR": self.work_dir,
+        "RUNFILES_DIR": self.find_runfiles(),
+        "TEST_SRCDIR": os.environ["TEST_SRCDIR"],
+        "XML_OUTPUT_FILE": xmlfile,
+    }
+    # Add in env, forcing everything to be a string.
+    if env:
+      for k, v in env.items():
+        test_env[k] = str(v)
+    completed = subprocess.run(
+        [filepath, *args],
+        env=test_env,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.STDOUT,
+    )
+    return TestResult(self, completed.returncode,
+                      completed.stdout.decode("utf-8"), xmlfile)
+
+  # Actual test cases.
+
+  def test_success(self):
+    self.write_file(
+        "thing.sh", """
+function test_success() {
+  echo foo >&${TEST_log} || fail "expected echo to succeed"
+  expect_log "foo"
+}
+
+run_suite "success tests"
+""")
+
+    result = self.execute_test("thing.sh")
+    result.assertSuccess("success tests")
+    result.assertTestPassed("test_success")
+
+  def test_timestamp(self):
+    self.write_file(
+        "thing.sh", """
+function test_timestamp() {
+  local ts=$(timestamp)
+  [[ $ts =~ ^[0-9]{13}$ ]] || fail "timestamp wan't valid: $ts"
+
+  local time_diff=$(get_run_time 100000 223456)
+  assert_equals $time_diff 123.456
+}
+
+run_suite "timestamp tests"
+""")
+
+    result = self.execute_test("thing.sh")
+    result.assertSuccess("timestamp tests")
+    result.assertTestPassed("test_timestamp")
+
+  def test_failure(self):
+    self.write_file(
+        "thing.sh", """
+function test_failure() {
+  fail "I'm a failure with <>&\\" escaped symbols"
+}
+
+run_suite "failure tests"
+""")
+
+    result = self.execute_test("thing.sh")
+    result.assertNotSuccess("failure tests", failures=0, errors=1)
+    result.assertTestFailed("test_failure")
+    result.assertXmlMessage(
+        "message=\"I'm a failure with &lt;&gt;&amp;&quot; escaped symbols\"")
+    result.assertXmlMessage("I'm a failure with <>&\" escaped symbols")
+
+  def test_set_bash_errexit_prints_stack_trace(self):
+    self.write_file(
+        "thing.sh", """
+set -euo pipefail
+
+function helper() {
+  echo before
+  false
+  echo after
+}
+
+function test_failure_in_helper() {
+  helper
+}
+
+run_suite "bash errexit tests"
+""")
+
+    result = self.execute_test("thing.sh")
+    result.assertNotSuccess("bash errexit tests")
+    result.assertTestFailed("test_failure_in_helper")
+    result.assertLogMessage(r"./thing.sh:\d*: in call to helper")
+    result.assertLogMessage(
+        r"./thing.sh:\d*: in call to test_failure_in_helper")
+
+  def test_set_bash_errexit_runs_tear_down(self):
+    self.write_file(
+        "thing.sh", """
+set -euo pipefail
+
+function tear_down() {
+  echo "Running tear_down"
+}
+
+function testenv_tear_down() {
+  echo "Running testenv_tear_down"
+}
+
+function test_failure_in_helper() {
+  wrong_command
+}
+
+run_suite "bash errexit tests"
+""")
+
+    result = self.execute_test("thing.sh")
+    result.assertNotSuccess("bash errexit tests")
+    result.assertTestFailed("test_failure_in_helper")
+    result.assertLogMessage("Running tear_down")
+    result.assertLogMessage("Running testenv_tear_down")
+
+  def test_set_bash_errexit_pipefail_propagates_failure_through_pipe(self):
+    self.write_file(
+        "thing.sh", """
+set -euo pipefail
+
+function test_pipefail() {
+  wrong_command | cat
+  echo after
+}
+
+run_suite "bash errexit tests"
+""")
+
+    result = self.execute_test("thing.sh")
+    result.assertNotSuccess("bash errexit tests")
+    result.assertTestFailed("test_pipefail")
+    result.assertLogMessage("wrong_command: command not found")
+    result.assertNotLogMessage("after")
+
+  def test_set_bash_errexit_no_pipefail_ignores_failure_before_pipe(self):
+    self.write_file(
+        "thing.sh", """
+set -eu
+set +o pipefail
+
+function test_nopipefail() {
+  wrong_command | cat
+  echo after
+}
+
+run_suite "bash errexit tests"
+""")
+
+    result = self.execute_test("thing.sh")
+    result.assertSuccess("bash errexit tests")
+    result.assertTestPassed("test_nopipefail")
+    result.assertLogMessage("wrong_command: command not found")
+    result.assertLogMessage("after")
+
+  def test_set_bash_errexit_pipefail_long_testname_succeeds(self):
+    test_name = "x" * 1000
+    self.write_file(
+        "thing.sh", """
+set -euo pipefail
+
+function test_%s() {
+  :
+}
+
+run_suite "bash errexit tests"
+""" % test_name)
+
+    result = self.execute_test("thing.sh")
+    result.assertSuccess("bash errexit tests")
+
+  def test_empty_test_fails(self):
+    self.write_file("thing.sh", """
+# No tests present.
+
+run_suite "empty test suite"
+""")
+
+    result = self.execute_test("thing.sh")
+    result.assertNotSuccess("empty test suite")
+    result.assertLogMessage("No tests found.")
+
+  def test_empty_test_succeeds_sharding(self):
+    self.write_file(
+        "thing.sh", """
+# Only one test.
+function test_thing() {
+  echo
+}
+
+run_suite "empty test suite"
+""")
+
+    # First shard.
+    result = self.execute_test(
+        "thing.sh", env={
+            "TEST_TOTAL_SHARDS": 2,
+            "TEST_SHARD_INDEX": 0,
+        })
+    result.assertSuccess("empty test suite")
+    result.assertLogMessage("No tests executed due to sharding")
+
+    # Second shard.
+    result = self.execute_test(
+        "thing.sh", env={
+            "TEST_TOTAL_SHARDS": 2,
+            "TEST_SHARD_INDEX": 1,
+        })
+    result.assertSuccess("empty test suite")
+    result.assertNotLogMessage("No tests")
+
+  def test_filter_runs_only_matching_test(self):
+    self.write_file(
+        "thing.sh",
+        textwrap.dedent("""
+        function test_abc() {
+          :
+        }
+
+        function test_def() {
+          echo "running def"
+        }
+
+        run_suite "tests to filter"
+        """))
+
+    result = self.execute_test(
+        "thing.sh", env={"TESTBRIDGE_TEST_ONLY": "test_a*"})
+
+    result.assertSuccess("tests to filter")
+    result.assertTestPassed("test_abc")
+    result.assertNotLogMessage("running def")
+
+  def test_filter_prefix_match_only_skips_test(self):
+    self.write_file(
+        "thing.sh",
+        textwrap.dedent("""
+        function test_abc() {
+          echo "running abc"
+        }
+
+        run_suite "tests to filter"
+        """))
+
+    result = self.execute_test(
+        "thing.sh", env={"TESTBRIDGE_TEST_ONLY": "test_a"})
+
+    result.assertNotSuccess("tests to filter")
+    result.assertLogMessage("No tests found.")
+
+  def test_filter_multiple_globs_runs_tests_matching_any(self):
+    self.write_file(
+        "thing.sh",
+        textwrap.dedent("""
+        function test_abc() {
+          echo "running abc"
+        }
+
+        function test_def() {
+          echo "running def"
+        }
+
+        run_suite "tests to filter"
+        """))
+
+    result = self.execute_test(
+        "thing.sh", env={"TESTBRIDGE_TEST_ONLY": "donotmatch:*a*"})
+
+    result.assertSuccess("tests to filter")
+    result.assertTestPassed("test_abc")
+    result.assertNotLogMessage("running def")
+
+  def test_filter_character_group_runs_only_matching_tests(self):
+    self.write_file(
+        "thing.sh",
+        textwrap.dedent("""
+        function test_aaa() {
+          :
+        }
+
+        function test_daa() {
+          :
+        }
+
+        function test_zaa() {
+          echo "running zaa"
+        }
+
+        run_suite "tests to filter"
+        """))
+
+    result = self.execute_test(
+        "thing.sh", env={"TESTBRIDGE_TEST_ONLY": "test_[a-f]aa"})
+
+    result.assertSuccess("tests to filter")
+    result.assertTestPassed("test_aaa")
+    result.assertTestPassed("test_daa")
+    result.assertNotLogMessage("running zaa")
+
+  def test_filter_sharded_runs_subset_of_filtered_tests(self):
+    for index in range(2):
+      with self.subTest(index=index):
+        self.__filter_sharded_runs_subset_of_filtered_tests(index)
+
+  def __filter_sharded_runs_subset_of_filtered_tests(self, index):
+    self.write_file(
+        "thing.sh",
+        textwrap.dedent("""
+        function test_a0() {
+          echo "running a0"
+        }
+
+        function test_a1() {
+          echo "running a1"
+        }
+
+        function test_bb() {
+          echo "running bb"
+        }
+
+        run_suite "tests to filter"
+        """))
+
+    result = self.execute_test(
+        "thing.sh",
+        env={
+            "TESTBRIDGE_TEST_ONLY": "test_a*",
+            "TEST_TOTAL_SHARDS": 2,
+            "TEST_SHARD_INDEX": index
+        })
+
+    result.assertSuccess("tests to filter")
+    # The sharding logic is shifted by 1, starts with 2nd shard.
+    result.assertTestPassed("test_a" + str(index ^ 1))
+    result.assertLogMessage("running a" + str(index ^ 1))
+    result.assertNotLogMessage("running a" + str(index))
+    result.assertNotLogMessage("running bb")
+
+  def test_arg_runs_only_matching_test_and_issues_warning(self):
+    self.write_file(
+        "thing.sh",
+        textwrap.dedent("""
+        function test_abc() {
+          :
+        }
+
+        function test_def() {
+          echo "running def"
+        }
+
+        run_suite "tests to filter"
+        """))
+
+    result = self.execute_test("thing.sh", args=["test_abc"])
+
+    result.assertSuccess("tests to filter")
+    result.assertTestPassed("test_abc")
+    result.assertNotLogMessage("running def")
+    result.assertLogMessage(
+        r"WARNING: Passing test names in arguments \(--test_arg\) is "
+        "deprecated, please use --test_filter='test_abc' instead.")
+
+  def test_arg_multiple_tests_issues_warning_with_test_filter_command(self):
+    self.write_file(
+        "thing.sh",
+        textwrap.dedent("""
+        function test_abc() {
+          :
+        }
+
+        function test_def() {
+          :
+        }
+
+        run_suite "tests to filter"
+        """))
+
+    result = self.execute_test("thing.sh", args=["test_abc", "test_def"])
+
+    result.assertSuccess("tests to filter")
+    result.assertTestPassed("test_abc")
+    result.assertTestPassed("test_def")
+    result.assertLogMessage(
+        r"WARNING: Passing test names in arguments \(--test_arg\) is "
+        "deprecated, please use --test_filter='test_abc:test_def' instead.")
+
+  def test_arg_and_filter_ignores_arg(self):
+    self.write_file(
+        "thing.sh",
+        textwrap.dedent("""
+        function test_abc() {
+          :
+        }
+
+        function test_def() {
+          echo "running def"
+        }
+
+        run_suite "tests to filter"
+        """))
+
+    result = self.execute_test(
+        "thing.sh", args=["test_def"], env={"TESTBRIDGE_TEST_ONLY": "test_a*"})
+
+    result.assertSuccess("tests to filter")
+    result.assertTestPassed("test_abc")
+    result.assertNotLogMessage("running def")
+    result.assertLogMessage(
+        "WARNING: Both --test_arg and --test_filter specified, ignoring --test_arg"
+    )
+
+  def test_custom_ifs_variable_finds_and_runs_test(self):
+    for sharded in (False, True):
+      for ifs in (r"\t", "t"):
+        with self.subTest(ifs=ifs, sharded=sharded):
+          self.__custom_ifs_variable_finds_and_runs_test(ifs, sharded)
+
+  def __custom_ifs_variable_finds_and_runs_test(self, ifs, sharded):
+    self.write_file(
+        "thing.sh",
+        textwrap.dedent(r"""
+        set -euo pipefail
+        IFS=$'%s'
+        function test_foo() {
+          :
+        }
+
+        run_suite "custom IFS test"
+        """ % ifs))
+
+    result = self.execute_test(
+        "thing.sh",
+        env={} if not sharded else {
+            "TEST_TOTAL_SHARDS": 2,
+            "TEST_SHARD_INDEX": 1
+        })
+
+    result.assertSuccess("custom IFS test")
+    result.assertTestPassed("test_foo")
+
+  def test_fail_in_teardown_reports_failure(self):
+    self.write_file(
+        "thing.sh",
+        textwrap.dedent(r"""
+        function tear_down() {
+          echo "tear_down log" >"${TEST_log}"
+          fail "tear_down failure"
+        }
+
+        function test_foo() {
+          :
+        }
+
+        run_suite "Failure in tear_down test"
+        """))
+
+    result = self.execute_test("thing.sh")
+
+    result.assertNotSuccess("Failure in tear_down test", errors=1)
+    result.assertTestFailed("test_foo", "tear_down failure")
+    result.assertXmlMessage('message="tear_down failure"')
+    result.assertLogMessage("tear_down log")
+
+  def test_fail_in_teardown_after_test_failure_reports_both_failures(self):
+    self.write_file(
+        "thing.sh",
+        textwrap.dedent(r"""
+        function tear_down() {
+          echo "tear_down log" >"${TEST_log}"
+          fail "tear_down failure"
+        }
+
+        function test_foo() {
+          echo "test_foo log" >"${TEST_log}"
+          fail "Test failure"
+        }
+
+        run_suite "Failure in tear_down test"
+        """))
+
+    result = self.execute_test("thing.sh")
+
+    result.assertNotSuccess("Failure in tear_down test", errors=1)
+    result.assertTestFailed("test_foo", "Test failure")
+    result.assertTestFailed("test_foo", "tear_down failure")
+    result.assertXmlMessage('message="Test failure"')
+    result.assertNotXmlMessage('message="tear_down failure"')
+    result.assertXmlMessage("test_foo log")
+    result.assertXmlMessage("tear_down log")
+    result.assertLogMessage("Test failure")
+    result.assertLogMessage("tear_down failure")
+    result.assertLogMessage("test_foo log")
+    result.assertLogMessage("tear_down log")
+
+  def test_errexit_in_teardown_reports_failure(self):
+    self.write_file(
+        "thing.sh",
+        textwrap.dedent(r"""
+        set -euo pipefail
+
+        function tear_down() {
+          invalid_command
+        }
+
+        function test_foo() {
+          :
+        }
+
+        run_suite "errexit in tear_down test"
+        """))
+
+    result = self.execute_test("thing.sh")
+
+    result.assertNotSuccess("errexit in tear_down test")
+    result.assertLogMessage("invalid_command: command not found")
+    result.assertXmlMessage('message="No failure message"')
+    result.assertXmlMessage("invalid_command: command not found")
+
+  def test_fail_in_tear_down_after_errexit_reports_both_failures(self):
+    self.write_file(
+        "thing.sh",
+        textwrap.dedent(r"""
+        set -euo pipefail
+
+        function tear_down() {
+          echo "tear_down log" >"${TEST_log}"
+          fail "tear_down failure"
+        }
+
+        function test_foo() {
+          invalid_command
+        }
+
+        run_suite "fail after failure"
+        """))
+
+    result = self.execute_test("thing.sh")
+
+    result.assertNotSuccess("fail after failure")
+    result.assertTestFailed(
+        "test_foo",
+        "terminated because this command returned a non-zero status")
+    result.assertTestFailed("test_foo", "tear_down failure")
+    result.assertLogMessage("invalid_command: command not found")
+    result.assertLogMessage("tear_down log")
+    result.assertXmlMessage('message="No failure message"')
+    result.assertXmlMessage("invalid_command: command not found")
+
+  def test_errexit_in_tear_down_after_errexit_reports_both_failures(self):
+    self.write_file(
+        "thing.sh",
+        textwrap.dedent(r"""
+        set -euo pipefail
+
+        function tear_down() {
+          invalid_command_tear_down
+        }
+
+        function test_foo() {
+          invalid_command_test
+        }
+
+        run_suite "fail after failure"
+        """))
+
+    result = self.execute_test("thing.sh")
+
+    result.assertNotSuccess("fail after failure")
+    result.assertTestFailed(
+        "test_foo",
+        "terminated because this command returned a non-zero status")
+    result.assertLogMessage("invalid_command_test: command not found")
+    result.assertLogMessage("invalid_command_tear_down: command not found")
+    result.assertXmlMessage('message="No failure message"')
+    result.assertXmlMessage("invalid_command_test: command not found")
+    result.assertXmlMessage("invalid_command_tear_down: command not found")
+
+
+if __name__ == "__main__":
+  unittest.main()
diff --git a/test/bashunit/unittest_utils.sh b/test/bashunit/unittest_utils.sh
new file mode 100644
index 0000000..be3409e
--- /dev/null
+++ b/test/bashunit/unittest_utils.sh
@@ -0,0 +1,181 @@
+# Copyright 2020 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Support for unittest.bash
+
+#### Set up the test environment.
+
+set -euo pipefail
+
+cat_jvm_log () {
+  if [[ "$log_content" =~ \
+      "(error code:".*", error message: '".*"', log file: '"(.*)"')" ]]; then
+    echo >&2
+    echo "Content of ${BASH_REMATCH[1]}:" >&2
+    cat "${BASH_REMATCH[1]}" >&2
+  fi
+}
+
+# Print message in "$1" then exit with status "$2"
+die () {
+  # second argument is optional, defaulting to 1
+  local status_code=${2:-1}
+  # Stop capturing stdout/stderr, and dump captured output
+  if [[ "$CAPTURED_STD_ERR" -ne 0 || "$CAPTURED_STD_OUT" -ne 0 ]]; then
+    restore_outputs
+    if [[ "$CAPTURED_STD_OUT" -ne 0 ]]; then
+        cat "${TEST_TMPDIR}/captured.out"
+        CAPTURED_STD_OUT=0
+    fi
+    if [[ "$CAPTURED_STD_ERR" -ne 0 ]]; then
+        cat "${TEST_TMPDIR}/captured.err" 1>&2
+        cat_jvm_log "$(cat "${TEST_TMPDIR}/captured.err")"
+        CAPTURED_STD_ERR=0
+    fi
+  fi
+
+  if [[ -n "${1-}" ]] ; then
+      echo "$1" 1>&2
+  fi
+  if [[ -n "${BASH-}" ]]; then
+    local caller_n=0
+    while [[ $caller_n -lt 4 ]] && \
+        caller_out=$(caller $caller_n 2>/dev/null); do
+      test $caller_n -eq 0 && echo "CALLER stack (max 4):"
+      echo "  $caller_out"
+      let caller_n=caller_n+1
+    done 1>&2
+  fi
+  if [[ -n "${status_code}" && "${status_code}" -ne 0 ]]; then
+      exit "$status_code"
+  else
+      exit 1
+  fi
+}
+
+# Print message in "$1" then record that a non-fatal error occurred in
+# ERROR_COUNT
+ERROR_COUNT="${ERROR_COUNT:-0}"
+error () {
+  if [[ -n "$1" ]] ; then
+      echo "$1" 1>&2
+  fi
+  ERROR_COUNT=$(($ERROR_COUNT + 1))
+}
+
+# Die if "$1" != "$2", print $3 as death reason
+check_eq () {
+  [[ "$1" = "$2" ]] || die "Check failed: '$1' == '$2' ${3:+ ($3)}"
+}
+
+# Die if "$1" == "$2", print $3 as death reason
+check_ne () {
+  [[ "$1" != "$2" ]] || die "Check failed: '$1' != '$2' ${3:+ ($3)}"
+}
+
+# The structure of the following if statements is such that if '[[' fails
+# (e.g., a non-number was passed in) then the check will fail.
+
+# Die if "$1" > "$2", print $3 as death reason
+check_le () {
+  [[ "$1" -gt "$2" ]] || die "Check failed: '$1' <= '$2' ${3:+ ($3)}"
+}
+
+# Die if "$1" >= "$2", print $3 as death reason
+check_lt () {
+  [[ "$1" -lt "$2" ]] || die "Check failed: '$1' < '$2' ${3:+ ($3)}"
+}
+
+# Die if "$1" < "$2", print $3 as death reason
+check_ge () {
+  [[ "$1" -ge "$2" ]] || die "Check failed: '$1' >= '$2' ${3:+ ($3)}"
+}
+
+# Die if "$1" <= "$2", print $3 as death reason
+check_gt () {
+  [[ "$1" -gt "$2" ]] || die "Check failed: '$1' > '$2' ${3:+ ($3)}"
+}
+
+# Die if $2 !~ $1; print $3 as death reason
+check_match ()
+{
+  expr match "$2" "$1" >/dev/null || \
+    die "Check failed: '$2' does not match regex '$1' ${3:+ ($3)}"
+}
+
+# Run command "$1" at exit. Like "trap" but multiple atexits don't
+# overwrite each other. Will break if someone does call trap
+# directly. So, don't do that.
+ATEXIT="${ATEXIT-}"
+atexit () {
+  if [[ -z "$ATEXIT" ]]; then
+      ATEXIT="$1"
+  else
+      ATEXIT="$1 ; $ATEXIT"
+  fi
+  trap "$ATEXIT" EXIT
+}
+
+## TEST_TMPDIR
+if [[ -z "${TEST_TMPDIR:-}" ]]; then
+  export TEST_TMPDIR="$(mktemp -d ${TMPDIR:-/tmp}/bazel-test.XXXXXXXX)"
+fi
+if [[ ! -e "${TEST_TMPDIR}" ]]; then
+  mkdir -p -m 0700 "${TEST_TMPDIR}"
+  # Clean TEST_TMPDIR on exit
+  atexit "rm -fr ${TEST_TMPDIR}"
+fi
+
+# Functions to compare the actual output of a test to the expected
+# (golden) output.
+#
+# Usage:
+#   capture_test_stdout
+#   ... do something ...
+#   diff_test_stdout "$TEST_SRCDIR/path/to/golden.out"
+
+# Redirect a file descriptor to a file.
+CAPTURED_STD_OUT="${CAPTURED_STD_OUT:-0}"
+CAPTURED_STD_ERR="${CAPTURED_STD_ERR:-0}"
+
+capture_test_stdout () {
+  exec 3>&1 # Save stdout as fd 3
+  exec 4>"${TEST_TMPDIR}/captured.out"
+  exec 1>&4
+  CAPTURED_STD_OUT=1
+}
+
+capture_test_stderr () {
+  exec 6>&2 # Save stderr as fd 6
+  exec 7>"${TEST_TMPDIR}/captured.err"
+  exec 2>&7
+  CAPTURED_STD_ERR=1
+}
+
+# Force XML_OUTPUT_FILE to an existing path
+if [[ -z "${XML_OUTPUT_FILE:-}" ]]; then
+  XML_OUTPUT_FILE=${TEST_TMPDIR}/output.xml
+fi
+
+# Functions to provide easy access to external repository outputs in the sibling
+# repository layout.
+#
+# Usage:
+#   bin_dir <repository name>
+#   genfiles_dir <repository name>
+#   testlogs_dir <repository name>
+
+testlogs_dir() {
+  echo $(bazel info bazel-testlogs | sed "s|bazel-out|bazel-out/$1|")
+}
diff --git a/test/rules/android_binary_internal/r8_integration/BUILD b/test/rules/android_binary_internal/r8_integration/BUILD
new file mode 100644
index 0000000..fcefaeb
--- /dev/null
+++ b/test/rules/android_binary_internal/r8_integration/BUILD
@@ -0,0 +1,12 @@
+load("@rules_python//python:py_test.bzl", "py_test")
+
+py_test(
+    name = "r8_integration_test",
+    srcs = ["r8_integration_test.py"],
+    data = [
+        "//test/rules/android_binary_internal/r8_integration/java/com/basicapp:basic_app_R8_no_shrink",
+        "//test/rules/android_binary_internal/r8_integration/java/com/basicapp:basic_app_R8_shrink",
+        "//test/rules/android_binary_internal/r8_integration/java/com/basicapp:basic_app_no_R8",
+        "@androidsdk-supplemental//:dexdump",
+    ],
+)
diff --git a/test/rules/android_binary_internal/r8_integration/java/com/basicapp/AndroidManifest.xml b/test/rules/android_binary_internal/r8_integration/java/com/basicapp/AndroidManifest.xml
new file mode 100644
index 0000000..8d9cc4a
--- /dev/null
+++ b/test/rules/android_binary_internal/r8_integration/java/com/basicapp/AndroidManifest.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.basicapp" >
+
+    <uses-sdk
+        android:minSdkVersion="27"
+        android:targetSdkVersion="30" />
+
+    <application
+        android:allowBackup="true"
+        android:label="@string/app_name"
+        android:taskAffinity="" >
+        <activity
+            android:name="com.basicapp.BasicActivity"
+            android:exported="true">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+    </application>
+</manifest>
diff --git a/test/rules/android_binary_internal/r8_integration/java/com/basicapp/AndroidManifest_lib.xml b/test/rules/android_binary_internal/r8_integration/java/com/basicapp/AndroidManifest_lib.xml
new file mode 100644
index 0000000..f661d59
--- /dev/null
+++ b/test/rules/android_binary_internal/r8_integration/java/com/basicapp/AndroidManifest_lib.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.basicapp" >
+
+    <uses-sdk
+        android:minSdkVersion="27"
+        android:targetSdkVersion="30" />
+
+</manifest>
diff --git a/test/rules/android_binary_internal/r8_integration/java/com/basicapp/BUILD b/test/rules/android_binary_internal/r8_integration/java/com/basicapp/BUILD
new file mode 100644
index 0000000..4727887
--- /dev/null
+++ b/test/rules/android_binary_internal/r8_integration/java/com/basicapp/BUILD
@@ -0,0 +1,56 @@
+load("//rules:rules.bzl", "android_binary", "android_library")
+
+[
+    android_binary(
+        name = name,
+        srcs = ["BasicActivity.java"],
+        manifest = "AndroidManifest.xml",
+        min_sdk_version = 27,
+        proguard_specs = specs,
+        resource_files = glob(["res/**"]),
+        shrink_resources = shrink,
+        visibility = ["//test/rules/android_binary_internal/r8_integration:__pkg__"],
+        deps = [
+            ":basic_lib",
+            ":lib_with_specs",
+        ],
+        # Work around --java_runtime_version=17 and --java_language_version=11
+        # set in the presubmit tests.
+        javacopts = ["-target", "8", "-source", "8"],
+    )
+    for name, specs, shrink in [
+        (
+            "basic_app_R8_shrink",
+            ["proguard.cfg"],
+            True,
+        ),
+        (
+            "basic_app_R8_no_shrink",
+            ["proguard.cfg"],
+            False,
+        ),
+        ("basic_app_no_R8", [], False),
+    ]
+]
+
+android_library(
+    name = "basic_lib",
+    srcs = ["UnusedActivity.java"],
+    manifest = "AndroidManifest_lib.xml",
+    resource_files = glob(["res_lib/**"]),
+)
+
+android_library(
+    name = "lib_with_specs",
+    srcs = ["LibWithSpecsActivity.java"],
+    manifest = "AndroidManifest_lib.xml",
+    proguard_specs = ["lib_proguard.cfg"],
+    deps = [":lib2_with_specs"],
+)
+
+android_library(
+    name = "lib2_with_specs",
+    srcs = ["Lib2WithSpecsActivity.java"],
+    manifest = "AndroidManifest_lib.xml",
+    proguard_specs = ["lib2_proguard.cfg"],
+)
diff --git a/test/rules/android_binary_internal/r8_integration/java/com/basicapp/BasicActivity.java b/test/rules/android_binary_internal/r8_integration/java/com/basicapp/BasicActivity.java
new file mode 100644
index 0000000..4aee8d1
--- /dev/null
+++ b/test/rules/android_binary_internal/r8_integration/java/com/basicapp/BasicActivity.java
@@ -0,0 +1,49 @@
+// Copyright 2023 The Bazel Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.basicapp;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.view.View;
+import android.widget.Button;
+import android.widget.TextView;
+
+/** The main activity of the Basic Sample App. */
+public class BasicActivity extends Activity {
+
+  @Override
+  protected void onCreate(Bundle savedInstanceState) {
+    super.onCreate(savedInstanceState);
+    setContentView(R.layout.basic_activity);
+
+    final Button buttons[] = {
+      findViewById(R.id.button_id_fizz), findViewById(R.id.button_id_buzz),
+    };
+
+    for (Button b : buttons) {
+      b.setOnClickListener(
+          new View.OnClickListener() {
+            public void onClick(View v) {
+              TextView tv = findViewById(R.id.text_hello);
+              if (v.getId() == R.id.button_id_fizz) {
+                tv.setText("fizz");
+              } else if (v.getId() == R.id.button_id_buzz) {
+                tv.setText("buzz");
+              }
+            }
+          });
+    }
+  }
+}
diff --git a/test/rules/android_binary_internal/r8_integration/java/com/basicapp/Lib2WithSpecsActivity.java b/test/rules/android_binary_internal/r8_integration/java/com/basicapp/Lib2WithSpecsActivity.java
new file mode 100644
index 0000000..57d613f
--- /dev/null
+++ b/test/rules/android_binary_internal/r8_integration/java/com/basicapp/Lib2WithSpecsActivity.java
@@ -0,0 +1,29 @@
+// Copyright 2023 The Bazel Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.basicapp;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.util.Log;
+
+/** The main activity of the Basic Sample App. */
+public class Lib2WithSpecsActivity extends Activity {
+
+  @Override
+  protected void onCreate(Bundle savedInstanceState) {
+    super.onCreate(savedInstanceState);
+    Log.i("TAG", "onCreate");
+  }
+}
diff --git a/test/rules/android_binary_internal/r8_integration/java/com/basicapp/LibWithSpecsActivity.java b/test/rules/android_binary_internal/r8_integration/java/com/basicapp/LibWithSpecsActivity.java
new file mode 100644
index 0000000..b40be64
--- /dev/null
+++ b/test/rules/android_binary_internal/r8_integration/java/com/basicapp/LibWithSpecsActivity.java
@@ -0,0 +1,29 @@
+// Copyright 2023 The Bazel Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.basicapp;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.util.Log;
+
+/** The main activity of the Basic Sample App. */
+public class LibWithSpecsActivity extends Activity {
+
+  @Override
+  protected void onCreate(Bundle savedInstanceState) {
+    super.onCreate(savedInstanceState);
+    Log.i("TAG", "onCreate");
+  }
+}
diff --git a/test/rules/android_binary_internal/r8_integration/java/com/basicapp/UnusedActivity.java b/test/rules/android_binary_internal/r8_integration/java/com/basicapp/UnusedActivity.java
new file mode 100644
index 0000000..e188f87
--- /dev/null
+++ b/test/rules/android_binary_internal/r8_integration/java/com/basicapp/UnusedActivity.java
@@ -0,0 +1,30 @@
+// Copyright 2023 The Bazel Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.basicapp;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.util.Log;
+
+/** The main activity of the Basic Sample App. */
+public class UnusedActivity extends Activity {
+
+  @Override
+  protected void onCreate(Bundle savedInstanceState) {
+    super.onCreate(savedInstanceState);
+    setContentView(R.layout.unused_activity);
+    Log.i("TAG", "onCreate");
+  }
+}
diff --git a/test/rules/android_binary_internal/r8_integration/java/com/basicapp/lib2_proguard.cfg b/test/rules/android_binary_internal/r8_integration/java/com/basicapp/lib2_proguard.cfg
new file mode 100644
index 0000000..3fddc01
--- /dev/null
+++ b/test/rules/android_binary_internal/r8_integration/java/com/basicapp/lib2_proguard.cfg
@@ -0,0 +1 @@
+-keep class com.basicapp.Lib2WithSpecsActivity
\ No newline at end of file
diff --git a/test/rules/android_binary_internal/r8_integration/java/com/basicapp/lib_proguard.cfg b/test/rules/android_binary_internal/r8_integration/java/com/basicapp/lib_proguard.cfg
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/test/rules/android_binary_internal/r8_integration/java/com/basicapp/lib_proguard.cfg
diff --git a/test/rules/android_binary_internal/r8_integration/java/com/basicapp/proguard.cfg b/test/rules/android_binary_internal/r8_integration/java/com/basicapp/proguard.cfg
new file mode 100644
index 0000000..e0ef7ce
--- /dev/null
+++ b/test/rules/android_binary_internal/r8_integration/java/com/basicapp/proguard.cfg
@@ -0,0 +1,2 @@
+# proguard specs
+-dontobfuscate
diff --git a/test/rules/android_binary_internal/r8_integration/java/com/basicapp/res/layout/basic_activity.xml b/test/rules/android_binary_internal/r8_integration/java/com/basicapp/res/layout/basic_activity.xml
new file mode 100644
index 0000000..f84199c
--- /dev/null
+++ b/test/rules/android_binary_internal/r8_integration/java/com/basicapp/res/layout/basic_activity.xml
@@ -0,0 +1,23 @@
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+              android:layout_width="match_parent"
+              android:layout_height="match_parent"
+              android:orientation="vertical" >
+
+    <TextView
+        android:id="@+id/text_hello"
+        android:text="@string/hello_world"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content" />
+
+     <Button
+        android:id="@+id/button_id_fizz"
+        android:layout_height="wrap_content"
+        android:layout_width="wrap_content"
+        android:text="fizz" />
+     <Button
+        android:id="@+id/button_id_buzz"
+        android:layout_height="wrap_content"
+        android:layout_width="wrap_content"
+        android:text="buzz" />
+
+</LinearLayout>
diff --git a/test/rules/android_binary_internal/r8_integration/java/com/basicapp/res/values/strings.xml b/test/rules/android_binary_internal/r8_integration/java/com/basicapp/res/values/strings.xml
new file mode 100644
index 0000000..565c987
--- /dev/null
+++ b/test/rules/android_binary_internal/r8_integration/java/com/basicapp/res/values/strings.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+
+    <string name="app_name" translatable="false">basicapp</string>
+    <string name="hello_world" translatable="false">Hello world!</string>
+    <string name="action_settings" translatable="false">Settings</string>
+
+</resources>
diff --git a/test/rules/android_binary_internal/r8_integration/java/com/basicapp/res_lib/layout/unused_activity.xml b/test/rules/android_binary_internal/r8_integration/java/com/basicapp/res_lib/layout/unused_activity.xml
new file mode 100644
index 0000000..61fb73e
--- /dev/null
+++ b/test/rules/android_binary_internal/r8_integration/java/com/basicapp/res_lib/layout/unused_activity.xml
@@ -0,0 +1,12 @@
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+              android:layout_width="match_parent"
+              android:layout_height="match_parent"
+              android:orientation="vertical" >
+
+    <TextView
+        android:id="@+id/text_hello"
+        android:text="@string/hello_world"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content" />
+
+</LinearLayout>
diff --git a/test/rules/android_binary_internal/r8_integration/java/com/basicapp/res_lib/values/strings.xml b/test/rules/android_binary_internal/r8_integration/java/com/basicapp/res_lib/values/strings.xml
new file mode 100644
index 0000000..79934a2
--- /dev/null
+++ b/test/rules/android_binary_internal/r8_integration/java/com/basicapp/res_lib/values/strings.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+
+    <string name="app_name" translatable="false">basicapp</string>
+    <string name="hello_world" translatable="false">Hello world! unused</string>
+    <string name="action_settings" translatable="false">Settings</string>
+
+</resources>
diff --git a/test/rules/android_binary_internal/r8_integration/r8_integration_test.py b/test/rules/android_binary_internal/r8_integration/r8_integration_test.py
new file mode 100755
index 0000000..b9ba7e2
--- /dev/null
+++ b/test/rules/android_binary_internal/r8_integration/r8_integration_test.py
@@ -0,0 +1,91 @@
+# Copyright 2023 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import os
+import subprocess
+import unittest
+import zipfile
+
+
+class R8IntegrationTest(unittest.TestCase):
+  """Tests Bazel's R8 integration."""
+
+  def _r8_integration_check(
+      self, apk, expect_unused_activity_resource, expect_unused_activity_class
+  ):
+    tmp = os.environ["TEST_TMPDIR"]
+    apk_directory = (
+        "test/rules/android_binary_internal/r8_integration/java/com/basicapp"
+    )
+    apk_tmp = os.path.join(tmp, apk)
+    classes_dex = os.path.join(apk_tmp, "classes.dex")
+    with zipfile.ZipFile(os.path.join(apk_directory, apk)) as zf:
+      apk_files = zf.namelist()
+      zf.extract("classes.dex", apk_tmp)
+
+    self.assertEqual(
+        expect_unused_activity_resource,
+        "res/layout/unused_activity.xml" in apk_files,
+    )
+
+    build_tools_dir = "external/androidsdk-supplemental/build-tools"
+    build_tools_version = os.listdir(build_tools_dir)[0]
+    dexdump = os.path.join(build_tools_dir, build_tools_version, "dexdump")
+
+    dexdump_proc = subprocess.run(
+        [dexdump, classes_dex],
+        stdout=subprocess.PIPE,
+        stderr=subprocess.PIPE,
+        check=True,
+    )
+    dexdump_stdout = str(dexdump_proc.stdout)
+    self.assertEqual(
+        expect_unused_activity_class,
+        "Class descriptor  : 'Lcom/basicapp/UnusedActivity;'" in dexdump_stdout,
+    )
+
+    # In all cases, the Lib2WithSpecsActivity class should be in the app,
+    # since lib2_proguard.cfg (an indirect dependency) specifies to keep it.
+    self.assertIn(
+        "Class descriptor  : 'Lcom/basicapp/Lib2WithSpecsActivity;'",
+        dexdump_stdout,
+    )
+
+  def test_r8_integration(self):
+    # No R8, so unused resources and unused classes should be in the app
+    self._r8_integration_check(
+        "basic_app_no_R8.apk",
+        expect_unused_activity_resource=True,
+        expect_unused_activity_class=True,
+    )
+
+    # Run R8, don't shrink, so unused class should not be in the app but unused
+    # resource should remain.
+    self._r8_integration_check(
+        "basic_app_R8_no_shrink.apk",
+        expect_unused_activity_resource=True,
+        expect_unused_activity_class=False,
+    )
+
+    # Run R8 and shrinkings, so unused classes and resources should not be in
+    # the app.
+    self._r8_integration_check(
+        "basic_app_R8_shrink.apk",
+        expect_unused_activity_resource=False,
+        expect_unused_activity_class=False,
+    )
+
+
+if __name__ == "__main__":
+  unittest.main()
diff --git a/test/utils/asserts.bzl b/test/utils/asserts.bzl
index a1e5a1d..7a81b61 100644
--- a/test/utils/asserts.bzl
+++ b/test/utils/asserts.bzl
@@ -27,6 +27,7 @@
     expected_starlark_android_resources_info = attr.label(),
     expected_output_group_info = attr.string_list_dict(),
     expected_native_libs_info = attr.label(),
+    expected_generated_extension_registry_provider = attr.string_list_dict(),
 )
 
 def _expected_resources_node_info_impl(ctx):
@@ -536,6 +537,20 @@
             "OutputGroupInfo." + key,
         )
 
+def _assert_generated_extension_registry_provider(expected, actual):
+    if expected and not actual:
+        fail("GeneratedExtensionRegistryProvider was expected but not found!")
+    for key in expected:
+        actual_attr = getattr(actual, key, None)
+        if actual_attr == None:  # both empty depset and list will fail.
+            fail("%s is not defined in OutputGroupInfo: %s" % (key, actual))
+
+        _assert_files(
+            expected[key],
+            [actual_attr] if type(actual_attr) != "depset" else actual_attr.to_list(),
+            "GeneratedExtensionRegistryProvider." + key,
+        )
+
 def _is_suffix_sublist(full, suffixes):
     """Returns whether suffixes is a sublist of suffixes of full."""
     for (fi, _) in enumerate(full):
@@ -598,6 +613,7 @@
         starlark_android_resources_info = _assert_starlark_android_resources_info,
         output_group_info = _assert_output_group_info,
         native_libs_info = _assert_native_libs_info,
+        generated_extension_registry_provider = _assert_generated_extension_registry_provider,
     ),
     files = _assert_files,
     r_class = struct(
diff --git a/toolchains/android/toolchain.bzl b/toolchains/android/toolchain.bzl
index f403e75..452fe6f 100644
--- a/toolchains/android/toolchain.bzl
+++ b/toolchains/android/toolchain.bzl
@@ -96,6 +96,12 @@
         default = "//tools/android:bundletool_deploy.jar",
         executable = True,
     ),
+    bundletool_module_builder = attr.label(
+        allow_single_file = True,
+        cfg = "exec",
+        default = "//src/tools/bundletool_module_builder",
+        executable = True,
+    ),
     centralize_r_class_tool = attr.label(
         allow_files = True,
         cfg = "exec",
@@ -145,7 +151,7 @@
     idlclass = attr.label(
         allow_files = True,
         cfg = "exec",
-        default = "@bazel_tools//tools/android:IdlClass",  # _deploy.jar?
+        default = "@bazel_tools//src/tools/android/java/com/google/devtools/build/android/idlclass:IdlClass_deploy.jar",
         executable = True,
     ),
     import_deps_checker = attr.label(
@@ -170,10 +176,10 @@
         executable = True,
     ),
     merge_baseline_profiles_tool = attr.label(
-      default = "@androidsdk//:fail",
-      cfg = "exec",
-      executable = True
-      ),
+        default = "@androidsdk//:fail",
+        cfg = "exec",
+        executable = True,
+    ),
     object_method_rewriter = attr.label(
         allow_files = True,
         cfg = "exec",
@@ -186,7 +192,7 @@
         executable = True,
     ),
     profgen = attr.label(
-        default =  "@androidsdk//:fail",
+        default = "@androidsdk//:fail",
         cfg = "exec",
         executable = True,
     ),
@@ -196,6 +202,18 @@
         allow_files = True,
         executable = True,
     ),
+    r8 = attr.label(
+        cfg = "exec",
+        default = "//tools/android:r8_deploy.jar",
+        executable = True,
+        allow_files = True,
+    ),
+    resource_shrinker = attr.label(
+        cfg = "exec",
+        default = "//tools/android:resource_shrinker_deploy.jar",
+        executable = True,
+        allow_files = True,
+    ),
     res_v3_dummy_manifest = attr.label(
         allow_files = True,
         default = "//rules:res_v3_dummy_AndroidManifest.xml",
@@ -208,6 +226,12 @@
         allow_files = True,
         default = "//rules:robolectric_properties_template.txt",
     ),
+    sandboxed_sdk_toolbox = attr.label(
+        allow_single_file = True,
+        cfg = "exec",
+        default = "//src/tools/java/com/google/devtools/build/android/sandboxedsdktoolbox:sandboxed_sdk_toolbox_deploy.jar",
+        executable = True,
+    ),
     testsupport = attr.label(
         default = "@androidsdk//:fail",
     ),
diff --git a/tools/android/BUILD b/tools/android/BUILD
index 7a85b59..922e7e3 100644
--- a/tools/android/BUILD
+++ b/tools/android/BUILD
@@ -40,3 +40,35 @@
         "@bazel_tools//src/tools/android/java/com/google/devtools/build/android:all_android_tools",
     ],
 )
+
+alias(
+    name = "java8_legacy_dex",
+    actual = "@bazel_tools//tools/android:java8_legacy_dex",
+    visibility = ["//visibility:public"],
+)
+
+alias(
+    name = "desugar_java8",
+    actual = "@bazel_tools//tools/android:desugar_java8",
+    visibility = ["//visibility:public"],
+)
+
+alias(
+    name = "desugared_java8_legacy_apis",
+    actual = "@bazel_tools//tools/android:desugared_java8_legacy_apis",
+    visibility = ["//visibility:public"],
+)
+
+java_binary(
+    name = "r8",
+    main_class = "com.android.tools.r8.R8",
+    visibility = ["//visibility:public"],
+    runtime_deps = ["@android_gmaven_r8//jar"],
+)
+
+java_binary(
+    name = "resource_shrinker",
+    main_class = "com.android.build.shrinker.ResourceShrinkerCli",
+    visibility = ["//visibility:public"],
+    runtime_deps = ["@rules_android_maven//:com_android_tools_build_gradle"],
+)
diff --git a/tools/jdk/BUILD b/tools/jdk/BUILD
index 76b1921..f3259df 100644
--- a/tools/jdk/BUILD
+++ b/tools/jdk/BUILD
@@ -8,7 +8,19 @@
 )
 
 alias(
+    name = "toolchain",
+    actual = "@bazel_tools//tools/jdk:toolchain",
+    visibility = ["//visibility:public"],
+)
+
+alias(
     name = "current_java_runtime",
     actual = "@bazel_tools//tools/jdk:current_java_runtime",
     visibility = ["//visibility:public"],
 )
+
+alias(
+    name = "current_host_java_runtime",
+    actual = "@bazel_tools//tools/jdk:current_host_java_runtime",
+    visibility = ["//visibility:public"],
+)