Import mobile-data-download code to AOSP. am: 5925581724

Original change: https://android-review.googlesource.com/c/platform/external/mobile-data-download/+/2134626

Change-Id: I4695a44439ccc7666fde927cd744cf2ccb8cb1a5
Signed-off-by: Automerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com>
diff --git a/Android.bp b/Android.bp
new file mode 100644
index 0000000..d610530
--- /dev/null
+++ b/Android.bp
@@ -0,0 +1,82 @@
+// Copyright 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package {
+    default_applicable_licenses: ["mobile_data_downloader_license"],
+}
+
+// Added automatically by a large-scale-change
+// See: http://go/android-license-faq
+license {
+    name: "mobile_data_downloader_license",
+    visibility: [":__subpackages__"],
+    license_kinds: [
+        "SPDX-license-identifier-Apache-2.0",
+    ],
+    license_text: [
+        "LICENSE",
+    ],
+}
+
+java_library {
+    name: "android_checker_annotation_stubs",
+    srcs: ["android-annotation-stubs/src/**/*.java"],
+    host_supported: true,
+    sdk_version: "core_current",
+}
+
+android_library {
+    name: "mobile_data_downloader_lib",
+    srcs: [
+        "java/**/*.java",
+    ],
+    exclude_srcs: [
+            "java/com/google/android/libraries/mobiledatadownload/downloader/offroad/dagger/**/*.java",
+            "java/com/google/android/libraries/mobiledatadownload/file/common/testing/**/*.java",
+            "java/com/google/android/libraries/mobiledatadownload/downloader/offroad/Offroad2FileDownloader.java",
+    ],
+    static_libs: [
+        "androidx.core_core",
+        "androidx.annotation_annotation",
+        "error_prone_annotations",
+        "guava",
+        "mobile-data-download-java-proto-lite",
+        "mobile-data-download-populator-java-proto-lite",
+        "dagger2",
+        "jsr330",
+        "checker-qual",
+        "android_downloader_lib",
+    ],
+    libs: [
+        "auto_value_annotations",
+        "framework-annotations-lib",
+        "unsupportedappusage",
+        "framework-adservices",
+        "android_checker_annotation_stubs",
+    ],
+    plugins: [
+        "auto_value_plugin",
+        "dagger2-compiler",
+        "auto_annotation_plugin",
+    ],
+    sdk_version: "current",
+    min_sdk_version: "30",
+    apex_available: [
+        "//apex_available:platform",
+         "com.android.adservices",
+    ],
+    visibility: [
+        "//packages/modules/AdServices:__subpackages__",
+    ],
+}
\ No newline at end of file
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
new file mode 100644
index 0000000..00f13b8
--- /dev/null
+++ b/AndroidManifest.xml
@@ -0,0 +1,22 @@
+<!--
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+-->
+<manifest
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.google.android.libraries.mobiledatadownload">
+    <uses-sdk android:minSdkVersion="16" />
+</manifest>
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..6272489
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,28 @@
+# How to Contribute
+
+We'd love to accept your patches and contributions to this project. There are
+just a few small guidelines you need to follow.
+
+## Contributor License Agreement
+
+Contributions to this project must be accompanied by a Contributor License
+Agreement. You (or your employer) retain the copyright to your contribution;
+this simply gives us permission to use and redistribute your contributions as
+part of the project. Head over to <https://cla.developers.google.com/> to see
+your current agreements on file or to sign a new one.
+
+You generally only need to submit a CLA once, so if you've already submitted one
+(even if it was for a different project), you probably don't need to do it
+again.
+
+## Code Reviews
+
+All submissions, including submissions by project members, require review. We
+use GitHub pull requests for this purpose. Consult
+[GitHub Help](https://help.github.com/articles/about-pull-requests/) for more
+information on using pull requests.
+
+## Community Guidelines
+
+This project follows [Google's Open Source Community
+Guidelines](https://opensource.google/conduct/).
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..7a4a3ea
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,202 @@
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
\ No newline at end of file
diff --git a/METADATA b/METADATA
new file mode 100644
index 0000000..5588018
--- /dev/null
+++ b/METADATA
@@ -0,0 +1,13 @@
+name: "Mobile Data Download"
+description:
+    "Android library for downloading and managing files on device."
+
+third_party {
+  url {
+    type: GIT
+    value: "https://github.com/google/mobile-data-download"
+  }
+  version: "14021ab48c795d4dad9b83a78102fac5db2bf85e"
+  last_upgrade_date { year: 2022 month: 6 day: 15 }
+  license_type: NOTICE
+}
diff --git a/MODULE_LICENSE_APACHE2 b/MODULE_LICENSE_APACHE2
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/MODULE_LICENSE_APACHE2
diff --git a/OWNERS b/OWNERS
new file mode 100644
index 0000000..370ff32
--- /dev/null
+++ b/OWNERS
@@ -0,0 +1,3 @@
+npattan@google.com
+binhnguyen@google.com
+haoliuu@google.com
diff --git a/android-annotation-stubs/gen_annotations.sh b/android-annotation-stubs/gen_annotations.sh
new file mode 100644
index 0000000..634da97
--- /dev/null
+++ b/android-annotation-stubs/gen_annotations.sh
@@ -0,0 +1,16 @@
+#!/bin/bash
+
+ANNOTATIONS=(
+    org.checkerframework.checker.nullness.compatqual.NullableDecl
+    org.checkerframework.checker.nullness.compatqual.NullableType
+)
+
+for a in ${ANNOTATIONS[@]}; do
+    package=${a%.*}
+    class=${a##*.}
+    dir=$(dirname $0)/src/${package//.//}
+    file=${class}.java
+
+    mkdir -p ${dir}
+    sed -e"s/__PACKAGE__/${package}/" -e"s/__CLASS__/${class}/" tmpl.java > ${dir}/${file}
+done
diff --git a/android-annotation-stubs/src/org/checkerframework/checker/nullness/compatqual/NullableDecl.java b/android-annotation-stubs/src/org/checkerframework/checker/nullness/compatqual/NullableDecl.java
new file mode 100644
index 0000000..5e7c344
--- /dev/null
+++ b/android-annotation-stubs/src/org/checkerframework/checker/nullness/compatqual/NullableDecl.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.checkerframework.checker.nullness.compatqual;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Target({
+        ElementType.ANNOTATION_TYPE,
+        ElementType.CONSTRUCTOR,
+        ElementType.FIELD,
+        ElementType.LOCAL_VARIABLE,
+        ElementType.METHOD,
+        ElementType.PACKAGE,
+        ElementType.PARAMETER,
+        ElementType.TYPE,
+        ElementType.TYPE_PARAMETER,
+        ElementType.TYPE_USE})
+@Retention(RetentionPolicy.SOURCE)
+public @interface NullableDecl {}
\ No newline at end of file
diff --git a/android-annotation-stubs/src/org/checkerframework/checker/nullness/compatqual/NullableType.java b/android-annotation-stubs/src/org/checkerframework/checker/nullness/compatqual/NullableType.java
new file mode 100644
index 0000000..520a44f
--- /dev/null
+++ b/android-annotation-stubs/src/org/checkerframework/checker/nullness/compatqual/NullableType.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.checkerframework.checker.nullness.compatqual;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/* This is an annotation stub to avoid dependencies on annotations that aren't
+ * in the Android platform source tree. */
+
+@Target({
+        ElementType.ANNOTATION_TYPE,
+        ElementType.CONSTRUCTOR,
+        ElementType.FIELD,
+        ElementType.LOCAL_VARIABLE,
+        ElementType.METHOD,
+        ElementType.PACKAGE,
+        ElementType.PARAMETER,
+        ElementType.TYPE,
+        ElementType.TYPE_PARAMETER,
+        ElementType.TYPE_USE})
+@Retention(RetentionPolicy.SOURCE)
+public @interface NullableType {}
\ No newline at end of file
diff --git a/android-annotation-stubs/tmpl.java b/android-annotation-stubs/tmpl.java
new file mode 100644
index 0000000..35fb2ea
--- /dev/null
+++ b/android-annotation-stubs/tmpl.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package __PACKAGE__;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/* This is an annotation stub to avoid dependencies on annotations that aren't
+ * in the Android platform source tree. */
+
+@Target({
+        ElementType.ANNOTATION_TYPE,
+        ElementType.CONSTRUCTOR,
+        ElementType.FIELD,
+        ElementType.LOCAL_VARIABLE,
+        ElementType.METHOD,
+        ElementType.PACKAGE,
+        ElementType.PARAMETER,
+        ElementType.TYPE,
+        ElementType.TYPE_PARAMETER,
+        ElementType.TYPE_USE})
+@Retention(RetentionPolicy.SOURCE)
+public @interface __CLASS__ {}
\ No newline at end of file
diff --git a/java/com/google/android/libraries/mobiledatadownload/AccountSource.java b/java/com/google/android/libraries/mobiledatadownload/AccountSource.java
new file mode 100644
index 0000000..1da6065
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/AccountSource.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+import android.accounts.Account;
+import com.google.common.collect.ImmutableList;
+
+/**
+ * Implementations are responsible for listing all accounts available on the system. Required for
+ * wipeout compliance.
+ */
+public interface AccountSource {
+  ImmutableList<Account> getAllAccounts();
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/AddFileGroupRequest.java b/java/com/google/android/libraries/mobiledatadownload/AddFileGroupRequest.java
new file mode 100644
index 0000000..74cd1f0
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/AddFileGroupRequest.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+import android.accounts.Account;
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Optional;
+import com.google.mobiledatadownload.DownloadConfigProto.DataFileGroup;
+import javax.annotation.concurrent.Immutable;
+
+/** Request to add file group in MDD. */
+@AutoValue
+@Immutable
+public abstract class AddFileGroupRequest {
+  AddFileGroupRequest() {}
+
+  public abstract DataFileGroup dataFileGroup();
+
+  public abstract Optional<Account> accountOptional();
+
+  public abstract Optional<String> variantIdOptional();
+
+  public static Builder newBuilder() {
+    return new AutoValue_AddFileGroupRequest.Builder();
+  }
+
+  /** Builder for {@link AddFileGroupRequest}. */
+  @AutoValue.Builder
+  public abstract static class Builder {
+    Builder() {}
+
+    /** Sets the data file group, which is required. */
+    public abstract Builder setDataFileGroup(DataFileGroup dataFileGroup);
+
+    /**
+     * Sets the account associated with the group, which is optional.
+     *
+     * <p>NOTE: When this option is set, it will also be required when retrieving file groups in
+     * {@link GetFileGroupRequest}.
+     */
+    public abstract Builder setAccountOptional(Optional<Account> accountOptional);
+
+    /**
+     * Sets the variant id associated with the group, which is optional.
+     *
+     * <p>NOTE: When this option is set, it will also be required when retrieving file groups in
+     * {@link GetFileGroupRequest}.
+     */
+    public abstract Builder setVariantIdOptional(Optional<String> variantIdOptional);
+
+    public abstract AddFileGroupRequest build();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/AggregateException.java b/java/com/google/android/libraries/mobiledatadownload/AggregateException.java
new file mode 100644
index 0000000..94080d9
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/AggregateException.java
@@ -0,0 +1,192 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+import androidx.annotation.VisibleForTesting;
+import com.google.common.base.Optional;
+import com.google.common.collect.ImmutableList;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.util.Collection;
+import java.util.Locale;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.ExecutionException;
+import javax.annotation.Nullable;
+
+/**
+ * Represents an exception that's an aggregate of multiple other exceptions.
+ *
+ * <p>The first aggregated exception in set as the cause (see {@link Exception#getCause}) of the
+ * {@link AggregateException}. The full list can be accessed with {@link #getFailures}.
+ */
+public final class AggregateException extends Exception {
+  private static final String SEPARATOR_HEADER = "--- Failure %d ----------------------------\n";
+  private static final String SEPARATOR = "-------------------------------------------";
+
+  /** The maximum number of causes to recursively print in an exception's message. */
+  private static final int MAX_CAUSE_DEPTH = 5;
+
+  private final ImmutableList<Throwable> failures;
+
+  private AggregateException(String message, Throwable cause, ImmutableList<Throwable> failures) {
+    super(message, cause);
+    this.failures = failures;
+  }
+
+  /** Returns the list of the aggregated failures. */
+  public ImmutableList<Throwable> getFailures() {
+    return failures;
+  }
+
+  /**
+   * Throws {@link AggregateException} if any of the future in {@code futures} fails with either
+   * {@link CancellationException} or {@link ExecutionException}.
+   *
+   * <p>The {@code callbackOptional}, if present, will be executed each time when a future inside
+   * {@code futures} completes.
+   */
+  public static <T> void throwIfFailed(
+      Collection<ListenableFuture<T>> futures,
+      Optional<FutureCallback<T>> callbackOptional,
+      String message,
+      Object... args)
+      throws AggregateException {
+    ImmutableList.Builder<Throwable> builder = null;
+    for (ListenableFuture<T> future : futures) {
+      try {
+        T result = Futures.getDone(future);
+        if (callbackOptional.isPresent()) {
+          callbackOptional.get().onSuccess(result);
+        }
+      } catch (CancellationException | ExecutionException e) {
+        // Unwrap the cause of the execution exception, we will instead wrap it with the aggregate.
+        if (builder == null) {
+          builder = ImmutableList.builder();
+        }
+        Throwable unwrapped = unwrapException(e);
+        builder.add(unwrapped);
+        if (callbackOptional.isPresent()) {
+          callbackOptional.get().onFailure(unwrapped);
+        }
+      }
+    }
+    if (builder == null) {
+      return;
+    }
+    final ImmutableList<Throwable> failures = builder.build();
+    throw newInstance(failures, message, args);
+  }
+
+  /**
+   * Throws {@link AggregateException} if any of the future in {@code futures} fails with either
+   * {@link CancellationException} or {@link ExecutionException}.
+   */
+  public static <T> void throwIfFailed(
+      Collection<ListenableFuture<T>> futures, String message, Object... args)
+      throws AggregateException {
+    throwIfFailed(futures, /* callbackOptional= */ Optional.absent(), message, args);
+  }
+
+  /**
+   * Consolidates completed futures into a single failed futures if any failures exist.
+   *
+   * <p>If there are no failures, a void future will be returned.
+   *
+   * <p>If there is a single failure, that failure will be returned.
+   *
+   * <p>If there are multiple failures, an AggregateException containing all failures will be
+   * returned.
+   */
+  public static <T> ListenableFuture<Void> consolidateDoneFutures(
+      Collection<ListenableFuture<T>> futures, String message, Object... args) {
+    try {
+      // Check if any futures are failed.
+      throwIfFailed(futures, message, args);
+    } catch (AggregateException e) {
+      if (e.getFailures().size() == 1) {
+        return Futures.immediateFailedFuture(e.getFailures().get(0));
+      } else {
+        return Futures.immediateFailedFuture(e);
+      }
+    }
+    return Futures.immediateVoidFuture();
+  }
+
+  /** Constructs a new {@link AggregateException} with the given throwables. */
+  public static AggregateException newInstance(
+      ImmutableList<Throwable> failures, String message, Object... args) {
+    String prologue = String.format(Locale.US, message, args);
+    return new AggregateException(
+        failures.size() > 1
+            ? throwablesToString(
+                prologue + "\n" + failures.size() + " failure(s) in total:\n", failures)
+            : prologue,
+        failures.get(0),
+        failures);
+  }
+
+  @VisibleForTesting
+  static Throwable unwrapException(Throwable t) {
+    Throwable cause = t.getCause();
+    if (cause == null) {
+      return t;
+    }
+    Class<?> clazz = t.getClass();
+    if (clazz.equals(ExecutionException.class)) {
+      return unwrapException(cause);
+    }
+    return t;
+  }
+
+  private static String throwablesToString(
+      @Nullable String prologue, ImmutableList<Throwable> throwables) {
+    try (StringWriter out = new StringWriter();
+        PrintWriter writer = new PrintWriter(out)) {
+      if (prologue != null) {
+        writer.println(prologue);
+      }
+      for (int i = 0; i < throwables.size(); i++) {
+        Throwable failure = throwables.get(i);
+        writer.printf(SEPARATOR_HEADER, (i + 1));
+        writer.println(throwableToString(failure));
+      }
+      writer.println(SEPARATOR);
+      return out.toString();
+    } catch (Throwable t) {
+      return "Failed to build string from throwables: " + t;
+    }
+  }
+
+  @VisibleForTesting
+  static String throwableToString(Throwable failure) {
+    return throwableToString(failure, /*depth=*/ 1);
+  }
+
+  private static String throwableToString(Throwable failure, int depth) {
+    String message = failure.getClass().getName() + ": " + failure.getMessage();
+    Throwable cause = failure.getCause();
+    if (cause != null) {
+      if (depth >= MAX_CAUSE_DEPTH) {
+        return message + "\n(...)";
+      }
+      return message + "\nCaused by: " + throwableToString(cause, depth + 1);
+    }
+    return message;
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/AndroidManifest.xml b/java/com/google/android/libraries/mobiledatadownload/AndroidManifest.xml
new file mode 100644
index 0000000..e48e188
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/AndroidManifest.xml
@@ -0,0 +1,26 @@
+<!--
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+-->
+<manifest
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.google.android.libraries.mobiledatadownload">
+
+  <uses-sdk android:minSdkVersion="16"/>
+
+  <!-- An empty application tag is required for framework AAR conformance. -->
+  <application></application>
+</manifest>
diff --git a/java/com/google/android/libraries/mobiledatadownload/BUILD b/java/com/google/android/libraries/mobiledatadownload/BUILD
new file mode 100644
index 0000000..733d814
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/BUILD
@@ -0,0 +1,255 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_library")
+
+package(
+    default_visibility = [
+        "//visibility:public",
+    ],
+    licenses = ["notice"],
+)
+
+android_library(
+    name = "mobiledatadownload",
+    srcs = glob(
+        ["*.java"],
+        exclude = [
+            "AccountSource.java",
+            "AggregateException.java",
+            "Configurator.java",
+            "TimeSource.java",
+            "Flags.java",
+            "Constants.java",
+            "DownloadException.java",
+            "DownloadListener.java",
+            "Logger.java",
+            "MobileDataDownloadBuilder.java",
+            "SilentFeedback.java",
+            "UsageEvent.java",
+            "SingleFileDownloadRequest.java",
+            "SingleFileDownloadListener.java",
+            "FileSource.java",
+            "ExperimentationConfig.java",
+        ],
+    ),
+    exports = [
+        ":single_file_interfaces",
+    ],
+    deps = [
+        ":DownloadException",
+        ":DownloadListener",
+        ":FileSource",
+        ":Flags",
+        ":UsageEvent",
+        ":single_file_interfaces",
+        "//java/com/google/android/libraries/mobiledatadownload/account:AccountUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/foreground:NotificationUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:MobileDataDownloadManager",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:EventLogger",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LogUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:MddLiteConversionUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:ProtoConversionUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/lite",
+        "//java/com/google/android/libraries/mobiledatadownload/monitor:DownloadProgressMonitor",
+        "//java/com/google/android/libraries/mobiledatadownload/tracing",
+        "//proto:client_config_java_proto_lite",
+        "//proto:download_config_java_proto_lite",
+        "@androidx_annotation_annotation",
+        "@androidx_core_core",
+        "@com_google_auto_value",
+        "@com_google_code_findbugs_jsr305",
+        "@com_google_guava_guava",
+        "@com_google_protobuf//:any_proto",
+        "@com_google_protobuf//:protobuf_lite",
+    ],
+)
+
+android_library(
+    name = "MobileDataDownloadBuilder",
+    srcs = [
+        "MobileDataDownloadBuilder.java",
+    ],
+    deps = [
+        ":AccountSource",
+        ":Configurator",
+        ":Constants",
+        ":DownloadException",
+        ":DownloadListener",
+        ":ExperimentationConfig",
+        ":Flags",
+        ":Logger",
+        ":SilentFeedback",
+        ":mobiledatadownload",
+        "//java/com/google/android/libraries/mobiledatadownload/account:AccountManagerAccountSource",
+        "//java/com/google/android/libraries/mobiledatadownload/account:AccountUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/delta:DeltaDecoder",
+        "//java/com/google/android/libraries/mobiledatadownload/downloader:FileDownloader",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/foreground:NotificationUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:MobileDataDownloadManager",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/dagger:ApplicationContextModule",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/dagger:DownloaderModule",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/dagger:ExecutorsModule",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/dagger:MainMddLibModule",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/dagger:StandaloneComponent",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:EventLogger",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LogSampler",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LogUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:MddEventLogger",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:NoOpEventLogger",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:ProtoConversionUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/lite",
+        "//java/com/google/android/libraries/mobiledatadownload/monitor:DownloadProgressMonitor",
+        "//java/com/google/android/libraries/mobiledatadownload/monitor:NetworkUsageMonitor",
+        "//java/com/google/android/libraries/mobiledatadownload/tracing",
+        "//proto:client_config_java_proto_lite",
+        "//proto:download_config_java_proto_lite",
+        "@androidx_core_core",
+        "@com_google_auto_value",
+        "@com_google_code_findbugs_jsr305",
+        "@com_google_dagger",
+        "@com_google_guava_guava",
+        "@com_google_protobuf//:protobuf_lite",
+    ],
+)
+
+android_library(
+    name = "AccountSource",
+    srcs = ["AccountSource.java"],
+    deps = [
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "TimeSource",
+    srcs = ["TimeSource.java"],
+)
+
+android_library(
+    name = "Configurator",
+    srcs = ["Configurator.java"],
+    deps = [
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "Flags",
+    srcs = ["Flags.java"],
+)
+
+android_library(
+    name = "Logger",
+    srcs = ["Logger.java"],
+    deps = [
+        "@com_google_protobuf//:protobuf_lite",
+    ],
+)
+
+android_library(
+    name = "SilentFeedback",
+    srcs = ["SilentFeedback.java"],
+    deps = [
+        "@com_google_errorprone_error_prone_annotations",
+    ],
+)
+
+android_library(
+    name = "TaskScheduler",
+    srcs = ["TaskScheduler.java"],
+    deps = [
+        "@com_google_auto_value",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "DownloadListener",
+    srcs = ["DownloadListener.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LogUtil",
+        "//proto:client_config_java_proto_lite",
+    ],
+)
+
+android_library(
+    name = "Constants",
+    srcs = ["Constants.java"],
+    deps = [
+        "//proto:download_config_java_proto_lite",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "DownloadException",
+    srcs = ["DownloadException.java"],
+    deps = ["@com_google_guava_guava"],
+)
+
+android_library(
+    name = "FileSource",
+    srcs = ["FileSource.java"],
+    deps = [
+        "@com_google_code_findbugs_jsr305",
+        "@com_google_protobuf//:protobuf_lite",
+    ],
+)
+
+android_library(
+    name = "AggregateException",
+    srcs = ["AggregateException.java"],
+    deps = [
+        "@androidx_annotation_annotation",
+        "@com_google_code_findbugs_jsr305",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "single_file_interfaces",
+    srcs = [
+        "SingleFileDownloadListener.java",
+        "SingleFileDownloadRequest.java",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/downloader:FileDownloader",
+        "@com_google_auto_value",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "UsageEvent",
+    srcs = [
+        "UsageEvent.java",
+    ],
+    deps = [
+        "//proto:client_config_java_proto_lite",
+        "@com_google_auto_value",
+    ],
+)
+
+android_library(
+    name = "ExperimentationConfig",
+    srcs = ["ExperimentationConfig.java"],
+    deps = [
+        "@com_google_auto_value",
+        "@com_google_guava_guava",
+    ],
+)
diff --git a/java/com/google/android/libraries/mobiledatadownload/Configurator.java b/java/com/google/android/libraries/mobiledatadownload/Configurator.java
new file mode 100644
index 0000000..182e6c5
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/Configurator.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+import com.google.common.util.concurrent.ListenableFuture;
+
+/** Interface for update configurations */
+public interface Configurator {
+
+  /** Commits to the most recent configuration. */
+  ListenableFuture<Void> commitToFlagSnapshot();
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/Constants.java b/java/com/google/android/libraries/mobiledatadownload/Constants.java
new file mode 100644
index 0000000..7c71cd1
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/Constants.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+import com.google.common.base.Optional;
+import com.google.mobiledatadownload.DownloadConfigProto.DownloadConditions;
+import com.google.mobiledatadownload.DownloadConfigProto.DownloadConditions.DeviceNetworkPolicy;
+import com.google.mobiledatadownload.DownloadConfigProto.DownloadConditions.DeviceStoragePolicy;
+
+/** External MDD Constants */
+public final class Constants {
+  // TODO: Figure out if we can add a test to keep in sync with DownloadCondition
+  /** To download under any conditions, clients should use constant. */
+  // LINT.IfChange
+  public static final Optional<DownloadConditions> NO_RESTRICTIONS_DOWNLOAD_CONDITIONS =
+      Optional.of(
+          DownloadConditions.newBuilder()
+              .setDeviceNetworkPolicy(DeviceNetworkPolicy.DOWNLOAD_ON_ANY_NETWORK)
+              .setDeviceStoragePolicy(DeviceStoragePolicy.BLOCK_DOWNLOAD_LOWER_THRESHOLD)
+              .build());
+  // LINT.ThenChange(<internal>)
+
+  /** The version of MDD library. Same as mdi_download module version. */
+  // TODO(b/122271766): Figure out how to update this automatically.
+  // LINT.IfChange
+  public static final int MDD_LIB_VERSION = 422883838;
+  // LINT.ThenChange(<internal>)
+
+  // <internal>
+  private Constants() {}
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/CustomFileGroupValidator.java b/java/com/google/android/libraries/mobiledatadownload/CustomFileGroupValidator.java
new file mode 100644
index 0000000..19beafe
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/CustomFileGroupValidator.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.errorprone.annotations.CheckReturnValue;
+import com.google.mobiledatadownload.ClientConfigProto.ClientFileGroup;
+
+/**
+ * Runs custom validation routine on the file group. The file group is not allowed to become active
+ * unless this returns true.
+ *
+ * <p>This callback is invoked for all file groups in the MDD instance. It is up to the implementor
+ * to handle file groups that don't need validation by, eg, returning true immediately.
+ *
+ * <p>The ClientFileGroup account field is never populated during validation. The status field will
+ * be set to PENDING_CUSTOM_VALIDATION.
+ */
+@CheckReturnValue
+public interface CustomFileGroupValidator {
+  ListenableFuture<Boolean> validateFileGroup(ClientFileGroup fileGroup);
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/DownloadException.java b/java/com/google/android/libraries/mobiledatadownload/DownloadException.java
new file mode 100644
index 0000000..cc9a148
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/DownloadException.java
@@ -0,0 +1,237 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+import static com.google.common.util.concurrent.Futures.immediateFailedFuture;
+
+import com.google.common.base.Preconditions;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
+
+/** Thrown when there is a download failure. */
+public final class DownloadException extends Exception {
+  /** This error code is a representation of {@code MddDownloadResult.Code}. */
+  private final DownloadResultCode downloadResultCode;
+
+  /**
+   * This is the result of calling download, which should be identical to {@code
+   * MddDownloadResult.Code}.
+   */
+  // LINT.IfChange
+  public enum DownloadResultCode {
+    UNSPECIFIED(0), // unset value
+
+    // File downloaded successfully.
+    // This is just a placeholder, we currently don't log for success case.
+    SUCCESS(1),
+
+    // The error we don't know.
+    UNKNOWN_ERROR(2),
+
+    // The errors from the android downloader outside MDD, which comes from:
+    // <internal>
+    ANDROID_DOWNLOADER_UNKNOWN(100),
+    ANDROID_DOWNLOADER_CANCELED(101),
+    ANDROID_DOWNLOADER_INVALID_REQUEST(102),
+    ANDROID_DOWNLOADER_HTTP_ERROR(103),
+    ANDROID_DOWNLOADER_REQUEST_ERROR(104),
+    ANDROID_DOWNLOADER_RESPONSE_OPEN_ERROR(105),
+    ANDROID_DOWNLOADER_RESPONSE_CLOSE_ERROR(106),
+    ANDROID_DOWNLOADER_NETWORK_IO_ERROR(107),
+    ANDROID_DOWNLOADER_DISK_IO_ERROR(108),
+    ANDROID_DOWNLOADER_FILE_SYSTEM_ERROR(109),
+    ANDROID_DOWNLOADER_UNKNOWN_IO_ERROR(110),
+    ANDROID_DOWNLOADER_OAUTH_ERROR(111),
+
+    // The errors from the android downloader v2 outside MDD, which comes from:
+    // <internal>
+    ANDROID_DOWNLOADER2_ERROR(200),
+
+    // The data file group has not been added to MDD by the time the caller
+    // makes download API call.
+    GROUP_NOT_FOUND_ERROR(300),
+
+    // The DownloadListener is present but the DownloadMonitor is not provided.
+    DOWNLOAD_MONITOR_NOT_PROVIDED_ERROR(301),
+
+    // Errors from unsatisfied download preconditions.
+    INSECURE_URL_ERROR(302),
+    LOW_DISK_ERROR(303),
+
+    // Errors from download preparation.
+    UNABLE_TO_CREATE_FILE_URI_ERROR(304),
+    SHARED_FILE_NOT_FOUND_ERROR(305),
+    MALFORMED_FILE_URI_ERROR(306),
+    UNABLE_TO_CREATE_MOBSTORE_RESPONSE_WRITER_ERROR(307),
+
+    // Errors from file validation.
+    UNABLE_TO_VALIDATE_DOWNLOAD_FILE_ERROR(308),
+    DOWNLOADED_FILE_NOT_FOUND_ERROR(309),
+    DOWNLOADED_FILE_CHECKSUM_MISMATCH_ERROR(310),
+    CUSTOM_FILEGROUP_VALIDATION_FAILED(330),
+
+    // Errors from download transforms.
+    UNABLE_TO_SERIALIZE_DOWNLOAD_TRANSFORM_ERROR(311),
+    DOWNLOAD_TRANSFORM_IO_ERROR(312),
+    FINAL_FILE_CHECKSUM_MISMATCH_ERROR(313),
+
+    // Errors from delta download.
+    DELTA_DOWNLOAD_BASE_FILE_NOT_FOUND_ERROR(314),
+    DELTA_DOWNLOAD_DECODE_IO_ERROR(315),
+
+    // The error occurs after the file is ready.
+    UNABLE_TO_UPDATE_FILE_STATE_ERROR(316),
+
+    // Fail to update the file group metadata.
+    UNABLE_TO_UPDATE_GROUP_METADATA_ERROR(317),
+
+    // Errors from sharing files with the blob storage.
+    // Failed to update the metadata max_expiration_date.
+    UNABLE_TO_UPDATE_FILE_MAX_EXPIRATION_DATE(318),
+    // Failed to share the file before SharedFileManager.startDownload is called.
+    UNABLE_SHARE_FILE_BEFORE_DOWNLOAD_ERROR(319),
+    // Failed to share the file after SharedFileManager.startDownload is called.
+    UNABLE_SHARE_FILE_AFTER_DOWNLOAD_ERROR(320),
+
+    // Download errors related to isolated file structure
+    UNABLE_TO_REMOVE_SYMLINK_STRUCTURE(321),
+    UNABLE_TO_CREATE_SYMLINK_STRUCTURE(322),
+
+    // Download errors related to importing inline files
+    // Failed to reserve file entries
+    UNABLE_TO_RESERVE_FILE_ENTRY(323),
+    // Invalid use of inlinefile url scheme
+    INVALID_INLINE_FILE_URL_SCHEME(324),
+    // Error performing inline file download
+    INLINE_FILE_IO_ERROR(327),
+    // Missing required inline download parms in FileDownloader's DownloadRequest
+    MISSING_INLINE_DOWNLOAD_PARAMS(328),
+    // Missing required inline file source in ImportFilesRequest
+    MISSING_INLINE_FILE_SOURCE(329),
+
+    // Download errors related to URL parsing
+    MALFORMED_DOWNLOAD_URL(325),
+    UNSUPPORTED_DOWNLOAD_URL_SCHEME(326),
+
+    // Download errors for manifest file group populator.
+    MANIFEST_FILE_GROUP_POPULATOR_INVALID_FLAG_ERROR(400),
+    MANIFEST_FILE_GROUP_POPULATOR_CONTENT_CHANGED_DURING_DOWNLOAD_ERROR(401),
+    MANIFEST_FILE_GROUP_POPULATOR_PARSE_MANIFEST_FILE_ERROR(402),
+    MANIFEST_FILE_GROUP_POPULATOR_DELETE_MANIFEST_FILE_ERROR(403),
+    MANIFEST_FILE_GROUP_POPULATOR_METADATA_IO_ERROR(404),
+
+    // GDD specific download errors, reserved from 2000-2999.
+    GDD_INVALID_ACCOUNT(2000),
+    GDD_INVALID_AUTH_TOKEN(2001),
+    GDD_FAIL_IN_SYNC_RUNNER(2002),
+    GDD_INVALID_ELEMENT_COMBINATION_RECEIVED(2003),
+    GDD_INVALID_INLINE_PAYLOAD_ELEMENT_DATA(2004),
+    GDD_INVALID_CURRENT_ACTIVE_ELEMENT_DATA(2005),
+    GDD_INVALID_NEXT_PENDING_ELEMENT_DATA(2006),
+    GDD_CURRENT_ACTIVE_GROUP_HAS_NO_INLINE_FILE(2007),
+    GDD_FAIL_TO_ADD_NEXT_PENDING_GROUP(2008),
+    GDD_MISSING_ACCOUNT_FOR_PRIVATE_SYNC(2009),
+    GDD_FAIL_IN_SYNC_RUNNER_PUBLIC(2010),
+    GDD_FAIL_IN_SYNC_RUNNER_PRIVATE(2011),
+    GDD_PUBLIC_SYNC_SUCCESS(2012),
+    GDD_PRIVATE_SYNC_SUCCESS(2013),
+    GDD_FAIL_TO_RETRIEVE_ZWIEBACK_TOKEN(2014);
+
+    private final int code;
+
+    DownloadResultCode(int code) {
+      this.code = code;
+    }
+
+    /** Returns the int code corresponding to this enum value. */
+    public int getCode() {
+      return code;
+    }
+  }
+  // LINT.ThenChange(<internal>)
+
+  /** Builder for {@link DownloadException}. */
+  public static final class Builder {
+    private DownloadResultCode downloadResultCode;
+    private String message;
+    private Throwable cause;
+
+    /** Sets the {@link DownloadResultCode}. */
+    public Builder setDownloadResultCode(DownloadResultCode downloadResultCode) {
+      this.downloadResultCode = downloadResultCode;
+      return this;
+    }
+
+    /** Sets the error message. */
+    public Builder setMessage(String message) {
+      this.message = message;
+      return this;
+    }
+
+    /** Sets the cause of the exception. */
+    public Builder setCause(Throwable cause) {
+      this.cause = cause;
+      return this;
+    }
+
+    /** Returns a {@link DownloadException} instance. */
+    public DownloadException build() {
+      Preconditions.checkNotNull(downloadResultCode);
+      if (message == null) {
+        message = "Download result code: " + downloadResultCode.name();
+      }
+      return new DownloadException(this);
+    }
+  }
+
+  /** Returns a Builder for {@link DownloadException}. */
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  public DownloadResultCode getDownloadResultCode() {
+    return downloadResultCode;
+  }
+
+  /**
+   * Wraps the throwable with {@link DownloadException} and returns a failed future only if the
+   * input future fails.
+   */
+  public static <T> ListenableFuture<T> wrapIfFailed(
+      ListenableFuture<T> future, DownloadResultCode code, String message) {
+    return Futures.catchingAsync(
+        future,
+        Throwable.class,
+        (Throwable t) -> immediateFailedFuture(wrap(t, code, message)),
+        MoreExecutors.directExecutor());
+  }
+
+  /** Wraps the throwable with {@link DownloadException}. */
+  private static DownloadException wrap(
+      Throwable throwable, DownloadResultCode code, String message) {
+    return DownloadException.builder()
+        .setDownloadResultCode(code)
+        .setMessage(message)
+        .setCause(throwable)
+        .build();
+  }
+
+  private DownloadException(Builder builder) {
+    super(builder.message, builder.cause);
+    this.downloadResultCode = builder.downloadResultCode;
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/DownloadFileGroupRequest.java b/java/com/google/android/libraries/mobiledatadownload/DownloadFileGroupRequest.java
new file mode 100644
index 0000000..8b98527
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/DownloadFileGroupRequest.java
@@ -0,0 +1,159 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+import android.accounts.Account;
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Optional;
+import com.google.mobiledatadownload.DownloadConfigProto.DownloadConditions;
+import javax.annotation.concurrent.Immutable;
+
+/** Request to download file group in MDD. */
+@AutoValue
+@Immutable
+public abstract class DownloadFileGroupRequest {
+
+  /** Defines notifiction behavior for foreground download requests. */
+  // LINT.IfChange(show_notifications)
+  public enum ShowNotifications {
+    NONE,
+    ALL,
+  }
+  // LINT.ThenChange(<internal>)
+
+  DownloadFileGroupRequest() {}
+
+  public abstract String groupName();
+
+  public abstract Optional<Account> accountOptional();
+
+  public abstract Optional<String> variantIdOptional();
+
+  /**
+   * If present, title text to display in notification when using foreground downloads. Otherwise,
+   * the file group name will be used.
+   *
+   * <p>See <internal> for an example of the notification.
+   */
+  public abstract Optional<String> contentTitleOptional();
+
+  /**
+   * If present, content text to display in notification when using foreground downloads. Otherwise,
+   * the file group name will be used.
+   *
+   * <p>See <internal> for an example of the notification.
+   */
+  public abstract Optional<String> contentTextOptional();
+
+  /**
+   * The conditions for the download. If absent, MDD will use the download conditions from the
+   * server config.
+   */
+  public abstract Optional<DownloadConditions> downloadConditionsOptional();
+
+  /** If present, will receive download progress update. */
+  public abstract Optional<DownloadListener> listenerOptional();
+
+  // The size of the being downloaded file in bytes.
+  // This is used to display the progressbar.
+  // If not specified, an indeterminate progressbar will be displayed.
+  // https://developer.android.com/reference/android/app/Notification.Builder.html#setProgress(int,%20int,%20boolean)
+  public abstract int groupSizeBytes();
+
+  /**
+   * If {@link ShowNotifications.NONE}, will not create notifications for this foreground download
+   * request.
+   */
+  public abstract ShowNotifications showNotifications();
+
+  public abstract boolean preserveZipDirectories();
+
+  public static Builder newBuilder() {
+    return new AutoValue_DownloadFileGroupRequest.Builder()
+        .setGroupSizeBytes(0)
+        .setShowNotifications(ShowNotifications.ALL)
+        .setPreserveZipDirectories(false);
+  }
+
+  /** Builder for {@link DownloadFileGroupRequest}. */
+  @AutoValue.Builder
+  public abstract static class Builder {
+    Builder() {}
+
+    /** Sets the name of the file group. */
+    public abstract Builder setGroupName(String groupName);
+
+    /** Sets the optional account that is associated to the file group. */
+    public abstract Builder setAccountOptional(Optional<Account> accountOptional);
+
+    /**
+     * Sets the variant id that is associated to the file group.
+     *
+     * <p>This parameter is only required to download a group that was added to MDD with a variantId
+     * specified (see {@link AddFileGroupRequest.Builder#setVariantIdOptional}).
+     *
+     * <p>If a variantId was specified when adding the group to MDD and is not included here, the
+     * request will fail with a {@link DownloadException} and a GROUP_NOT_FOUND result code.
+     *
+     * <p>Similarly, if a variantId was <em>not</em> specified when adding the group to MDD and
+     * <em>is</em> included here, the request will also fail with the same exception.
+     */
+    public abstract Builder setVariantIdOptional(Optional<String> variantIdOptional);
+
+    /** Sets the optional title text for a notification when using foreground downloads. */
+    public abstract Builder setContentTitleOptional(Optional<String> contentTitleOptional);
+
+    /** Sets the optional content text for a notification when using foreground downloads. */
+    public abstract Builder setContentTextOptional(Optional<String> contentTextOptional);
+
+    /**
+     * Sets the optional download conditions. If absent, MDD will use the download conditions from
+     * the server config.
+     */
+    public abstract Builder setDownloadConditionsOptional(
+        Optional<DownloadConditions> downloadConditionsOptional);
+
+    /**
+     * Sets the optional download listener when using foreground downloads. If present, will receive
+     * download progress update.
+     */
+    public abstract Builder setListenerOptional(Optional<DownloadListener> listenerOptional);
+
+    /**
+     * Sets size of the being downloaded group in bytes when using foreground downloads. This is
+     * used to display the progressbar. If not specified, a indeterminate progressbar will be
+     * displayed.
+     * https://developer.android.com/reference/android/app/Notification.Builder.html#setProgress(int,%20int,%20boolean)
+     */
+    public abstract Builder setGroupSizeBytes(int groupSizeBytes);
+
+    /**
+     * Controls if notifications should be created for this download request when using foreground
+     * downloads. Defaults to true.
+     */
+    public abstract Builder setShowNotifications(ShowNotifications notifications);
+
+    /**
+     * By default, MDD will scan the directories generated by unpacking zip files in a download
+     * transform and generate a ClientDataFile for each contained file. By default, MDD also hides
+     * the root directory. Setting this to true disables that behavior, and will simply return the
+     * directories as ClientDataFiles.
+     */
+    public abstract Builder setPreserveZipDirectories(boolean preserve);
+
+    public abstract DownloadFileGroupRequest build();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/DownloadListener.java b/java/com/google/android/libraries/mobiledatadownload/DownloadListener.java
new file mode 100644
index 0000000..240406d
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/DownloadListener.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil;
+import com.google.mobiledatadownload.ClientConfigProto.ClientFileGroup;
+
+/**
+ * Supports registering for download progress update.
+ *
+ * <p>In general, don't do anything heavy on onProgress and onComplete since it is running o
+ */
+public interface DownloadListener {
+  String TAG = "DownloadListener";
+
+  /**
+   * Will be triggered periodically with the current downloaded size of the file group. This could
+   * be used to show progressbar to users.
+   *
+   * <p>The onProgress is run on MDD Download Executor. If you need to do heavy work, please offload
+   * to a background task.
+   */
+  // TODO(b/129464897): make onProgress run on control executor.
+  void onProgress(long currentSize);
+
+  /**
+   * This will be called when the download is completed. The clientFileGroup has data about the
+   * downloaded file group.
+   *
+   * <p>The onComplete is run on MDD Control Executor. If you need to do heavy work, please offload
+   * to a background task.
+   */
+  void onComplete(ClientFileGroup clientFileGroup);
+
+  /** This will be called when the download failed. */
+  default void onFailure(Throwable t) {
+    LogUtil.e(t, "%s: onFailure", TAG);
+  }
+
+  /**
+   * Callback triggered when all downloads are in a state waiting for connectivity, and no download
+   * progress is happening until connectivity resumes.
+   */
+  default void pausedForConnectivity() {
+    LogUtil.d("%s: pausedForConnectivity", TAG);
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/ExperimentationConfig.java b/java/com/google/android/libraries/mobiledatadownload/ExperimentationConfig.java
new file mode 100644
index 0000000..9b6148b
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/ExperimentationConfig.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Optional;
+import com.google.errorprone.annotations.CheckReturnValue;
+
+/** Configuration values for experimentation in MDD. */
+@CheckReturnValue
+@AutoValue
+public abstract class ExperimentationConfig {
+
+  /**
+   * Returns the log source to which download stage experiment IDS will be added as external
+   * experiment IDs.
+   */
+  public abstract Optional<String> getHostAppLogSource();
+
+  /**
+   * Returns the primes log source to which download stage experiment IDs will be added as external
+   * experiment IDs. This will allow slicing primes metrics to MDD rollouts.
+   */
+  public abstract Optional<String> getPrimesLogSource();
+
+  // TODO(b/201463803): add per-file-group overrides.
+
+  public static Builder builder() {
+    return new AutoValue_ExperimentationConfig.Builder();
+  }
+
+  /** Builder for ExperimentationConfig. */
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    /**
+     * Sets the host app log source. Download stage experiment ids will be added as external
+     * experiment ids to this log source.
+     *
+     * <p>Optional.
+     *
+     * @param hostAppLogSource the name of the host app log source.
+     */
+    public abstract Builder setHostAppLogSource(String hostAppLogSource);
+
+    /**
+     * Sets the host app primes log source. Download stage experiment ids will be added as external
+     * experiment ids to this log source. This will allow slicing primes metrics to MDD roll outs.
+     * See <internal> for more details.
+     *
+     * <p>Optional.
+     *
+     * @param primesLogSource the name of the primes log source
+     */
+    public abstract Builder setPrimesLogSource(String primesLogSource);
+
+    public abstract ExperimentationConfig build();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/FileGroupPopulator.java b/java/com/google/android/libraries/mobiledatadownload/FileGroupPopulator.java
new file mode 100644
index 0000000..1dccd87
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/FileGroupPopulator.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+import com.google.common.util.concurrent.ListenableFuture;
+
+/**
+ * Populates MDD with file groups from multiple sources like phenotype or built on the device.
+ *
+ * <p>Clients must overrides the refreshFileGroups method and add groups to {@link
+ * MobileDataDownload#addFileGroup(AddFileGroupRequest)} in the impl.
+ */
+public interface FileGroupPopulator {
+
+  /**
+   * Called periodically by MDD to refresh file groups.
+   *
+   * <p>This group should ideally be reading from a source like phenotype, so that mdd gets the
+   * updates from there on a regular basis.
+   */
+  ListenableFuture<Void> refreshFileGroups(MobileDataDownload mobileDataDownload);
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/FileSource.java b/java/com/google/android/libraries/mobiledatadownload/FileSource.java
new file mode 100644
index 0000000..c95bcca
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/FileSource.java
@@ -0,0 +1,136 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+import android.net.Uri;
+import com.google.protobuf.ByteString;
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.Immutable;
+
+/** Either a URI or a ByteString. */
+// TODO(b/219765048) use AutoOneOf once that's available in Android
+@Immutable
+public abstract class FileSource {
+  /** The possible types of source. */
+  public enum Kind {
+    BYTESTRING,
+    URI
+  }
+
+  /** The type of this source. */
+  public abstract Kind getKind();
+
+  public abstract ByteString byteString();
+
+  public abstract Uri uri();
+
+  /** Create a FileSource from a ByteString. */
+  public static FileSource ofByteString(ByteString byteString) {
+    if (byteString == null) {
+      throw new NullPointerException();
+    }
+    return new ImplByteString(byteString);
+  }
+
+  /** Create a FileSource from a URI. */
+  public static FileSource ofUri(Uri uri) {
+    if (uri == null) {
+      throw new NullPointerException();
+    }
+    return new ImplUri(uri);
+  }
+
+  // Parent class that each implementation will inherit from.
+  private abstract static class Parent extends FileSource {
+    @Override
+    public ByteString byteString() {
+      throw new UnsupportedOperationException(getKind().toString());
+    }
+
+    @Override
+    public Uri uri() {
+      throw new UnsupportedOperationException(getKind().toString());
+    }
+  }
+
+  // Implementation when the contained property is "byteString".
+  private static final class ImplByteString extends Parent {
+    private final ByteString byteString;
+
+    ImplByteString(ByteString byteString) {
+      this.byteString = byteString;
+    }
+
+    @Override
+    public ByteString byteString() {
+      return byteString;
+    }
+
+    @Override
+    public FileSource.Kind getKind() {
+      return FileSource.Kind.BYTESTRING;
+    }
+
+    @Override
+    public boolean equals(@Nullable Object x) {
+      if (x instanceof FileSource) {
+        FileSource that = (FileSource) x;
+        return this.getKind() == that.getKind() && this.byteString.equals(that.byteString());
+      } else {
+        return false;
+      }
+    }
+
+    @Override
+    public int hashCode() {
+      return byteString.hashCode();
+    }
+  }
+
+  // Implementation when the contained property is "uri".
+  private static final class ImplUri extends Parent {
+    private final Uri uri;
+
+    ImplUri(Uri uri) {
+      this.uri = uri;
+    }
+
+    @Override
+    public Uri uri() {
+      return uri;
+    }
+
+    @Override
+    public FileSource.Kind getKind() {
+      return FileSource.Kind.URI;
+    }
+
+    @Override
+    public boolean equals(@Nullable Object x) {
+      if (x instanceof FileSource) {
+        FileSource that = (FileSource) x;
+        return this.getKind() == that.getKind() && this.uri.equals(that.uri());
+      } else {
+        return false;
+      }
+    }
+
+    @Override
+    public int hashCode() {
+      return uri.hashCode();
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/Flags.java b/java/com/google/android/libraries/mobiledatadownload/Flags.java
new file mode 100644
index 0000000..6a5bead
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/Flags.java
@@ -0,0 +1,286 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+/**
+ * Responsible for configuring MDD.
+ *
+ * <p>All default implementations match default_value from GCL ignoring conditional_values, etc,
+ * unless noted otherwise.
+ */
+public interface Flags {
+  // LINT.IfChange
+
+  // FeatureFlags
+  default boolean clearStateOnMddDisabled() {
+    return false;
+  }
+
+  default boolean mddDeleteGroupsRemovedAccounts() {
+    return false;
+  }
+
+  default boolean broadcastNewlyDownloadedGroups() {
+    return true;
+  }
+
+  default boolean logFileGroupsWithFilesMissing() {
+    return true;
+  }
+
+  default boolean deleteFileGroupsWithFilesMissing() {
+    return true;
+  }
+
+  default boolean dumpMddInfo() {
+    return false;
+  }
+
+  default boolean enableDebugUi() {
+    return false;
+  }
+
+  default boolean enableClientErrorLogging() {
+    return false;
+  }
+
+  default int fileKeyVersion() {
+    return 2;
+  }
+
+  default boolean testOnlyFileKeyVersion() {
+    return false;
+  }
+
+  default boolean enableCompressedFile() {
+    return true;
+  }
+
+  default boolean enableZipFolder() {
+    return true;
+  }
+
+  default boolean enableDeltaDownload() {
+    return true;
+  }
+
+  default boolean enableMddGcmService() {
+    return true;
+  }
+
+  default boolean enableSilentFeedback() {
+    return true;
+  }
+
+  default boolean migrateToNewFileKey() {
+    return true;
+  }
+
+  default boolean migrateFileExpirationPolicy() {
+    return true;
+  }
+
+  default boolean downloadFirstOnWifiThenOnAnyNetwork() {
+    return true;
+  }
+
+  default boolean logStorageStats() {
+    return true;
+  }
+
+  default boolean logNetworkStats() {
+    return true;
+  }
+
+  default boolean removeGroupkeysWithDownloadedFieldNotSet() {
+    return true;
+  }
+
+  default boolean cacheLastLocation() {
+    return true;
+  }
+
+  default int locationCustomParamS2Level() {
+    return 10;
+  }
+
+  default int locationTaskTimeoutSec() {
+    return 5;
+  }
+
+  default boolean addConfigsFromPhenotype() {
+    return true;
+  }
+
+  default boolean enableMobileDataDownload() {
+    return true;
+  }
+
+  default int mddResetTrigger() {
+    return 0;
+  }
+
+  default boolean mddEnableDownloadPendingGroups() {
+    return true;
+  }
+
+  default boolean mddEnableVerifyPendingGroups() {
+    return true;
+  }
+
+  default boolean mddEnableGarbageCollection() {
+    return true;
+  }
+
+  default boolean mddDeleteUninstalledApps() {
+    return true;
+  }
+
+  default boolean enableMobstoreFileService() {
+    return true;
+  }
+
+  default boolean enableDelayedDownload() {
+    return true;
+  }
+
+  default boolean gcmRescheduleOnlyOncePerProcessStart() {
+    return true;
+  }
+
+  default boolean gmsMddSwitchToCronet() {
+    return false;
+  }
+
+  default boolean enableDaysSinceLastMaintenanceTracking() {
+    return true;
+  }
+
+  default boolean enableSideloading() {
+    return false;
+  }
+
+  default boolean enableDownloadStageExperimentIdPropagation() {
+    return false; // TODO(b/201463803): flip to true once rolled out.
+  }
+
+  // Controls verification of isolated symlink structures.
+  // By default, verification is ON. if this flag is set to false, verification will be turned OFF.
+  default boolean enableIsolatedStructureVerification() {
+    return true;
+  }
+
+  default boolean enableRngBasedDeviceStableSampling() {
+    return false; // TODO(b/144684763): Switch to true after fully rolled out.
+  }
+
+  // PeriodTaskFlags
+  default long maintenanceGcmTaskPeriod() {
+    return 86400;
+  }
+
+  default long chargingGcmTaskPeriod() {
+    return 21600;
+  }
+
+  default long cellularChargingGcmTaskPeriod() {
+    return 21600;
+  }
+
+  default long wifiChargingGcmTaskPeriod() {
+    return 21600;
+  }
+
+  // MddSampleIntervals
+  default int mddDefaultSampleInterval() {
+    return 100;
+  }
+
+  default int mddDownloadEventsSampleInterval() {
+    return 1;
+  }
+
+  default int groupStatsLoggingSampleInterval() {
+    return 100;
+  }
+
+  default int apiLoggingSampleInterval() {
+    return 100;
+  }
+
+  default int cleanupLogLoggingSampleInterval() {
+    return 1000;
+  }
+
+  default int silentFeedbackSampleInterval() {
+    return 100;
+  }
+
+  default int storageStatsLoggingSampleInterval() {
+    return 100;
+  }
+
+  default int networkStatsLoggingSampleInterval() {
+    return 100;
+  }
+
+  default int mobstoreFileServiceStatsSampleInterval() {
+    return 100;
+  }
+
+  default int mddAndroidSharingSampleInterval() {
+    return 100;
+  }
+
+  // DownloaderFlags
+  default boolean downloaderEnforceHttps() {
+    return true;
+  }
+
+  default boolean enforceLowStorageBehavior() {
+    return true;
+  }
+
+  default int absFreeSpaceAfterDownload() {
+    return 500 * 1024 * 1024; // 500mb
+  }
+
+  default int absFreeSpaceAfterDownloadLowStorageAllowed() {
+    return 100 * 1024 * 1024; // 100mb
+  }
+
+  default int absFreeSpaceAfterDownloadExtremelyLowStorageAllowed() {
+    return 2 * 1024 * 1024; // 2mb
+  }
+
+  default float fractionFreeSpaceAfterDownload() {
+    return 0.1F;
+  }
+
+  default int timeToWaitForDownloader() {
+    return 120000; // 2 minutes
+  }
+
+  default int downloaderMaxThreads() {
+    return 2;
+  }
+
+  default int downloaderMaxRetryOnChecksumMismatchCount() {
+    return 5;
+  }
+
+  // LINT.ThenChange(<internal>)
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/GetFileGroupRequest.java b/java/com/google/android/libraries/mobiledatadownload/GetFileGroupRequest.java
new file mode 100644
index 0000000..bf117d5
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/GetFileGroupRequest.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+import android.accounts.Account;
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Optional;
+import javax.annotation.concurrent.Immutable;
+
+/** Request to get a single file group. */
+@AutoValue
+@Immutable
+public abstract class GetFileGroupRequest {
+  GetFileGroupRequest() {}
+
+  public abstract String groupName();
+
+  public abstract Optional<Account> accountOptional();
+
+  public abstract Optional<String> variantIdOptional();
+
+  public abstract boolean preserveZipDirectories();
+
+  public static Builder newBuilder() {
+    return new AutoValue_GetFileGroupRequest.Builder().setPreserveZipDirectories(false);
+  }
+
+  /** Builder for {@link GetFileGroupRequest}. */
+  @AutoValue.Builder
+  public abstract static class Builder {
+    Builder() {}
+
+    /** Sets the name of the file group, which is required. */
+    public abstract Builder setGroupName(String groupName);
+
+    /** Sets the account that is associated to the file group, which is optional. */
+    public abstract Builder setAccountOptional(Optional<Account> accountOptional);
+
+    /** Sets the variant id associated with the group, which is optional. */
+    public abstract Builder setVariantIdOptional(Optional<String> variantIdOptional);
+
+    /**
+     * By default, MDD will scan the directories generated by unpacking zip files in a download
+     * transform and generate a ClientDataFile for each contained file. By default, MDD also hides
+     * the root directory. Setting this to true disables that behavior, and will simply return the
+     * directories as ClientDataFiles.
+     */
+    public abstract Builder setPreserveZipDirectories(boolean preserve);
+
+    public abstract GetFileGroupRequest build();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/GetFileGroupsByFilterRequest.java b/java/com/google/android/libraries/mobiledatadownload/GetFileGroupsByFilterRequest.java
new file mode 100644
index 0000000..504ddf7
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/GetFileGroupsByFilterRequest.java
@@ -0,0 +1,100 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import android.accounts.Account;
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Optional;
+import javax.annotation.concurrent.Immutable;
+
+/** Request to get multiple file groups after filtering. */
+@AutoValue
+@Immutable
+public abstract class GetFileGroupsByFilterRequest {
+  GetFileGroupsByFilterRequest() {}
+
+  // If this value is set to true, groupName should not be set.
+  public abstract boolean includeAllGroups();
+
+  // If this value is set to true, only groups without account will be returned, and accountOptional
+  // should be absent. The default value is false.
+  public abstract boolean groupWithNoAccountOnly();
+
+  public abstract Optional<String> groupNameOptional();
+
+  public abstract Optional<Account> accountOptional();
+
+  public abstract boolean preserveZipDirectories();
+
+  public static Builder newBuilder() {
+    return new AutoValue_GetFileGroupsByFilterRequest.Builder()
+        .setIncludeAllGroups(false)
+        .setGroupWithNoAccountOnly(false)
+        .setPreserveZipDirectories(false);
+  }
+
+  /** Builder for {@link GetFileGroupsByFilterRequest}. */
+  @AutoValue.Builder
+  public abstract static class Builder {
+    Builder() {}
+
+    /** Sets the flag whether all groups are included. */
+    public abstract Builder setIncludeAllGroups(boolean includeAllGroups);
+
+    /** Sets the flag whether to only return account independent groups. */
+    public abstract Builder setGroupWithNoAccountOnly(boolean groupWithNoAccountOnly);
+
+    /**
+     * Sets the name of the file group, which is optional. When groupNameOptional is absent, caller
+     * must set the includeAllGroups.
+     */
+    public abstract Builder setGroupNameOptional(Optional<String> groupNameOptional);
+
+    /** Sets the account that is associated with the file group, which is optional. */
+    public abstract Builder setAccountOptional(Optional<Account> accountOptional);
+
+    /**
+     * By default, MDD will scan the directories generated by unpacking zip files in a download
+     * transform and generate a ClientDataFile for each contained file. By default, MDD also hides
+     * the root directory. Setting this to true disables that behavior, and will simply return the
+     * directories as ClientDataFiles.
+     */
+    public abstract Builder setPreserveZipDirectories(boolean preserve);
+
+    abstract GetFileGroupsByFilterRequest autoBuild();
+
+    public final GetFileGroupsByFilterRequest build() {
+      GetFileGroupsByFilterRequest getFileGroupsByFilterRequest = autoBuild();
+
+      if (getFileGroupsByFilterRequest.includeAllGroups()) {
+        checkArgument(!getFileGroupsByFilterRequest.groupNameOptional().isPresent());
+        checkArgument(!getFileGroupsByFilterRequest.accountOptional().isPresent());
+      } else {
+        checkArgument(
+            getFileGroupsByFilterRequest.groupNameOptional().isPresent(),
+            "Request must provide a group name or source to filter by");
+      }
+
+      if (getFileGroupsByFilterRequest.groupWithNoAccountOnly()) {
+        checkArgument(!getFileGroupsByFilterRequest.accountOptional().isPresent());
+      }
+
+      return getFileGroupsByFilterRequest;
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/ImportFilesRequest.java b/java/com/google/android/libraries/mobiledatadownload/ImportFilesRequest.java
new file mode 100644
index 0000000..c6c9c36
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/ImportFilesRequest.java
@@ -0,0 +1,148 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+import android.accounts.Account;
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Optional;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.mobiledatadownload.DownloadConfigProto.DataFile;
+import com.google.protobuf.Any;
+import com.google.protobuf.ByteString;
+import javax.annotation.concurrent.Immutable;
+
+/** Request to import files into an existing DataFileGroup. */
+@AutoValue
+@Immutable
+public abstract class ImportFilesRequest {
+
+  ImportFilesRequest() {}
+
+  /** Name that identifies the file group to import files into. */
+  public abstract String groupName();
+
+  /** Build id that identifies the file group to import files into. */
+  public abstract long buildId();
+
+  /** Variant id that identifies the file group to import files into. */
+  public abstract String variantId();
+
+  /**
+   * Custom property that identifies the file group to import files into.
+   *
+   * <p>If a file group supports this field, it is required to identify the file group. In most
+   * cases, this field does not need to be included to identify the file group.
+   *
+   * <p>Contact <internal>@ if you think this field is required for your use-case.
+   */
+  public abstract Optional<Any> customPropertyOptional();
+
+  /** List of {@link DataFile}s to import into the existing file group. */
+  public abstract ImmutableList<DataFile> updatedDataFileList();
+
+  /**
+   * Map of inline file content that should be imported.
+   *
+   * <p>The Map is keyed by the {@link DataFile#fileId} that represents the file content and the
+   * values are the file content contained in a {@link ByteString}.
+   */
+  public abstract ImmutableMap<String, FileSource> inlineFileMap();
+
+  /** Account associated with the file group. */
+  public abstract Optional<Account> accountOptional();
+
+  public static Builder newBuilder() {
+    // Set updatedDataFileList as empty by default
+    return new AutoValue_ImportFilesRequest.Builder().setUpdatedDataFileList(ImmutableList.of());
+  }
+
+  /** Builder for {@link ImportFilesRequest}. */
+  @AutoValue.Builder
+  public abstract static class Builder {
+    Builder() {}
+
+    /**
+     * Sets the name of the file group to import files into.
+     *
+     * <p>This is required to identify the file group.
+     */
+    public abstract Builder setGroupName(String groupName);
+
+    /**
+     * Sets the build id of the file group to import files into.
+     *
+     * <p>This is required to identify the file group.
+     */
+    public abstract Builder setBuildId(long buildId);
+
+    /**
+     * Sets the variant id of the file group to import files into.
+     *
+     * <p>This is required to identify the file group.
+     */
+    public abstract Builder setVariantId(String variantId);
+
+    /**
+     * Sets the custom property of the file group to import files into.
+     *
+     * <p>This should only be provided if the file group supports this field. Most cases do not
+     * require this.
+     *
+     * <p>Contact <internal>@ if you think this field is required for your use-case.
+     */
+    public abstract Builder setCustomPropertyOptional(Optional<Any> customPropertyOptional);
+
+    /**
+     * Sets the List of inline DataFiles that should be updated in the file group.
+     *
+     * <p>This list can be included to update DataFiles in the existing file group identified by the
+     * other parameters in the request ({@link #groupName}, {@link #buildId}, and {@link
+     * #variantId}).
+     *
+     * <p>Files in this list are merged into the existing file group based on {@link
+     * DataFile#fileId}. That is:
+     *
+     * <ul>
+     *   <li>If a File exists with the same fileId, it is replaced by the File in this List
+     *   <li>If a File does not exist with the same fileId, it is added to the file group
+     * </ul>
+     *
+     * <p>This list is only required if inline files need to be added/updated in the existing file
+     * group. If the existing file group has inline files added with {@link
+     * MobileDataDownload#addFileGroup}, this list may be empty and the existing inline files that
+     * need to be imported can be included in {@link ImportFilesRequest#inlineFileMap}.
+     */
+    public abstract Builder setUpdatedDataFileList(ImmutableList<DataFile> updatedDataFileList);
+
+    /**
+     * Sets the map of inline file content to import.
+     *
+     * <p>The keys of this map should be fileIds of DataFiles that need to be imported. The values
+     * of the map should be FileSource.
+     *
+     * <p>NOTE: Key/Value pairs included in this map can references inline files already in the
+     * existing file group (added via {@link MobileDataDownload#addFileGroup}) or inline files
+     * included in the {@link ImportFilesRequest#updatedDataFileList}.
+     */
+    public abstract Builder setInlineFileMap(ImmutableMap<String, FileSource> inlineFileMap);
+
+    /** Sets the optional account that is associated with the file group. */
+    public abstract Builder setAccountOptional(Optional<Account> accountOptional);
+
+    public abstract ImportFilesRequest build();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/Logger.java b/java/com/google/android/libraries/mobiledatadownload/Logger.java
new file mode 100644
index 0000000..0f6f853
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/Logger.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+import com.google.protobuf.MessageLite;
+
+/** Responsible to log MDD events. */
+public interface Logger {
+
+  void log(MessageLite event, int eventCode);
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/MobileDataDownload.java b/java/com/google/android/libraries/mobiledatadownload/MobileDataDownload.java
new file mode 100644
index 0000000..688691e
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/MobileDataDownload.java
@@ -0,0 +1,410 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+import com.google.android.libraries.mobiledatadownload.TaskScheduler.ConstraintOverrides;
+import com.google.common.base.Optional;
+import com.google.common.collect.ImmutableList;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.errorprone.annotations.CheckReturnValue;
+import com.google.mobiledatadownload.ClientConfigProto.ClientFileGroup;
+import java.util.Map;
+
+/** The root object and entry point for the MobileDataDownload library. */
+public interface MobileDataDownload {
+
+  /**
+   * Adds for download the data file group in {@link AddFileGroupRequest}, after running validation
+   * on the group. This group will replace any previous version of this group once it is downloaded.
+   *
+   * <p>This api takes {@link AddFileGroupRequest} that contains data file group, and it can be used
+   * to set extra params such as account.
+   *
+   * <p>This doesn't start the download right away. The download starts later when the tasks
+   * scheduled via {@link #schedulePeriodicTasks} are run.
+   *
+   * <p>Calling this api with the exact same parameters multiple times is a no-op.
+   *
+   * @param addFileGroupRequest The request to add file group in MDD.
+   * @return ListenableFuture of true if the group was successfully added, or the group was already
+   *     present; ListenableFuture of false if the group is invalid or an I/O error occurs.
+   */
+  ListenableFuture<Boolean> addFileGroup(AddFileGroupRequest addFileGroupRequest);
+
+  /**
+   * Removes all versions of the data file group that matches {@link RemoveFileGroupRequest} from
+   * MDD. If no data file group matches, this call is a no-op.
+   *
+   * <p>This api takes {@link RemoveFileGroupRequest} that contains data file group, and it can be
+   * used to set extra params such as account.
+   *
+   * @param removeFileGroupRequest The request to remove file group from MDD.
+   * @return Listenable of true if the group was successfully removed, or no group matches;
+   *     Listenable of false if the matching group fails to be removed.
+   */
+  ListenableFuture<Boolean> removeFileGroup(RemoveFileGroupRequest removeFileGroupRequest);
+
+  /**
+   * Removes all versions of the data file groups that match {@link RemoveFileGroupsByFilterRequest}
+   * from MDD. If no data file group matches, this call is a no-op.
+   *
+   * <p>This api takes a {@link RemoveFileGroupsByFilterRequest} that contains optional filters for
+   * the group name, group source, associated account, etc.
+   *
+   * <p>A resolved future will only be returned if the removal completes successfully for all
+   * matching file groups. If any failures occur during this method, it will return a failed future
+   * with an {@link AggregateException} containing the failures that occurred.
+   *
+   * <p>NOTE: This only removes the metadata from MDD, not file content. Downloaded files that are
+   * no longer needed are deleted during MDD's daily maintenance task.
+   *
+   * @param removeFileGroupsByFilterRequest The request to remove file group from MDD.
+   * @return ListenableFuture that resolves with {@link RemoveFileGroupsByFilterResponse}, or fails
+   *     with {@link AggregateException}
+   */
+  ListenableFuture<RemoveFileGroupsByFilterResponse> removeFileGroupsByFilter(
+      RemoveFileGroupsByFilterRequest removeFileGroupsByFilterRequest);
+
+  /**
+   * Returns the latest downloaded data that we have for the given group name.
+   *
+   * <p>This api takes an instance of {@link GetFileGroupRequest} that contains group name, and it
+   * can be used to set extra params such as account.
+   *
+   * <p>This listenable future will return null if no group exists or has been downloaded for the
+   * given group name.
+   *
+   * @param getFileGroupRequest The request to get a single file group.
+   * @return The ListenableFuture of requested client file group for the given request.
+   */
+  ListenableFuture<ClientFileGroup> getFileGroup(GetFileGroupRequest getFileGroupRequest);
+
+  /**
+   * Returns all the data that we have for the given {@link GetFileGroupsByFilterRequest}.
+   *
+   * <p>This listenable future will return a list of file groups with their current download status.
+   *
+   * <p>Only present fields in {@link GetFileGroupsByFilterRequest} will be used to perform the
+   * filtering, i.e. when no account is specified in the filter, file groups won't be filtered based
+   * on account.
+   *
+   * @param getFileGroupsByFilterRequest The request to get multiple file groups after filtering.
+   * @return The ListenableFuture that will resolve to a list of the requested client file groups,
+   *     including pending and downloaded versions; this ListenableFuture will resolve to all client
+   *     file groups when {@code getFileGroupsByFilterRequest.includeAllGroups} is true.
+   */
+  ListenableFuture<ImmutableList<ClientFileGroup>> getFileGroupsByFilter(
+      GetFileGroupsByFilterRequest getFileGroupsByFilterRequest);
+
+  /**
+   * Imports Inline Files into an Existing MDD File Group.
+   *
+   * <p>This api takes a {@link ImportFilesRequest} containing identifying information about an
+   * existing File Group, an optional list of {@link DataFile}s to import into the existing File
+   * Group, and a Map of file content to import into MDD.
+   *
+   * <p>The identifying information is used to identify a file group and its specific version. This
+   * prevents the caller from accidentally importing files into the wrong file group or the wrong
+   * version of the file group. An optional {@link Account} parameter can also be specified if the
+   * existing file group was associated with an account.
+   *
+   * <p>The given {@link DataFile} list allows updated files (still compatible with a given file
+   * group version) to be imported into MDD. This API wll merge the given DataFiles into the
+   * existing file group in the following manner:
+   *
+   * <ul>
+   *   <li>DataFiles included in the DataFile list but not the existing file group will be added as
+   *       new DataFiles
+   *   <li>DataFiles included in the DataFile list will replace DataFiles in the existing file group
+   *       if their file Ids match.
+   *   <li>DataFiles included in the existing file group but not the DataFile list will remain
+   *       untouched.
+   * </ul>
+   *
+   * <p>{@link ImportFilesRequest} also requires a Map of file sources that should be imported by
+   * MDD. The Map is keyed by the fileIds of DataFiles and contains the contents of the file to
+   * import within a {@link ByteString}. This Map must contains an entry for all {@link DataFile}s
+   * which require an inline file source. Only "Inline" {@link DataFile}s should be included in this
+   * map (see details below).
+   *
+   * <p>An inline {@link DataFile} is the same as a standard {@link DataFile}, but instead of an
+   * "https" url, the url should match the following format:
+   *
+   * <pre>{@code "inlinefile:<key>"}</pre>
+   *
+   * <p>Where {@code key} is a unique identifier of the file. In most cases, the checksum should be
+   * used as this key. If the checksum is not used, another unique identifier should be used to
+   * allow proper deduping of the file import within MDD.
+   *
+   * <p>Example inline file url:
+   *
+   * <pre>{@code inlinefile:sha1:9a4ea3ca81d3f1d631531cbc216a62d9b10509ee}</pre>
+   *
+   * <p>NOTE: Inline files can be specified by the given DataFile list in {@link
+   * ImportFilesRequest}, but can also be specified by a {@link DataFileGroup} added via {@link
+   * #addFileGroup}. A File Group that contains inline files will not be considered DOWNLOADED until
+   * all inline files are imported via this API.
+   *
+   * <p>Because this method performs an update to the stored File Group metadata, the given {@link
+   * ImportFilesRequest} must satisfy the following conditions:
+   *
+   * <ul>
+   *   <li>The requests identifying information must match an existing File Group
+   *   <li>All inline DataFiles must have file content specified in the request's Inline File Map
+   * </ul>
+   *
+   * <p>If either of these conditions is not met, this operation will return a failed
+   * ListenableFuture.
+   *
+   * <p>Finally, this API is a atomic operation. That is, <em>either all inline files will be
+   * imported successfully or none will be imported</em>. If there is a failure with importing a
+   * file, MDD will not update the file group (i.e. future calls to {@link #getFileGroup} will
+   * return the same {@link ClientFileGroup} as before this call).
+   *
+   * @param importFilesRequest Request containing required parameters to perform import files
+   *     operation.
+   * @return ListenableFuture that resolves when all inline files are successfully imported.
+   */
+  ListenableFuture<Void> importFiles(ImportFilesRequest importFilesRequest);
+
+  /**
+   * Downloads a single file.
+   *
+   * <p>This api takes {@link SingleFileDownloadRequest}, which contains a download url of the file
+   * to download. the destination location on device must also be specified. See
+   * SingleFileDownloadRequest for full list of required/optional parameters.
+   *
+   * <p>The returned ListenableFuture will fail if there is an error during the download. The caller
+   * is responsible for calling downloadFile again to restart the download.
+   *
+   * <p>The caller can be notified of progress by providing a {@link SingleFileDownloadListener}.
+   * This listener will also provide callbacks for a completed download, failed download, or paused
+   * download due to connectivity loss.
+   *
+   * <p>The caller can specify constraints that should be used for the download by providing a
+   * {@link com.google.android.libraries.mobiledatadownload.downloader.DownloadConstraints}. This
+   * allows downloads to only start when on Wifi, for example. By default, no constraints are
+   * specified.
+   *
+   * @param singleFileDownloadRequest The request to download a file.
+   * @return ListenableFuture that resolves when file is downloaded.
+   */
+  @CheckReturnValue
+  ListenableFuture<Void> downloadFile(SingleFileDownloadRequest singleFileDownloadRequest);
+
+  /**
+   * Downloads and returns the latest downloaded data that we have for the given group name.
+   *
+   * <p>This api takes {@link DownloadFileGroupRequest} that contains group name, and it can be used
+   * to set extra params such as account, download conditions, and download listener.
+   *
+   * <p>The group name must be added using {@link #addFileGroup} before downloading the file group.
+   *
+   * <p>The returned ListenableFuture will be resolved when the file group is downloaded. It can
+   * also be used to cancel the download.
+   *
+   * <p>The returned ListenableFuture would fail if there is any error during the download. Client
+   * is responsible to call the downloadFileGroup to resume the download.
+   *
+   * <p>Download progress is supported through the DownloadListener.
+   *
+   * <p>To download under any conditions, clients should use {@link
+   * Constants.NO_RESTRICTIONS_DOWNLOAD_CONDITIONS}
+   *
+   * @param downloadFileGroupRequest The request to download file group.
+   */
+  // TODO: Handle the case where a client calls this API for the same group when the
+  //  earlier call has not finished.
+  ListenableFuture<ClientFileGroup> downloadFileGroup(
+      DownloadFileGroupRequest downloadFileGroupRequest);
+
+  /**
+   * Downloads a file using a foreground service and notification.
+   *
+   * <p>This is similar to {@link #downloadFile}, but allows the download to continue running when
+   * the app enters the background.
+   *
+   * <p>The notification created for the download includes a cancel action. This will allow the
+   * download to be cancelled even when the app is in the background.
+   *
+   * <p>The cancel action in the notification menu requires the ForegroundService to be registered
+   * with the application (via the AndroidManifest.xml). This allows the cancellation intents to be
+   * properly picked up. To register the service, the following lines must be included in the app's
+   * {@code AndroidManifest.xml}:
+   *
+   * <pre>{@code
+   * <!-- Needed by foreground download service -->
+   * <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
+   *
+   * <!-- Service for MDD foreground downloads -->
+   * <service
+   *   android:name="com.google.android.libraries.mobiledatadownload.foreground.sting.ForegroundDownloadService"
+   *   android:exported="false" />
+   * }</pre>
+   *
+   * <p>NOTE: The above excerpt is for Framework and Sting apps. Dagger apps should use the same
+   * excerpt, but change the {@code android:name} property to:
+   *
+   * <pre>{@code
+   * android:name="com.google.android.libraries.mobiledatadownload.foreground.dagger.ForegroundDownloadService"
+   * }</pre>
+   */
+  @CheckReturnValue
+  ListenableFuture<Void> downloadFileWithForegroundService(
+      SingleFileDownloadRequest singleFileDownloadRequest);
+
+  /**
+   * Download a file group and show foreground download progress in a notification. User can cancel
+   * the download from the notification menu.
+   *
+   * <p>The cancel action in the notification menu requires the ForegroundService to be registered
+   * with the application (via the AndroidManifest.xml). This allows the cancellation intents to be
+   * properly picked up. To register the service, the following lines must be included in the app's
+   * {@code AndroidManifest.xml}:
+   *
+   * <pre>{@code
+   * <!-- Needed by foreground download service -->
+   * <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
+   *
+   * <!-- Service for MDD foreground downloads -->
+   * <service
+   *   android:name="com.google.android.libraries.mobiledatadownload.foreground.sting.ForegroundDownloadService"
+   *   android:exported="false" />
+   * }</pre>
+   *
+   * <p>NOTE: The above excerpt is for Framework and Sting apps. Dagger apps should use the same
+   * excerpt, but change the {@code android:name} property to:
+   *
+   * <pre>{@code
+   * android:name="com.google.android.libraries.mobiledatadownload.foreground.dagger.ForegroundDownloadService"
+   * }</pre>
+   */
+  ListenableFuture<ClientFileGroup> downloadFileGroupWithForegroundService(
+      DownloadFileGroupRequest downloadFileGroupRequest);
+
+  /**
+   * Cancel an on-going foreground download.
+   *
+   * <p>Attempts to cancel an on-going foreground download using best effort. If download is unknown
+   * to MDD, this operation is a noop.
+   *
+   * <p>If the download was started with {@link
+   * #downloadFileGroupWithForegroundService(DownloadFileGroupRequest)}, the specific {@code
+   * downloadKey} must be the group name of the file group.
+   *
+   * <p>If the download was started with {@link
+   * #downloadFileWithForegroundService(SingleFileDownloadRequest)}, the specific {@code
+   * downloadKey} must be the destination file uri (in string form).
+   *
+   * @param downloadKey the key associated with the download
+   */
+  void cancelForegroundDownload(String downloadKey);
+
+  /**
+   * Triggers the execution of MDD maintenance.
+   *
+   * <p>MDD needs to run maintenance task once a day. If you call {@link
+   * #schedulePeriodicBackgroundTasks} api, the maintenance will be called automatically. In case
+   * you don't want to schedule MDD tasks, you can call this maintenance method directly.
+   *
+   * <p>If you do need to call this api, make sure that this api is called exactly once every day.
+   *
+   * <p>The returned ListenableFuture would fail if the maintenance execution doesn't succeed.
+   */
+  ListenableFuture<Void> maintenance();
+
+  /**
+   * Schedule periodic tasks that will download and verify all file groups when the required
+   * conditions are met, using the given {@link TaskScheduler}.
+   *
+   * <p>If the host app doesn't provide a TaskScheduler, calling this API will be a no-op.
+   *
+   * @deprecated Use the {@link schedulePeriodicBackgroundTasks} instead.
+   */
+  @Deprecated
+  void schedulePeriodicTasks();
+
+  /**
+   * Schedule periodic background tasks that will download and verify all file groups when the
+   * required conditions are met, using the given {@link TaskScheduler}.
+   *
+   * <p>If the host app doesn't provide a TaskScheduler, calling this API will be a no-op.
+   */
+  ListenableFuture<Void> schedulePeriodicBackgroundTasks();
+
+  /**
+   * Schedule periodic background tasks that will download and verify all file groups when the
+   * required conditions are met, using the given {@link TaskScheduler}.
+   *
+   * <p>If the host app doesn't provide a TaskScheduler, calling this API will be a no-op.
+   *
+   * @param constraintOverridesMap to allow clients to override constraints requirements.
+   *     <p><code>{@code
+   *  ConstraintOverrides wifiOverrides =
+   *     ConstraintOverrides.newBuilder()
+   *         .setRequiresCharging(false)
+   *         .setRequiresDeviceIdle(true)
+   *         .build();
+   * ConstraintOverrides cellularOverrides =
+   *     ConstraintOverrides.newBuilder()
+   *         .setRequiresCharging(true)
+   *         .setRequiresDeviceIdle(false)
+   *         .build();
+   *
+   *  Map<String, ConstraintOverrides> constraintOverridesMap = new HashMap<>();
+   *  constraintOverridesMap.put(TaskScheduler.WIFI_CHARGING_PERIODIC_TASK, wifiOverrides);
+   *  constraintOverridesMap.put(TaskScheduler.CELLULAR_CHARGING_PERIODIC_TASK, cellularOverrides);
+   *
+   *  mobileDataDownload.schedulePeriodicBackgroundTasks(Optional.of(constraintOverridesMap)).get();
+   * }</code>
+   */
+  ListenableFuture<Void> schedulePeriodicBackgroundTasks(
+      Optional<Map<String, ConstraintOverrides>> constraintOverridesMap);
+
+  /**
+   * Handle a task scheduled via a task scheduling service.
+   *
+   * <p>This method should not be called on the main thread, as it does work on the thread it is
+   * called on.
+   *
+   * @return a listenable future which indicates when any async task scheduled is complete.
+   */
+  ListenableFuture<Void> handleTask(String tag);
+
+  /** Clear MDD metadata and its managed files. MDD will be reset to a clean state. */
+  ListenableFuture<Void> clear();
+
+  /**
+   * Return MDD debug info as a string. This could return some PII information so it's not
+   * recommended to be called in production build.
+   *
+   * <p>This debug info string could be very long. In order to print them in adb logcat, we have to
+   * split the string. See how it is done in our sample app: <internal>
+   */
+  String getDebugInfoAsString();
+
+  /**
+   * Reports usage of a file group back to MDD. This can be used to track errors with file group
+   * roll outs. Each usage of the file group should result in a single call of this method in order
+   * to allow for accurate metrics server side.
+   *
+   * @param usageEvent that will be logged.
+   * @return a listenable future which indicates that the UsageEvent has been logged.
+   */
+  @CanIgnoreReturnValue
+  ListenableFuture<Void> reportUsage(UsageEvent usageEvent);
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/MobileDataDownloadBuilder.java b/java/com/google/android/libraries/mobiledatadownload/MobileDataDownloadBuilder.java
new file mode 100644
index 0000000..5cfb0eb
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/MobileDataDownloadBuilder.java
@@ -0,0 +1,385 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+import android.content.Context;
+import com.google.android.libraries.mobiledatadownload.account.AccountManagerAccountSource;
+import com.google.android.libraries.mobiledatadownload.delta.DeltaDecoder;
+import com.google.android.libraries.mobiledatadownload.downloader.FileDownloader;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.internal.dagger.ApplicationContextModule;
+import com.google.android.libraries.mobiledatadownload.internal.dagger.DaggerStandaloneComponent;
+import com.google.android.libraries.mobiledatadownload.internal.dagger.DownloaderModule;
+import com.google.android.libraries.mobiledatadownload.internal.dagger.ExecutorsModule;
+import com.google.android.libraries.mobiledatadownload.internal.dagger.MainMddLibModule;
+import com.google.android.libraries.mobiledatadownload.internal.dagger.StandaloneComponent;
+import com.google.android.libraries.mobiledatadownload.internal.logging.EventLogger;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LogSampler;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil;
+import com.google.android.libraries.mobiledatadownload.internal.logging.MddEventLogger;
+import com.google.android.libraries.mobiledatadownload.internal.logging.NoOpEventLogger;
+import com.google.android.libraries.mobiledatadownload.lite.Downloader;
+import com.google.android.libraries.mobiledatadownload.monitor.DownloadProgressMonitor;
+import com.google.android.libraries.mobiledatadownload.monitor.NetworkUsageMonitor;
+import com.google.common.base.Optional;
+import com.google.common.base.Preconditions;
+import com.google.common.base.Supplier;
+import com.google.common.collect.ImmutableList;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+import java.security.SecureRandom;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Executor;
+
+/**
+ * A Builder for the {@link MobileDataDownload}.
+ *
+ * <p>WARNING: Only one object should be built. Otherwise, there may be locking errors on the
+ * underlying database and unnecessary memory consumption.
+ *
+ * <p>Furthermore, there may be interference between scheduled task.
+ */
+public final class MobileDataDownloadBuilder {
+  private static final String TAG = "MobileDataDownloadBuilder";
+
+  private final DaggerStandaloneComponent.Builder componentBuilder;
+
+  private Context context;
+  private ListeningExecutorService controlExecutor;
+  private final List<FileGroupPopulator> fileGroupPopulatorList = new ArrayList<>();
+  private Optional<TaskScheduler> taskSchedulerOptional = Optional.absent();
+  private SynchronousFileStorage fileStorage;
+  private NetworkUsageMonitor networkUsageMonitor;
+  private Optional<DownloadProgressMonitor> downloadMonitorOptional = Optional.absent();
+  private Supplier<FileDownloader> fileDownloaderSupplier;
+  private Optional<DeltaDecoder> deltaDecoderOptional = Optional.absent();
+  private Optional<Configurator> configurator = Optional.absent();
+  private Optional<Logger> loggerOptional = Optional.absent();
+  private Optional<SilentFeedback> silentFeedbackOptional = Optional.absent();
+  private Optional<String> instanceIdOptional = Optional.absent();
+  private Optional<Class<?>> foregroundDownloadServiceClassOptional = Optional.absent();
+  private Optional<Flags> flagsOptional = Optional.absent();
+  private Optional<AccountSource> accountSourceOptional = Optional.absent();
+  private boolean useDefaultAccountSource = true;
+  private Optional<CustomFileGroupValidator> customFileGroupValidatorOptional = Optional.absent();
+  private Optional<ExperimentationConfig> experimentationConfigOptional = Optional.absent();
+
+  public static MobileDataDownloadBuilder newBuilder() {
+    return new MobileDataDownloadBuilder();
+  }
+
+  private MobileDataDownloadBuilder() {
+    componentBuilder = DaggerStandaloneComponent.builder();
+  }
+
+  public MobileDataDownloadBuilder setContext(Context context) {
+    this.context = context.getApplicationContext();
+    return this;
+  }
+
+  /**
+   * Set Unique Instance ID of this instance of MobileDataDownload. Instance ID must be non-empty
+   * and [a-z] (lower case).
+   *
+   * <p>Most apps should use @Singleton MDD. If an app wants to use multiple instances of MDD,
+   * please be aware of following caveats: Each instance of MDD will have its own metadata, base
+   * directory, and periodic backbround tasks. There is no sharing and no-dedup between instances.
+   * Please talk to <internal>@ before using this.
+   */
+  public MobileDataDownloadBuilder setInstanceIdOptional(Optional<String> instanceIdOptional) {
+    this.instanceIdOptional = instanceIdOptional;
+    return this;
+  }
+
+  /**
+   * Set the Control Executor which will manage MDD meta data.
+   *
+   * <p>NOTE: Control Executor must not be single thread executor otherwise it could lead to
+   * deadlock or other side effects.
+   */
+  public MobileDataDownloadBuilder setControlExecutor(ListeningExecutorService controlExecutor) {
+    Preconditions.checkNotNull(controlExecutor);
+    // Executor that will execute tasks sequentially.
+    this.controlExecutor = controlExecutor;
+    return this;
+  }
+
+  /**
+   * Sets a config populator that will be used by MDD to periodically refresh the data file groups.
+   *
+   * <p>If this is not set, then the client is responsible for refreshing the list of file groups in
+   * MDD as and when they see fit.
+   */
+  public MobileDataDownloadBuilder addFileGroupPopulator(FileGroupPopulator fileGroupPopulator) {
+    this.fileGroupPopulatorList.add(fileGroupPopulator);
+    return this;
+  }
+
+  /**
+   * Add a list of config populator that will be used by MDD to periodically refresh the data file
+   * groups.
+   *
+   * <p>If this is not set, then the client is responsible for refreshing the list of file groups in
+   * MDD as and when they see fit.
+   */
+  public MobileDataDownloadBuilder addFileGroupPopulators(
+      ImmutableList<FileGroupPopulator> fileGroupPopulators) {
+    this.fileGroupPopulatorList.addAll(fileGroupPopulators);
+    return this;
+  }
+
+  /**
+   * Set the task scheduler that will be used by MDD to schedule periodic and one-off tasks. Clients
+   * can use GCM, FJD or Work Manager to schedule tasks, and then forward the notification to {@link
+   * MobileDataDownload#handleTask(String)}.
+   */
+  public MobileDataDownloadBuilder setTaskScheduler(Optional<TaskScheduler> taskSchedulerOptional) {
+    this.taskSchedulerOptional = taskSchedulerOptional;
+    return this;
+  }
+
+  /** Set the optional Configurator which if present will be used by MDD to configure its flags. */
+  public MobileDataDownloadBuilder setConfiguratorOptional(Optional<Configurator> configurator) {
+    this.configurator = configurator;
+    return this;
+  }
+
+  /** Set the optional Logger which if present will be used by MDD to log events. */
+  public MobileDataDownloadBuilder setLoggerOptional(Optional<Logger> logger) {
+    this.loggerOptional = logger;
+    return this;
+  }
+
+  /** Set the flags otherwise default values will be used only. */
+  public MobileDataDownloadBuilder setFlagsOptional(Optional<Flags> flags) {
+    this.flagsOptional = flags;
+    return this;
+  }
+
+  /**
+   * Set the optional SilentFeedback which if present will be used by MDD to send silent feedbacks.
+   */
+  public MobileDataDownloadBuilder setSilentFeedbackOptional(
+      Optional<SilentFeedback> silentFeedbackOptional) {
+    this.silentFeedbackOptional = silentFeedbackOptional;
+    return this;
+  }
+
+  /**
+   * Set the MobStore SynchronousFileStorage. Ideally this should be the same object as the one used
+   * by the client app to read files from MDD
+   */
+  public MobileDataDownloadBuilder setFileStorage(SynchronousFileStorage fileStorage) {
+    this.fileStorage = fileStorage;
+    return this;
+  }
+
+  /**
+   * Set the NetworkUsageMonitor. This NetworkUsageMonitor instance must be the same instance that
+   * is registered with SynchronousFileStorage.
+   */
+  public MobileDataDownloadBuilder setNetworkUsageMonitor(NetworkUsageMonitor networkUsageMonitor) {
+    this.networkUsageMonitor = networkUsageMonitor;
+    return this;
+  }
+
+  /**
+   * Set the DownloadProgressMonitor. This DownloadProgressMonitor instance must be the same
+   * instance that is registered with SynchronousFileStorage.
+   */
+  public MobileDataDownloadBuilder setDownloadMonitorOptional(
+      Optional<DownloadProgressMonitor> downloadMonitorOptional) {
+    this.downloadMonitorOptional = downloadMonitorOptional;
+    return this;
+  }
+
+  /**
+   * Set the FileDownloader Supplier. MDD takes in a Supplier of FileDownload to support lazy
+   * instantiation of the FileDownloader
+   */
+  public MobileDataDownloadBuilder setFileDownloaderSupplier(
+      Supplier<FileDownloader> fileDownloaderSupplier) {
+    this.fileDownloaderSupplier = fileDownloaderSupplier;
+    return this;
+  }
+
+  /** Set the Delta file decoder. */
+  public MobileDataDownloadBuilder setDeltaDecoderOptional(
+      Optional<DeltaDecoder> deltaDecoderOptional) {
+    this.deltaDecoderOptional = deltaDecoderOptional;
+    return this;
+  }
+
+  /**
+   * Set the Foreground Download Service. This foreground service will keep the download alive even
+   * if the user navigates away from the host app. This ensures long download can finish.
+   *
+   * <p>If the host needs to use both MDDLite and Full MDD, the Foreground Download Service can be
+   * shared as an optimization. Please talk to <internal>@ on how to setup a shared Foreground
+   * Download Service.
+   */
+  public MobileDataDownloadBuilder setForegroundDownloadServiceOptional(
+      Optional<Class<?>> foregroundDownloadServiceClass) {
+    this.foregroundDownloadServiceClassOptional = foregroundDownloadServiceClass;
+    return this;
+  }
+
+  /**
+   * Sets the AccountSource that's used to wipeout account-related data at maintenance time. If this
+   * method is not called, an account source based on AccountManager will be injected.
+   */
+  public MobileDataDownloadBuilder setAccountSourceOptional(
+      Optional<AccountSource> accountSourceOptional) {
+    this.accountSourceOptional = accountSourceOptional;
+    useDefaultAccountSource = false;
+    return this;
+  }
+
+  public MobileDataDownloadBuilder setCustomFileGroupValidatorOptional(
+      Optional<CustomFileGroupValidator> customFileGroupValidatorOptional) {
+    this.customFileGroupValidatorOptional = customFileGroupValidatorOptional;
+    return this;
+  }
+
+  /**
+   * Sets the ExperimentationConfig that's used when propagating experiment ids to external log
+   * sources. If this is not called, experiment ids are not propagated. See <internal> for more
+   * details.
+   */
+  public MobileDataDownloadBuilder setExperimentationConfigOptional(
+      Optional<ExperimentationConfig> experimentationConfigOptional) {
+    this.experimentationConfigOptional = experimentationConfigOptional;
+    return this;
+  }
+
+  // We use java.util.concurrent.Executor directly to create default Control Executor and
+  // Download Executor.
+  public MobileDataDownload build() {
+    Preconditions.checkNotNull(context);
+    Preconditions.checkNotNull(taskSchedulerOptional);
+    Preconditions.checkNotNull(fileStorage);
+
+    Preconditions.checkNotNull(networkUsageMonitor);
+    Preconditions.checkNotNull(downloadMonitorOptional);
+    Preconditions.checkNotNull(fileDownloaderSupplier);
+    Preconditions.checkNotNull(customFileGroupValidatorOptional);
+
+    Executor sequentialControlExecutor = MoreExecutors.newSequentialExecutor(controlExecutor);
+    if (configurator.isPresent()) {
+      // Submit commit task to sequentialControlExecutor to ensure that the commit task finishes
+      // before any other API tasks can run.
+      ListenableFuture<Void> commitFuture =
+          Futures.submitAsync(
+              () -> configurator.get().commitToFlagSnapshot(), sequentialControlExecutor);
+
+      Futures.addCallback(
+          commitFuture,
+          new FutureCallback<Void>() {
+            @Override
+            public void onSuccess(Void result) {
+              LogUtil.d("%s: Succeeded commitToFlagSnapshot.", TAG);
+            }
+
+            @Override
+            public void onFailure(Throwable t) {
+              LogUtil.w("%s: Failed to commitToFlagSnapshot: %s", TAG, t);
+            }
+          },
+          MoreExecutors.directExecutor() /*fine to use directExecutor since it only print logs*/);
+    }
+
+    componentBuilder.applicationContextModule(new ApplicationContextModule(context));
+
+    componentBuilder.executorsModule(new ExecutorsModule(sequentialControlExecutor));
+
+    componentBuilder.downloaderModule(
+        new DownloaderModule(deltaDecoderOptional, fileDownloaderSupplier));
+
+    Flags flags = flagsOptional.or(new Flags() {});
+
+    // EventLogger is needed in FrameworkProtoDataStoreModule, which is a sting module. As such it
+    // cannot be constructed in our internal dagger module if we want to share the same EventLogger
+    // throughout the library.
+    final EventLogger eventLogger;
+    if (loggerOptional.isPresent()) {
+      eventLogger =
+          new MddEventLogger(
+              context,
+              loggerOptional.get(),
+              Constants.MDD_LIB_VERSION,
+              new LogSampler(flags, new SecureRandom()),
+              flags);
+    } else {
+      eventLogger = new NoOpEventLogger();
+    }
+
+    if (useDefaultAccountSource) {
+      accountSourceOptional = Optional.of(new AccountManagerAccountSource(context));
+    }
+
+    componentBuilder.mainMddLibModule(
+        new MainMddLibModule(
+            fileStorage,
+            networkUsageMonitor,
+            eventLogger,
+            downloadMonitorOptional,
+            silentFeedbackOptional,
+            instanceIdOptional,
+            accountSourceOptional,
+            flags,
+            experimentationConfigOptional));
+
+    StandaloneComponent component = componentBuilder.build();
+
+    if (eventLogger instanceof MddEventLogger) {
+      ((MddEventLogger) eventLogger).setLoggingStateStore(component.getLoggingStateStore());
+    }
+
+    Downloader.Builder singleFileDownloaderBuilder =
+        Downloader.newBuilder()
+            .setContext(context)
+            .setControlExecutor(sequentialControlExecutor)
+            .setFileDownloaderSupplier(fileDownloaderSupplier);
+
+    if (downloadMonitorOptional.isPresent()) {
+      singleFileDownloaderBuilder.setDownloadMonitor(downloadMonitorOptional.get());
+    }
+
+    if (foregroundDownloadServiceClassOptional.isPresent()) {
+      singleFileDownloaderBuilder.setForegroundDownloadService(
+          foregroundDownloadServiceClassOptional.get());
+    }
+    Downloader singleFileDownloader = singleFileDownloaderBuilder.build();
+
+    return new MobileDataDownloadImpl(
+        context,
+        component.getEventLogger(),
+        component.getMobileDataDownloadManager(),
+        sequentialControlExecutor,
+        fileGroupPopulatorList,
+        taskSchedulerOptional,
+        fileStorage,
+        downloadMonitorOptional,
+        foregroundDownloadServiceClassOptional,
+        flags,
+        singleFileDownloader,
+        customFileGroupValidatorOptional);
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/MobileDataDownloadImpl.java b/java/com/google/android/libraries/mobiledatadownload/MobileDataDownloadImpl.java
new file mode 100644
index 0000000..4201b19
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/MobileDataDownloadImpl.java
@@ -0,0 +1,1312 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+import static com.google.android.libraries.mobiledatadownload.tracing.TracePropagation.propagateAsyncCallable;
+import static com.google.android.libraries.mobiledatadownload.tracing.TracePropagation.propagateAsyncFunction;
+import static com.google.android.libraries.mobiledatadownload.tracing.TracePropagation.propagateCallable;
+
+import android.accounts.Account;
+import android.content.Context;
+import android.net.Uri;
+import android.text.TextUtils;
+import android.util.Pair;
+import androidx.annotation.VisibleForTesting;
+import androidx.core.app.NotificationCompat;
+import androidx.core.app.NotificationManagerCompat;
+import com.google.android.libraries.mobiledatadownload.DownloadException.DownloadResultCode;
+import com.google.android.libraries.mobiledatadownload.TaskScheduler.ConstraintOverrides;
+import com.google.android.libraries.mobiledatadownload.TaskScheduler.NetworkState;
+import com.google.android.libraries.mobiledatadownload.account.AccountUtil;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.foreground.NotificationUtil;
+import com.google.android.libraries.mobiledatadownload.internal.MobileDataDownloadManager;
+import com.google.android.libraries.mobiledatadownload.internal.logging.EventLogger;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil;
+import com.google.android.libraries.mobiledatadownload.internal.util.MddLiteConversionUtil;
+import com.google.android.libraries.mobiledatadownload.internal.util.ProtoConversionUtil;
+import com.google.android.libraries.mobiledatadownload.lite.Downloader;
+import com.google.android.libraries.mobiledatadownload.monitor.DownloadProgressMonitor;
+import com.google.common.base.Optional;
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.util.concurrent.AsyncFunction;
+import com.google.common.util.concurrent.ExecutionSequencer;
+import com.google.common.util.concurrent.FluentFuture;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.mobiledatadownload.ClientConfigProto.ClientFile;
+import com.google.mobiledatadownload.ClientConfigProto.ClientFileGroup;
+import com.google.mobiledatadownload.DownloadConfigProto;
+import com.google.mobiledatadownload.DownloadConfigProto.DataFileGroup;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFile;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal;
+import com.google.mobiledatadownload.internal.MetadataProto.DownloadConditions;
+import com.google.mobiledatadownload.internal.MetadataProto.GroupKey;
+import com.google.protobuf.Any;
+import com.google.protobuf.GeneratedMessageLite;
+import com.google.protobuf.InvalidProtocolBufferException;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import javax.annotation.Nullable;
+/**
+ * Default implementation for {@link
+ * com.google.android.libraries.mobiledatadownload.MobileDataDownload}.
+ */
+class MobileDataDownloadImpl implements MobileDataDownload {
+  private static final String TAG = "MobileDataDownload";
+  private static final long DUMP_DEBUG_INFO_TIMEOUT = 3;
+
+  private final Context context;
+  private final EventLogger eventLogger;
+  private final List<FileGroupPopulator> fileGroupPopulatorList;
+  private final Optional<TaskScheduler> taskSchedulerOptional;
+  private final MobileDataDownloadManager mobileDataDownloadManager;
+  private final SynchronousFileStorage fileStorage;
+  private final Flags flags;
+  private final Downloader singleFileDownloader;
+
+  // This executor will execute tasks sequentially.
+  private final Executor sequentialControlExecutor;
+  // ExecutionSequencer will execute a ListenableFuture and its Futures.transforms before taking the
+  // next task (<internal>). Most of MDD API should use
+  // ExecutionSequencer to guarantee Metadata synchronization. Currently only downloadFileGroup and
+  // handleTask APIs do not use ExecutionSequencer since their execution could take long time and
+  // using ExecutionSequencer would block other APIs.
+  private final ExecutionSequencer futureSerializer = ExecutionSequencer.create();
+  private final Optional<DownloadProgressMonitor> downloadMonitorOptional;
+  private final Optional<Class<?>> foregroundDownloadServiceClassOptional;
+  private final AsyncFunction<DataFileGroupInternal, Boolean> customFileGroupValidator;
+
+  // Synchronization will be done through sequentialControlExecutor
+  // Keep all the on-going foreground downloads.
+  @VisibleForTesting
+  final Map<String, ListenableFuture<ClientFileGroup>> keyToListenableFuture = new HashMap<>();
+
+  MobileDataDownloadImpl(
+      Context context,
+      EventLogger eventLogger,
+      MobileDataDownloadManager mobileDataDownloadManager,
+      Executor sequentialControlExecutor,
+      List<FileGroupPopulator> fileGroupPopulatorList,
+      Optional<TaskScheduler> taskSchedulerOptional,
+      SynchronousFileStorage fileStorage,
+      Optional<DownloadProgressMonitor> downloadMonitorOptional,
+      Optional<Class<?>> foregroundDownloadServiceClassOptional,
+      Flags flags,
+      Downloader singleFileDownloader,
+      Optional<CustomFileGroupValidator> customValidatorOptional) {
+    this.context = context;
+    this.eventLogger = eventLogger;
+    this.fileGroupPopulatorList = fileGroupPopulatorList;
+    this.taskSchedulerOptional = taskSchedulerOptional;
+    this.sequentialControlExecutor = sequentialControlExecutor;
+    this.mobileDataDownloadManager = mobileDataDownloadManager;
+    this.fileStorage = fileStorage;
+    this.downloadMonitorOptional = downloadMonitorOptional;
+    this.foregroundDownloadServiceClassOptional = foregroundDownloadServiceClassOptional;
+    this.flags = flags;
+    this.singleFileDownloader = singleFileDownloader;
+    this.customFileGroupValidator =
+        createCustomFileGroupValidator(
+            customValidatorOptional,
+            mobileDataDownloadManager,
+            sequentialControlExecutor,
+            fileStorage);
+  }
+
+  // Wraps the custom validator because the validation at a lower level of the stack where
+  // the ClientFileGroup is not available, yet ClientFileGroup is the client-facing API we'd
+  // like to expose.
+  private static AsyncFunction<DataFileGroupInternal, Boolean> createCustomFileGroupValidator(
+      Optional<CustomFileGroupValidator> validatorOptional,
+      MobileDataDownloadManager mobileDataDownloadManager,
+      Executor executor,
+      SynchronousFileStorage fileStorage) {
+    if (!validatorOptional.isPresent()) {
+      return unused -> Futures.immediateFuture(true);
+    }
+
+    return internalFileGroup ->
+        Futures.transformAsync(
+            createClientFileGroup(
+                internalFileGroup,
+                /* account= */ null,
+                ClientFileGroup.Status.PENDING_CUSTOM_VALIDATION,
+                /* preserveZipDirectories= */ false,
+                mobileDataDownloadManager,
+                executor,
+                fileStorage),
+            propagateAsyncFunction(
+                clientFileGroup -> validatorOptional.get().validateFileGroup(clientFileGroup)),
+            executor);
+  }
+
+  @Override
+  public ListenableFuture<Boolean> addFileGroup(AddFileGroupRequest addFileGroupRequest) {
+    return futureSerializer.submitAsync(
+        propagateAsyncCallable(
+            () -> {
+              LogUtil.d(
+                  "%s: Adding for download group = '%s', variant = '%s' and associating it with"
+                      + " account = '%s', variant = '%s'",
+                  TAG,
+                  addFileGroupRequest.dataFileGroup().getGroupName(),
+                  addFileGroupRequest.dataFileGroup().getVariantId(),
+                  String.valueOf(addFileGroupRequest.accountOptional().orNull()),
+                  String.valueOf(addFileGroupRequest.variantIdOptional().orNull()));
+
+              DataFileGroup dataFileGroup = addFileGroupRequest.dataFileGroup();
+
+              // Ensure that the owner package is always set as the host app.
+              if (!dataFileGroup.hasOwnerPackage()) {
+                dataFileGroup =
+                    dataFileGroup.toBuilder().setOwnerPackage(context.getPackageName()).build();
+              } else if (!context.getPackageName().equals(dataFileGroup.getOwnerPackage())) {
+                LogUtil.e(
+                    "%s: Added group = '%s' with wrong owner package: '%s' v.s. '%s' ",
+                    TAG,
+                    dataFileGroup.getGroupName(),
+                    context.getPackageName(),
+                    dataFileGroup.getOwnerPackage());
+                return Futures.immediateFuture(false);
+              }
+
+              GroupKey.Builder groupKeyBuilder =
+                  GroupKey.newBuilder()
+                      .setGroupName(dataFileGroup.getGroupName())
+                      .setOwnerPackage(dataFileGroup.getOwnerPackage());
+
+              if (addFileGroupRequest.accountOptional().isPresent()) {
+                groupKeyBuilder.setAccount(
+                    AccountUtil.serialize(addFileGroupRequest.accountOptional().get()));
+              }
+
+              if (addFileGroupRequest.variantIdOptional().isPresent()) {
+                groupKeyBuilder.setVariantId(addFileGroupRequest.variantIdOptional().get());
+              }
+
+              try {
+                DataFileGroupInternal dataFileGroupInternal =
+                    ProtoConversionUtil.convert(dataFileGroup);
+                return mobileDataDownloadManager.addGroupForDownloadInternal(
+                    groupKeyBuilder.build(), dataFileGroupInternal, customFileGroupValidator);
+              } catch (InvalidProtocolBufferException e) {
+                // TODO(b/118137672): Consider rethrow exception instead of returning false.
+                LogUtil.e(
+                    e, "%s: Unable to convert from DataFileGroup to DataFileGroupInternal.", TAG);
+                return Futures.immediateFuture(false);
+              }
+            }),
+        sequentialControlExecutor);
+  }
+
+  // TODO: Change to return ListenableFuture<Void>.
+  @Override
+  public ListenableFuture<Boolean> removeFileGroup(RemoveFileGroupRequest removeFileGroupRequest) {
+    return futureSerializer.submitAsync(
+        () -> {
+          GroupKey.Builder groupKeyBuilder =
+              GroupKey.newBuilder()
+                  .setGroupName(removeFileGroupRequest.groupName())
+                  .setOwnerPackage(context.getPackageName());
+          if (removeFileGroupRequest.accountOptional().isPresent()) {
+            groupKeyBuilder.setAccount(
+                AccountUtil.serialize(removeFileGroupRequest.accountOptional().get()));
+          }
+          if (removeFileGroupRequest.variantIdOptional().isPresent()) {
+            groupKeyBuilder.setVariantId(removeFileGroupRequest.variantIdOptional().get());
+          }
+
+          GroupKey groupKey = groupKeyBuilder.build();
+          return Futures.transform(
+              mobileDataDownloadManager.removeFileGroup(
+                  groupKey, removeFileGroupRequest.pendingOnly()),
+              voidArg -> true,
+              sequentialControlExecutor);
+        },
+        sequentialControlExecutor);
+  }
+
+  @Override
+  public ListenableFuture<RemoveFileGroupsByFilterResponse> removeFileGroupsByFilter(
+      RemoveFileGroupsByFilterRequest removeFileGroupsByFilterRequest) {
+    return futureSerializer.submitAsync(
+        () ->
+            FluentFuture.from(mobileDataDownloadManager.getAllFreshGroups())
+                .transformAsync(
+                    allFreshGroups -> {
+                      ImmutableSet.Builder<GroupKey> groupKeysToRemoveBuilder =
+                          ImmutableSet.builder();
+                      for (Pair<GroupKey, DataFileGroupInternal> keyDataFileGroupPair :
+                          allFreshGroups) {
+                        if (applyRemoveFileGroupsFilter(
+                            removeFileGroupsByFilterRequest, keyDataFileGroupPair)) {
+                          // Remove downloaded status so pending/downloaded versions of the same
+                          // group are treated as one.
+                          groupKeysToRemoveBuilder.add(
+                              keyDataFileGroupPair.first.toBuilder().clearDownloaded().build());
+                        }
+                      }
+                      ImmutableSet<GroupKey> groupKeysToRemove = groupKeysToRemoveBuilder.build();
+                      if (groupKeysToRemove.isEmpty()) {
+                        return Futures.immediateFuture(
+                            RemoveFileGroupsByFilterResponse.newBuilder()
+                                .setRemovedFileGroupsCount(0)
+                                .build());
+                      }
+                      return Futures.transform(
+                          mobileDataDownloadManager.removeFileGroups(groupKeysToRemove.asList()),
+                          unused ->
+                              RemoveFileGroupsByFilterResponse.newBuilder()
+                                  .setRemovedFileGroupsCount(groupKeysToRemove.size())
+                                  .build(),
+                          sequentialControlExecutor);
+                    },
+                    sequentialControlExecutor),
+        sequentialControlExecutor);
+  }
+
+  // Perform filtering using options from RemoveFileGroupsByFilterRequest
+  private static boolean applyRemoveFileGroupsFilter(
+      RemoveFileGroupsByFilterRequest removeFileGroupsByFilterRequest,
+      Pair<GroupKey, DataFileGroupInternal> keyDataFileGroupPair) {
+    // If request filters by account, ensure account is present and is equal
+    Optional<Account> accountOptional = removeFileGroupsByFilterRequest.accountOptional();
+    if (!accountOptional.isPresent() && keyDataFileGroupPair.first.hasAccount()) {
+      // Account must explicitly be provided in order to remove account associated file groups.
+      return false;
+    }
+    if (accountOptional.isPresent()
+        && !AccountUtil.serialize(accountOptional.get())
+            .equals(keyDataFileGroupPair.first.getAccount())) {
+      return false;
+    }
+
+    return true;
+  }
+
+  // TODO: Futures.immediateFuture(null) uses a different annotation for Nullable.
+  @SuppressWarnings("nullness")
+  @Override
+  public ListenableFuture<ClientFileGroup> getFileGroup(GetFileGroupRequest getFileGroupRequest) {
+    return futureSerializer.submitAsync(
+        () -> {
+          GroupKey.Builder groupKeyBuilder =
+              GroupKey.newBuilder()
+                  .setGroupName(getFileGroupRequest.groupName())
+                  .setOwnerPackage(context.getPackageName());
+
+          if (getFileGroupRequest.accountOptional().isPresent()) {
+            groupKeyBuilder.setAccount(
+                AccountUtil.serialize(getFileGroupRequest.accountOptional().get()));
+          }
+
+          if (getFileGroupRequest.variantIdOptional().isPresent()) {
+            groupKeyBuilder.setVariantId(getFileGroupRequest.variantIdOptional().get());
+          }
+
+          GroupKey groupKey = groupKeyBuilder.build();
+          return Futures.transformAsync(
+              mobileDataDownloadManager.getFileGroup(groupKey, /*downloaded=*/ true),
+              dataFileGroup ->
+                  createClientFileGroupAndLogQueryStats(
+                      groupKey,
+                      dataFileGroup,
+                      /*downloaded=*/ true,
+                      getFileGroupRequest.preserveZipDirectories()),
+              sequentialControlExecutor);
+        },
+        sequentialControlExecutor);
+  }
+
+  private ListenableFuture<ClientFileGroup> createClientFileGroupAndLogQueryStats(
+      GroupKey groupKey,
+      @Nullable DataFileGroupInternal dataFileGroup,
+      boolean downloaded,
+      boolean preserveZipDirectories) {
+    return Futures.transform(
+        createClientFileGroup(
+            dataFileGroup,
+            groupKey.hasAccount() ? groupKey.getAccount() : null,
+            downloaded ? ClientFileGroup.Status.DOWNLOADED : ClientFileGroup.Status.PENDING,
+            preserveZipDirectories,
+            mobileDataDownloadManager,
+            sequentialControlExecutor,
+            fileStorage),
+        clientFileGroup -> {
+          if (clientFileGroup != null) {
+            eventLogger.logMddQueryStats(createFileGroupDetails(clientFileGroup));
+          }
+          return clientFileGroup;
+        },
+        sequentialControlExecutor);
+  }
+
+  @SuppressWarnings("nullness")
+  private static ListenableFuture<ClientFileGroup> createClientFileGroup(
+      @Nullable DataFileGroupInternal dataFileGroup,
+      @Nullable String account,
+      ClientFileGroup.Status status,
+      boolean preserveZipDirectories,
+      MobileDataDownloadManager manager,
+      Executor executor,
+      SynchronousFileStorage fileStorage) {
+    if (dataFileGroup == null) {
+      return Futures.immediateFuture(null);
+    }
+    ClientFileGroup.Builder clientFileGroupBuilderInit =
+        ClientFileGroup.newBuilder()
+            .setGroupName(dataFileGroup.getGroupName())
+            .setOwnerPackage(dataFileGroup.getOwnerPackage())
+            .setVersionNumber(dataFileGroup.getFileGroupVersionNumber())
+            .setBuildId(dataFileGroup.getBuildId())
+            .setVariantId(dataFileGroup.getVariantId())
+            .setStatus(status)
+            .addAllLocale(dataFileGroup.getLocaleList());
+
+    if (account != null) {
+      clientFileGroupBuilderInit.setAccount(account);
+    }
+
+    if (dataFileGroup.hasCustomMetadata()) {
+      clientFileGroupBuilderInit.setCustomMetadata(dataFileGroup.getCustomMetadata());
+    }
+
+    ListenableFuture<ClientFileGroup.Builder> clientFileGroupBuilderFuture =
+        Futures.immediateFuture(clientFileGroupBuilderInit);
+    for (DataFile dataFile : dataFileGroup.getFileList()) {
+      clientFileGroupBuilderFuture =
+          Futures.transformAsync(
+              clientFileGroupBuilderFuture,
+              clientFileGroupBuilder -> {
+                if (status == ClientFileGroup.Status.DOWNLOADED
+                    || status == ClientFileGroup.Status.PENDING_CUSTOM_VALIDATION) {
+                  return Futures.transformAsync(
+                      manager.getDataFileUri(dataFile, dataFileGroup),
+                      fileUri -> {
+                        if (fileUri == null) {
+                          return Futures.immediateFailedFuture(
+                              DownloadException.builder()
+                                  .setDownloadResultCode(
+                                      DownloadResultCode.DOWNLOADED_FILE_NOT_FOUND_ERROR)
+                                  .setMessage("getDataFileUri() resolved to null")
+                                  .build());
+                        }
+                        try {
+                          if (!preserveZipDirectories && fileStorage.isDirectory(fileUri)) {
+                            String rootPath = fileUri.getPath();
+                            if (rootPath != null) {
+                              clientFileGroupBuilder.addAllFile(
+                                  listAllClientFilesOfDirectory(fileStorage, fileUri, rootPath));
+                            }
+                          } else {
+                            clientFileGroupBuilder.addFile(
+                                createClientFile(
+                                    dataFile.getFileId(),
+                                    dataFile.getByteSize(),
+                                    dataFile.getDownloadedFileByteSize(),
+                                    fileUri.toString(),
+                                    dataFile.hasCustomMetadata()
+                                        ? dataFile.getCustomMetadata()
+                                        : null));
+                          }
+                        } catch (IOException e) {
+                          LogUtil.e(e, "Failed to list files under directory:" + fileUri);
+                        }
+                        return Futures.immediateFuture(clientFileGroupBuilder);
+                      },
+                      executor);
+                } else {
+                  clientFileGroupBuilder.addFile(
+                      createClientFile(
+                          dataFile.getFileId(),
+                          dataFile.getByteSize(),
+                          dataFile.getDownloadedFileByteSize(),
+                          /* uri = */ null,
+                          dataFile.hasCustomMetadata() ? dataFile.getCustomMetadata() : null));
+                  return Futures.immediateFuture(clientFileGroupBuilder);
+                }
+              },
+              executor);
+    }
+
+    return FluentFuture.from(clientFileGroupBuilderFuture)
+        .transform(GeneratedMessageLite.Builder::build, executor)
+        .catching(DownloadException.class, exn -> null, executor);
+  }
+
+  private static ClientFile createClientFile(
+      String fileId,
+      int byteSize,
+      int downloadByteSize,
+      @Nullable String uri,
+      @Nullable Any customMetadata) {
+    ClientFile.Builder clientFileBuilder =
+        ClientFile.newBuilder().setFileId(fileId).setFullSizeInBytes(byteSize);
+    if (downloadByteSize > 0) {
+      // Files with downloaded transforms like compress and zip could have different downloaded
+      // file size than the final file size on disk. Return the downloaded file size for client to
+      // track and calculate the download progress.
+      clientFileBuilder.setDownloadSizeInBytes(downloadByteSize);
+    }
+    if (uri != null) {
+      clientFileBuilder.setFileUri(uri);
+    }
+    if (customMetadata != null) {
+      clientFileBuilder.setCustomMetadata(customMetadata);
+    }
+    return clientFileBuilder.build();
+  }
+
+  private static List<ClientFile> listAllClientFilesOfDirectory(
+      SynchronousFileStorage fileStorage, Uri dirUri, String rootDir) throws IOException {
+    List<ClientFile> clientFileList = new ArrayList<>();
+    for (Uri childUri : fileStorage.children(dirUri)) {
+      if (fileStorage.isDirectory(childUri)) {
+        clientFileList.addAll(listAllClientFilesOfDirectory(fileStorage, childUri, rootDir));
+      } else {
+        String childPath = childUri.getPath();
+        if (childPath != null) {
+          ClientFile clientFile =
+              ClientFile.newBuilder()
+                  .setFileId(childPath.replaceFirst(rootDir, ""))
+                  .setFullSizeInBytes((int) fileStorage.fileSize(childUri))
+                  .setFileUri(childUri.toString())
+                  .build();
+          clientFileList.add(clientFile);
+        }
+      }
+    }
+    return clientFileList;
+  }
+
+  @Override
+  public ListenableFuture<ImmutableList<ClientFileGroup>> getFileGroupsByFilter(
+      GetFileGroupsByFilterRequest getFileGroupsByFilterRequest) {
+    return futureSerializer.submitAsync(
+        () ->
+            Futures.transformAsync(
+                mobileDataDownloadManager.getAllFreshGroups(),
+                allFreshGroups -> {
+                  ListenableFuture<ImmutableList.Builder<ClientFileGroup>>
+                      clientFileGroupsBuilderFuture =
+                          Futures.immediateFuture(ImmutableList.<ClientFileGroup>builder());
+                  for (Pair<GroupKey, DataFileGroupInternal> keyDataFileGroupPair :
+                      allFreshGroups) {
+                    clientFileGroupsBuilderFuture =
+                        Futures.transformAsync(
+                            clientFileGroupsBuilderFuture,
+                            clientFileGroupsBuilder -> {
+                              GroupKey groupKey = keyDataFileGroupPair.first;
+                              DataFileGroupInternal dataFileGroup = keyDataFileGroupPair.second;
+                              if (applyFilter(
+                                  getFileGroupsByFilterRequest, groupKey, dataFileGroup)) {
+                                return Futures.transform(
+                                    createClientFileGroupAndLogQueryStats(
+                                        groupKey,
+                                        dataFileGroup,
+                                        groupKey.getDownloaded(),
+                                        getFileGroupsByFilterRequest.preserveZipDirectories()),
+                                    clientFileGroup -> {
+                                      if (clientFileGroup != null) {
+                                        clientFileGroupsBuilder.add(clientFileGroup);
+                                      }
+                                      return clientFileGroupsBuilder;
+                                    },
+                                    sequentialControlExecutor);
+                              }
+                              return Futures.immediateFuture(clientFileGroupsBuilder);
+                            },
+                            sequentialControlExecutor);
+                  }
+
+                  return Futures.transform(
+                      clientFileGroupsBuilderFuture,
+                      ImmutableList.Builder::build,
+                      sequentialControlExecutor);
+                },
+                sequentialControlExecutor),
+        sequentialControlExecutor);
+  }
+
+  // Perform filtering using options from GetFileGroupsByFilterRequest
+  private static boolean applyFilter(
+      GetFileGroupsByFilterRequest getFileGroupsByFilterRequest,
+      GroupKey groupKey,
+      DataFileGroupInternal fileGroup) {
+    if (getFileGroupsByFilterRequest.includeAllGroups()) {
+      return true;
+    }
+
+    // If request filters by group name, ensure name is equal
+    Optional<String> groupNameOptional = getFileGroupsByFilterRequest.groupNameOptional();
+    if (groupNameOptional.isPresent()
+        && !TextUtils.equals(groupNameOptional.get(), groupKey.getGroupName())) {
+      return false;
+    }
+
+    // When the caller requests account independent groups only.
+    if (getFileGroupsByFilterRequest.groupWithNoAccountOnly()) {
+      return !groupKey.hasAccount();
+    }
+
+    // When the caller requests account dependent groups as well.
+    if (getFileGroupsByFilterRequest.accountOptional().isPresent()
+        && !AccountUtil.serialize(getFileGroupsByFilterRequest.accountOptional().get())
+            .equals(groupKey.getAccount())) {
+      return false;
+    }
+    return true;
+  }
+
+  /**
+   * Creates {@link IcingDataDownloadFileGroupStats} from {@link ClientFileGroup} for remote logging
+   * purposes.
+   */
+  private static Void createFileGroupDetails(ClientFileGroup clientFileGroup) {
+    return null;
+  }
+
+  @Override
+  public ListenableFuture<Void> importFiles(ImportFilesRequest importFilesRequest) {
+    GroupKey.Builder groupKeyBuilder =
+        GroupKey.newBuilder()
+            .setGroupName(importFilesRequest.groupName())
+            .setOwnerPackage(context.getPackageName());
+
+    if (importFilesRequest.accountOptional().isPresent()) {
+      groupKeyBuilder.setAccount(AccountUtil.serialize(importFilesRequest.accountOptional().get()));
+    }
+
+    GroupKey groupKey = groupKeyBuilder.build();
+
+    ImmutableList.Builder<DataFile> updatedDataFileListBuilder =
+        ImmutableList.builderWithExpectedSize(importFilesRequest.updatedDataFileList().size());
+    for (DownloadConfigProto.DataFile dataFile : importFilesRequest.updatedDataFileList()) {
+      updatedDataFileListBuilder.add(ProtoConversionUtil.convertDataFile(dataFile));
+    }
+
+    return futureSerializer.submitAsync(
+        () ->
+            mobileDataDownloadManager.importFiles(
+                groupKey,
+                importFilesRequest.buildId(),
+                importFilesRequest.variantId(),
+                updatedDataFileListBuilder.build(),
+                importFilesRequest.inlineFileMap(),
+                importFilesRequest.customPropertyOptional(),
+                customFileGroupValidator),
+        sequentialControlExecutor);
+  }
+
+  @Override
+  public ListenableFuture<Void> downloadFile(SingleFileDownloadRequest singleFileDownloadRequest) {
+    return singleFileDownloader.download(
+        MddLiteConversionUtil.convertToDownloadRequest(singleFileDownloadRequest));
+  }
+
+  @Override
+  public ListenableFuture<ClientFileGroup> downloadFileGroup(
+      DownloadFileGroupRequest downloadFileGroupRequest) {
+    String groupName = downloadFileGroupRequest.groupName();
+    GroupKey.Builder groupKeyBuilder =
+        GroupKey.newBuilder().setGroupName(groupName).setOwnerPackage(context.getPackageName());
+
+    if (downloadFileGroupRequest.accountOptional().isPresent()) {
+      groupKeyBuilder.setAccount(
+          AccountUtil.serialize(downloadFileGroupRequest.accountOptional().get()));
+    }
+    if (downloadFileGroupRequest.variantIdOptional().isPresent()) {
+      groupKeyBuilder.setVariantId(downloadFileGroupRequest.variantIdOptional().get());
+    }
+
+    GroupKey groupKey = groupKeyBuilder.build();
+
+    ListenableFuture<ClientFileGroup> downloadFuture =
+        Futures.submitAsync(
+            () -> {
+              if (downloadFileGroupRequest.listenerOptional().isPresent()) {
+                if (downloadMonitorOptional.isPresent()) {
+                  downloadMonitorOptional
+                      .get()
+                      .addDownloadListener(
+                          groupName, downloadFileGroupRequest.listenerOptional().get());
+                } else {
+                  return Futures.immediateFailedFuture(
+                      DownloadException.builder()
+                          .setDownloadResultCode(
+                              DownloadResultCode.DOWNLOAD_MONITOR_NOT_PROVIDED_ERROR)
+                          .setMessage(
+                              "downloadFileGroup: DownloadListener is present but Download Monitor"
+                                  + " is not provided!")
+                          .build());
+                }
+              }
+
+              Optional<DownloadConditions> downloadConditions =
+                  downloadFileGroupRequest.downloadConditionsOptional().isPresent()
+                      ? Optional.of(
+                          ProtoConversionUtil.convert(
+                              downloadFileGroupRequest.downloadConditionsOptional().get()))
+                      : Optional.absent();
+              ListenableFuture<DataFileGroupInternal> downloadFileGroupFuture =
+                  mobileDataDownloadManager.downloadFileGroup(
+                      groupKey, downloadConditions, customFileGroupValidator);
+
+              return Futures.transformAsync(
+                  downloadFileGroupFuture,
+                  dataFileGroup -> {
+                    return Futures.transform(
+                        createClientFileGroup(
+                            dataFileGroup,
+                            downloadFileGroupRequest.accountOptional().isPresent()
+                                ? AccountUtil.serialize(
+                                    downloadFileGroupRequest.accountOptional().get())
+                                : null,
+                            ClientFileGroup.Status.DOWNLOADED,
+                            downloadFileGroupRequest.preserveZipDirectories(),
+                            mobileDataDownloadManager,
+                            sequentialControlExecutor,
+                            fileStorage),
+                        Preconditions::checkNotNull,
+                        sequentialControlExecutor);
+                  },
+                  sequentialControlExecutor);
+            },
+            sequentialControlExecutor);
+
+    ListenableFuture<ClientFileGroup> transformFuture =
+        Futures.transform(
+            downloadFuture,
+            clientFileGroup -> {
+              if (downloadFileGroupRequest.listenerOptional().isPresent()) {
+                downloadFileGroupRequest.listenerOptional().get().onComplete(clientFileGroup);
+                if (downloadMonitorOptional.isPresent()) {
+                  downloadMonitorOptional.get().removeDownloadListener(groupName);
+                }
+              }
+              return clientFileGroup;
+            },
+            sequentialControlExecutor);
+
+    Futures.addCallback(
+        transformFuture,
+        new FutureCallback<ClientFileGroup>() {
+          @Override
+          public void onSuccess(ClientFileGroup result) {}
+
+          @Override
+          public void onFailure(Throwable t) {
+            if (downloadFileGroupRequest.listenerOptional().isPresent()
+                && downloadMonitorOptional.isPresent()) {
+              downloadMonitorOptional.get().removeDownloadListener(groupName);
+            }
+          }
+        },
+        sequentialControlExecutor);
+
+    return transformFuture;
+  }
+
+  @Override
+  public ListenableFuture<Void> downloadFileWithForegroundService(
+      SingleFileDownloadRequest singleFileDownloadRequest) {
+    return singleFileDownloader.downloadWithForegroundService(
+        MddLiteConversionUtil.convertToDownloadRequest(singleFileDownloadRequest));
+  }
+
+  @Override
+  public ListenableFuture<ClientFileGroup> downloadFileGroupWithForegroundService(
+      DownloadFileGroupRequest downloadFileGroupRequest) {
+    LogUtil.d("%s: downloadFileGroupWithForegroundService start.", TAG);
+    if (!foregroundDownloadServiceClassOptional.isPresent()) {
+      return Futures.immediateFailedFuture(
+          new IllegalArgumentException(
+              "downloadFileGroupWithForegroundService: ForegroundDownloadService is not"
+                  + " provided!"));
+    }
+
+    if (!downloadMonitorOptional.isPresent()) {
+      return Futures.immediateFailedFuture(
+          DownloadException.builder()
+              .setDownloadResultCode(DownloadResultCode.DOWNLOAD_MONITOR_NOT_PROVIDED_ERROR)
+              .setMessage(
+                  "downloadFileGroupWithForegroundService: Download Monitor is not provided!")
+              .build());
+    }
+
+    // It's OK to recreate the NotificationChannel since it can also be used to restore a
+    // deleted channel and to update an existing channel's name, description, group, and/or
+    // importance.
+    NotificationUtil.createNotificationChannel(context);
+
+    String groupName = downloadFileGroupRequest.groupName();
+    GroupKey.Builder groupKeyBuilder =
+        GroupKey.newBuilder().setGroupName(groupName).setOwnerPackage(context.getPackageName());
+
+    if (downloadFileGroupRequest.accountOptional().isPresent()) {
+      groupKeyBuilder.setAccount(
+          AccountUtil.serialize(downloadFileGroupRequest.accountOptional().get()));
+    }
+    if (downloadFileGroupRequest.variantIdOptional().isPresent()) {
+      groupKeyBuilder.setVariantId(downloadFileGroupRequest.variantIdOptional().get());
+    }
+
+    GroupKey groupKey = groupKeyBuilder.build();
+
+    ListenableFuture<ClientFileGroup> downloadFuture =
+        Futures.transformAsync(
+            // Check if requested file group has already been downloaded
+            tryToGetDownloadedFileGroup(downloadFileGroupRequest),
+            downloadedFileGroupOptional -> {
+              // If the file group has already been downloaded, return that one.
+              if (downloadedFileGroupOptional.isPresent()) {
+                return Futures.immediateFuture(downloadedFileGroupOptional.get());
+              }
+
+              // if there is the same on-going request, return that one.
+              if (keyToListenableFuture.containsKey(downloadFileGroupRequest.groupName())) {
+                // keyToListenableFuture.get must return Non-null since we check the containsKey
+                // above.
+                // checkNotNull is to suppress false alarm about @Nullable result.
+                return Preconditions.checkNotNull(
+                    keyToListenableFuture.get(downloadFileGroupRequest.groupName()));
+              }
+
+              // Only start the foreground download service when this is the first download
+              // request.
+              if (keyToListenableFuture.isEmpty()) {
+                NotificationUtil.startForegroundDownloadService(
+                    context,
+                    foregroundDownloadServiceClassOptional.get(),
+                    downloadFileGroupRequest.groupName());
+              }
+
+              DownloadListener downloadListenerWithNotification =
+                  createDownloadListenerWithNotification(downloadFileGroupRequest);
+              // The downloadMonitor will trigger the DownloadListener.
+              downloadMonitorOptional
+                  .get()
+                  .addDownloadListener(
+                      downloadFileGroupRequest.groupName(), downloadListenerWithNotification);
+
+              Optional<DownloadConditions> downloadConditions =
+                  downloadFileGroupRequest.downloadConditionsOptional().isPresent()
+                      ? Optional.of(
+                          ProtoConversionUtil.convert(
+                              downloadFileGroupRequest.downloadConditionsOptional().get()))
+                      : Optional.absent();
+              ListenableFuture<DataFileGroupInternal> downloadFileGroupFuture =
+                  mobileDataDownloadManager.downloadFileGroup(
+                      groupKey, downloadConditions, customFileGroupValidator);
+
+              ListenableFuture<ClientFileGroup> transformFuture =
+                  Futures.transformAsync(
+                      downloadFileGroupFuture,
+                      dataFileGroup -> {
+                        return Futures.transform(
+                            createClientFileGroup(
+                                dataFileGroup,
+                                downloadFileGroupRequest.accountOptional().isPresent()
+                                    ? AccountUtil.serialize(
+                                        downloadFileGroupRequest.accountOptional().get())
+                                    : null,
+                                ClientFileGroup.Status.DOWNLOADED,
+                                downloadFileGroupRequest.preserveZipDirectories(),
+                                mobileDataDownloadManager,
+                                sequentialControlExecutor,
+                                fileStorage),
+                            Preconditions::checkNotNull,
+                            sequentialControlExecutor);
+                      },
+                      sequentialControlExecutor);
+
+              Futures.addCallback(
+                  transformFuture,
+                  new FutureCallback<ClientFileGroup>() {
+                    @Override
+                    public void onSuccess(ClientFileGroup clientFileGroup) {
+                      // Currently the MobStore monitor does not support onSuccess so we have to add
+                      // callback to the download future here.
+                      // TODO(b/148057674): Use the same logic as MDDLite to keep the foreground
+                      // download service alive until the client's onComplete finishes.
+                      downloadListenerWithNotification.onComplete(clientFileGroup);
+                    }
+
+                    @Override
+                    public void onFailure(Throwable t) {
+                      // Currently the MobStore monitor does not support onFailure so we have to add
+                      // callback to the download future here.
+                      downloadListenerWithNotification.onFailure(t);
+                    }
+                  },
+                  sequentialControlExecutor);
+
+              keyToListenableFuture.put(downloadFileGroupRequest.groupName(), transformFuture);
+              return transformFuture;
+            },
+            sequentialControlExecutor);
+
+    return downloadFuture;
+  }
+
+  /** Helper method to check if file group has been downloaded and return it early. */
+  private ListenableFuture<Optional<ClientFileGroup>> tryToGetDownloadedFileGroup(
+      DownloadFileGroupRequest downloadFileGroupRequest) {
+    String groupName = downloadFileGroupRequest.groupName();
+    GroupKey.Builder groupKeyBuilder =
+        GroupKey.newBuilder().setGroupName(groupName).setOwnerPackage(context.getPackageName());
+
+    if (downloadFileGroupRequest.accountOptional().isPresent()) {
+      groupKeyBuilder.setAccount(
+          AccountUtil.serialize(downloadFileGroupRequest.accountOptional().get()));
+    }
+    boolean isDownloadListenerPresent = downloadFileGroupRequest.listenerOptional().isPresent();
+    GroupKey groupKey = groupKeyBuilder.build();
+
+    // Get pending and downloaded versions to tell if we should return downloaded version early
+    ListenableFuture<Pair<DataFileGroupInternal, DataFileGroupInternal>> fileGroupVersionsFuture =
+        Futures.transformAsync(
+            mobileDataDownloadManager.getFileGroup(groupKey, /* downloaded = */ false),
+            pendingDataFileGroup ->
+                Futures.transform(
+                    mobileDataDownloadManager.getFileGroup(groupKey, /* downloaded = */ true),
+                    downloadedDataFileGroup ->
+                        Pair.create(pendingDataFileGroup, downloadedDataFileGroup),
+                    sequentialControlExecutor),
+            sequentialControlExecutor);
+
+    return Futures.transformAsync(
+        fileGroupVersionsFuture,
+        fileGroupVersionsPair -> {
+          // if pending version is not null, return absent
+          if (fileGroupVersionsPair.first != null) {
+            return Futures.immediateFuture(Optional.absent());
+          }
+          // If both groups are null, return group not found failure
+          if (fileGroupVersionsPair.second == null) {
+            // TODO(b/174808410): Add Logging
+            // file group is not pending nor downloaded -- return failure.
+            DownloadException failure =
+                DownloadException.builder()
+                    .setDownloadResultCode(DownloadResultCode.GROUP_NOT_FOUND_ERROR)
+                    .setMessage("Nothing to download for file group: " + groupKey.getGroupName())
+                    .build();
+            if (isDownloadListenerPresent) {
+              downloadFileGroupRequest.listenerOptional().get().onFailure(failure);
+            }
+            return Futures.immediateFailedFuture(failure);
+          }
+
+          DataFileGroupInternal downloadedDataFileGroup = fileGroupVersionsPair.second;
+
+          // Notify download listener (if present) that file group has been downloaded.
+          if (isDownloadListenerPresent) {
+            downloadMonitorOptional
+                .get()
+                .addDownloadListener(
+                    downloadFileGroupRequest.groupName(),
+                    downloadFileGroupRequest.listenerOptional().get());
+          }
+          FluentFuture<Optional<ClientFileGroup>> transformFuture =
+              FluentFuture.from(
+                      createClientFileGroup(
+                          downloadedDataFileGroup,
+                          downloadFileGroupRequest.accountOptional().isPresent()
+                              ? AccountUtil.serialize(
+                                  downloadFileGroupRequest.accountOptional().get())
+                              : null,
+                          ClientFileGroup.Status.DOWNLOADED,
+                          downloadFileGroupRequest.preserveZipDirectories(),
+                          mobileDataDownloadManager,
+                          sequentialControlExecutor,
+                          fileStorage))
+                  .transform(Preconditions::checkNotNull, sequentialControlExecutor)
+                  .transform(
+                      clientFileGroup -> {
+                        if (isDownloadListenerPresent) {
+                          downloadFileGroupRequest
+                              .listenerOptional()
+                              .get()
+                              .onComplete(clientFileGroup);
+                          downloadMonitorOptional.get().removeDownloadListener(groupName);
+                        }
+                        return Optional.of(clientFileGroup);
+                      },
+                      sequentialControlExecutor);
+          transformFuture.addCallback(
+              new FutureCallback<Optional<ClientFileGroup>>() {
+                @Override
+                public void onSuccess(Optional<ClientFileGroup> result) {}
+
+                @Override
+                public void onFailure(Throwable t) {
+                  if (isDownloadListenerPresent) {
+                    downloadMonitorOptional.get().removeDownloadListener(groupName);
+                  }
+                }
+              },
+              sequentialControlExecutor);
+
+          return transformFuture;
+        },
+        sequentialControlExecutor);
+  }
+
+  private DownloadListener createDownloadListenerWithNotification(
+      DownloadFileGroupRequest downloadRequest) {
+    NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
+
+    NotificationCompat.Builder notification =
+        NotificationUtil.createNotificationBuilder(
+            context,
+            downloadRequest.groupSizeBytes(),
+            downloadRequest.contentTitleOptional().or(downloadRequest.groupName()),
+            downloadRequest.contentTextOptional().or(downloadRequest.groupName()));
+    int notificationKey = NotificationUtil.notificationKeyForKey(downloadRequest.groupName());
+
+    if (downloadRequest.showNotifications() == DownloadFileGroupRequest.ShowNotifications.ALL) {
+      NotificationUtil.createCancelAction(
+          context,
+          foregroundDownloadServiceClassOptional.get(),
+          downloadRequest.groupName(),
+          notification,
+          notificationKey);
+
+      notificationManager.notify(notificationKey, notification.build());
+    }
+
+    return new DownloadListener() {
+      @Override
+      public void onProgress(long currentSize) {
+        sequentialControlExecutor.execute(
+            () -> {
+              // There can be a race condition, where onPausedForConnectivity can be called
+              // after onComplete or onFailure which removes the future and the notification.
+              if (keyToListenableFuture.containsKey(downloadRequest.groupName())
+                  && downloadRequest.showNotifications()
+                      == DownloadFileGroupRequest.ShowNotifications.ALL) {
+                notification
+                    .setCategory(NotificationCompat.CATEGORY_PROGRESS)
+                    .setSmallIcon(android.R.drawable.stat_sys_download)
+                    .setProgress(
+                        downloadRequest.groupSizeBytes(),
+                        (int) currentSize,
+                        /* indeterminate = */ downloadRequest.groupSizeBytes() <= 0);
+                notificationManager.notify(notificationKey, notification.build());
+              }
+              if (downloadRequest.listenerOptional().isPresent()) {
+                downloadRequest.listenerOptional().get().onProgress(currentSize);
+              }
+            });
+      }
+
+      @Override
+      public void pausedForConnectivity() {
+        sequentialControlExecutor.execute(
+            () -> {
+              // There can be a race condition, where pausedForConnectivity can be called
+              // after onComplete or onFailure which removes the future and the notification.
+              if (keyToListenableFuture.containsKey(downloadRequest.groupName())
+                  && downloadRequest.showNotifications()
+                      == DownloadFileGroupRequest.ShowNotifications.ALL) {
+                notification
+                    .setCategory(NotificationCompat.CATEGORY_STATUS)
+                    .setContentText(NotificationUtil.getDownloadPausedMessage(context))
+                    .setSmallIcon(android.R.drawable.stat_sys_download)
+                    .setOngoing(true)
+                    // hide progress bar.
+                    .setProgress(0, 0, false);
+                notificationManager.notify(notificationKey, notification.build());
+              }
+
+              if (downloadRequest.listenerOptional().isPresent()) {
+                downloadRequest.listenerOptional().get().pausedForConnectivity();
+              }
+            });
+      }
+
+      @Override
+      public void onComplete(ClientFileGroup clientFileGroup) {
+        sequentialControlExecutor.execute(
+            () -> {
+              // Clear the notification action.
+              if (downloadRequest.showNotifications()
+                  == DownloadFileGroupRequest.ShowNotifications.ALL) {
+                notification.mActions.clear();
+
+                NotificationUtil.cancelNotificationForKey(context, downloadRequest.groupName());
+              }
+
+              keyToListenableFuture.remove(downloadRequest.groupName());
+              // If there is no other on-going foreground download, shutdown the
+              // ForegroundDownloadService
+              if (keyToListenableFuture.isEmpty()) {
+                NotificationUtil.stopForegroundDownloadService(
+                    context, foregroundDownloadServiceClassOptional.get());
+              }
+
+              if (downloadRequest.listenerOptional().isPresent()) {
+                downloadRequest.listenerOptional().get().onComplete(clientFileGroup);
+              }
+
+              downloadMonitorOptional.get().removeDownloadListener(downloadRequest.groupName());
+            });
+      }
+
+      @Override
+      public void onFailure(Throwable t) {
+        sequentialControlExecutor.execute(
+            () -> {
+              if (downloadRequest.showNotifications()
+                  == DownloadFileGroupRequest.ShowNotifications.ALL) {
+                // Clear the notification action.
+                notification.mActions.clear();
+
+                // Show download failed in notification.
+                notification
+                    .setCategory(NotificationCompat.CATEGORY_STATUS)
+                    .setContentText(NotificationUtil.getDownloadFailedMessage(context))
+                    .setOngoing(false)
+                    .setSmallIcon(android.R.drawable.stat_sys_warning)
+                    // hide progress bar.
+                    .setProgress(0, 0, false);
+
+                notificationManager.notify(notificationKey, notification.build());
+              }
+              keyToListenableFuture.remove(downloadRequest.groupName());
+
+              // If there is no other on-going foreground download, shutdown the
+              // ForegroundDownloadService
+              if (keyToListenableFuture.isEmpty()) {
+                NotificationUtil.stopForegroundDownloadService(
+                    context, foregroundDownloadServiceClassOptional.get());
+              }
+
+              if (downloadRequest.listenerOptional().isPresent()) {
+                downloadRequest.listenerOptional().get().onFailure(t);
+              }
+              downloadMonitorOptional.get().removeDownloadListener(downloadRequest.groupName());
+            });
+      }
+    };
+  }
+
+  @Override
+  public void cancelForegroundDownload(String downloadKey) {
+    LogUtil.d("%s: CancelForegroundDownload for key = %s", TAG, downloadKey);
+    sequentialControlExecutor.execute(
+        () -> {
+          if (keyToListenableFuture.containsKey(downloadKey)) {
+            keyToListenableFuture.get(downloadKey).cancel(true);
+          } else {
+            // downloadKey is not a file group, attempt cancel with internal MDD Lite instance in
+            // case it's a single file uri (cancel call is a noop if internal MDD Lite doesn't know
+            // about it).
+            singleFileDownloader.cancelForegroundDownload(downloadKey);
+          }
+        });
+  }
+
+  @Override
+  public void schedulePeriodicTasks() {
+    schedulePeriodicTasksInternal(Optional.absent());
+  }
+
+  @Override
+  public ListenableFuture<Void> schedulePeriodicBackgroundTasks() {
+    return futureSerializer.submit(
+        propagateCallable(
+            () -> {
+              schedulePeriodicTasksInternal(/* constraintOverridesMap = */ Optional.absent());
+              return null;
+            }),
+        sequentialControlExecutor);
+  }
+
+  @Override
+  public ListenableFuture<Void> schedulePeriodicBackgroundTasks(
+      Optional<Map<String, ConstraintOverrides>> constraintOverridesMap) {
+    return futureSerializer.submit(
+        propagateCallable(
+            () -> {
+              schedulePeriodicTasksInternal(constraintOverridesMap);
+              return null;
+            }),
+        sequentialControlExecutor);
+  }
+
+  private void schedulePeriodicTasksInternal(
+      Optional<Map<String, ConstraintOverrides>> constraintOverridesMap) {
+    if (!taskSchedulerOptional.isPresent()) {
+      LogUtil.e(
+          "%s: Called schedulePeriodicTasksInternal when taskScheduler is not provided.", TAG);
+      return;
+    }
+
+    TaskScheduler taskScheduler = taskSchedulerOptional.get();
+
+    // Schedule task that runs on charging without any network, every 6 hours.
+    taskScheduler.schedulePeriodicTask(
+        TaskScheduler.CHARGING_PERIODIC_TASK,
+        flags.chargingGcmTaskPeriod(),
+        NetworkState.NETWORK_STATE_ANY,
+        getConstraintOverrides(constraintOverridesMap, TaskScheduler.CHARGING_PERIODIC_TASK));
+
+    // Schedule maintenance task that runs on charging, once every day.
+    // This task should run even if mdd is disabled, to handle cleanup.
+    taskScheduler.schedulePeriodicTask(
+        TaskScheduler.MAINTENANCE_PERIODIC_TASK,
+        flags.maintenanceGcmTaskPeriod(),
+        NetworkState.NETWORK_STATE_ANY,
+        getConstraintOverrides(constraintOverridesMap, TaskScheduler.MAINTENANCE_PERIODIC_TASK));
+
+    // Schedule task that runs on cellular+charging, every 6 hours.
+    taskScheduler.schedulePeriodicTask(
+        TaskScheduler.CELLULAR_CHARGING_PERIODIC_TASK,
+        flags.cellularChargingGcmTaskPeriod(),
+        NetworkState.NETWORK_STATE_CONNECTED,
+        getConstraintOverrides(
+            constraintOverridesMap, TaskScheduler.CELLULAR_CHARGING_PERIODIC_TASK));
+
+    // Schedule task that runs on wifi+charging, every 6 hours.
+    taskScheduler.schedulePeriodicTask(
+        TaskScheduler.WIFI_CHARGING_PERIODIC_TASK,
+        flags.wifiChargingGcmTaskPeriod(),
+        NetworkState.NETWORK_STATE_UNMETERED,
+        getConstraintOverrides(constraintOverridesMap, TaskScheduler.WIFI_CHARGING_PERIODIC_TASK));
+  }
+
+  private static Optional<ConstraintOverrides> getConstraintOverrides(
+      Optional<Map<String, ConstraintOverrides>> constraintOverridesMap,
+      String maintenancePeriodicTask) {
+    return constraintOverridesMap.isPresent()
+        ? Optional.fromNullable(constraintOverridesMap.get().get(maintenancePeriodicTask))
+        : Optional.absent();
+  }
+
+  @Override
+  public ListenableFuture<Void> handleTask(String tag) {
+    // All work done here that touches metadata (MobileDataDownloadManager) should be serialized
+    // through sequentialControlExecutor.
+    switch (tag) {
+      case TaskScheduler.MAINTENANCE_PERIODIC_TASK:
+        return futureSerializer.submitAsync(
+            mobileDataDownloadManager::maintenance, sequentialControlExecutor);
+
+      case TaskScheduler.CHARGING_PERIODIC_TASK:
+        ListenableFuture<Void> refreshFileGroupsFuture = refreshFileGroups();
+        return Futures.transformAsync(
+            refreshFileGroupsFuture,
+            propagateAsyncFunction(
+                v -> mobileDataDownloadManager.verifyAllPendingGroups(customFileGroupValidator)),
+            sequentialControlExecutor);
+
+      case TaskScheduler.CELLULAR_CHARGING_PERIODIC_TASK:
+        return refreshAndDownload(false /*onWifi*/);
+
+      case TaskScheduler.WIFI_CHARGING_PERIODIC_TASK:
+        return refreshAndDownload(true /*onWifi*/);
+
+      default:
+        LogUtil.d("%s: gcm task doesn't belong to MDD", TAG);
+        return Futures.immediateFailedFuture(
+            new IllegalArgumentException("Unknown task tag sent to MDD.handleTask() " + tag));
+    }
+  }
+
+  private ListenableFuture<Void> refreshAndDownload(boolean onWifi) {
+    // We will do 2 passes to support 2-step downloads. In each step, we will refresh and then
+    // download.
+    return FluentFuture.from(refreshFileGroups())
+        .transformAsync(
+            v ->
+                mobileDataDownloadManager.downloadAllPendingGroups(
+                    onWifi, customFileGroupValidator),
+            sequentialControlExecutor)
+        .transformAsync(v -> refreshFileGroups(), sequentialControlExecutor)
+        .transformAsync(
+            v ->
+                mobileDataDownloadManager.downloadAllPendingGroups(
+                    onWifi, customFileGroupValidator),
+            sequentialControlExecutor);
+  }
+
+  private ListenableFuture<Void> refreshFileGroups() {
+    List<ListenableFuture<Void>> refreshFutures = new ArrayList<>();
+    for (FileGroupPopulator fileGroupPopulator : fileGroupPopulatorList) {
+      refreshFutures.add(fileGroupPopulator.refreshFileGroups(this));
+    }
+
+    return Futures.whenAllComplete(refreshFutures).call(() -> null, sequentialControlExecutor);
+  }
+
+  @Override
+  public ListenableFuture<Void> maintenance() {
+    return handleTask(TaskScheduler.MAINTENANCE_PERIODIC_TASK);
+  }
+
+  @Override
+  public ListenableFuture<Void> clear() {
+    return futureSerializer.submitAsync(
+        mobileDataDownloadManager::clear, sequentialControlExecutor);
+  }
+
+  // incompatible argument for parameter msg of e.
+  // incompatible types in return.
+  @Override
+  public String getDebugInfoAsString() {
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    PrintWriter writer = new PrintWriter(out);
+    try {
+      // Okay to block here because this method is for debugging only.
+      mobileDataDownloadManager.dump(writer).get(DUMP_DEBUG_INFO_TIMEOUT, TimeUnit.SECONDS);
+      writer.println("==== MOBSTORE_DEBUG_INFO ====");
+      writer.print(fileStorage.getDebugInfo());
+    } catch (ExecutionException | TimeoutException e) {
+      String errString = String.format("%s: Couldn't get debug info: %s", TAG, e);
+      LogUtil.e(errString);
+      return errString;
+    } catch (InterruptedException e) {
+      // see <internal>
+      Thread.currentThread().interrupt();
+      String errString = String.format("%s: Couldn't get debug info: %s", TAG, e);
+      LogUtil.e(errString);
+      return errString;
+    }
+    writer.flush();
+    return out.toString();
+  }
+
+  @Override
+  public ListenableFuture<Void> reportUsage(UsageEvent usageEvent) {
+    eventLogger.logMddUsageEvent(createFileGroupDetails(usageEvent.clientFileGroup()), null);
+
+    return Futures.immediateVoidFuture();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/RemoveFileGroupRequest.java b/java/com/google/android/libraries/mobiledatadownload/RemoveFileGroupRequest.java
new file mode 100644
index 0000000..7075f5d
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/RemoveFileGroupRequest.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+import android.accounts.Account;
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Optional;
+import javax.annotation.concurrent.Immutable;
+
+/** Request to remove file group from MDD. */
+@AutoValue
+@Immutable
+public abstract class RemoveFileGroupRequest {
+  RemoveFileGroupRequest() {}
+
+  public abstract String groupName();
+
+  public abstract Optional<Account> accountOptional();
+
+  public abstract Optional<String> variantIdOptional();
+
+  public abstract boolean pendingOnly();
+
+  public static Builder newBuilder() {
+    return new AutoValue_RemoveFileGroupRequest.Builder().setPendingOnly(false);
+  }
+
+  /** Builder for {@link RemoveFileGroupRequest}. */
+  @AutoValue.Builder
+  public abstract static class Builder {
+    Builder() {}
+
+    /** Sets the name of the file group, which is required. */
+    public abstract Builder setGroupName(String groupName);
+
+    /** Sets the account that is associated to the file group, which is optional. */
+    public abstract Builder setAccountOptional(Optional<Account> accountOptional);
+
+    /**
+     * Sets the variant id that is associated to the file group.
+     *
+     * <p>This parameter is only required to remove a group that was added to MDD with a variantId
+     * specified (see {@link AddFileGroupRequest.Builder#setVariantIdOptional}).
+     *
+     * <p>If a variantId was specified when adding the group to MDD and is not included here, the
+     * request will result in a no-op.
+     *
+     * <p>Similarly, if a variantId was <em>not</em> specified when adding the group to MDD and
+     * <em>is</em> included here, the request will also result in a no-op.
+     */
+    public abstract Builder setVariantIdOptional(Optional<String> variantIdOptional);
+
+    /**
+     * When true, only remove the pending version of the file group, leaving the active downloaded
+     * version untouched.
+     */
+    public abstract Builder setPendingOnly(boolean pendingOnly);
+
+    public abstract RemoveFileGroupRequest build();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/RemoveFileGroupsByFilterRequest.java b/java/com/google/android/libraries/mobiledatadownload/RemoveFileGroupsByFilterRequest.java
new file mode 100644
index 0000000..306c23d
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/RemoveFileGroupsByFilterRequest.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+import android.accounts.Account;
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Optional;
+import com.google.mobiledatadownload.DownloadConfigProto.DataFileGroup;
+import javax.annotation.concurrent.Immutable;
+
+/**
+ * Request to remove file groups from MDD that match given filters.
+ *
+ * <p>With the exception of account filtering (see below), only the filters provided will be applied
+ * to file groups in MDD. That is, a file groups will be removed if and only if it matches all
+ * <em>provided</em> filters. See each filter setter description for more details on how filtering
+ * will be performed.
+ *
+ * <p>NOTE: Account filtering is a considered special case as filtering is performed <b>both</b>
+ * when account is provided and when it is absent. see {@link Builder#setAccountOptional} for more
+ * details on account filtering.
+ */
+@AutoValue
+@Immutable
+public abstract class RemoveFileGroupsByFilterRequest {
+  RemoveFileGroupsByFilterRequest() {}
+
+  public abstract Optional<Account> accountOptional();
+
+  public static Builder newBuilder() {
+    return new AutoValue_RemoveFileGroupsByFilterRequest.Builder();
+  }
+
+  /** Builder for {@link RemoveFileGroupsByFilterRequest}. */
+  @AutoValue.Builder
+  public abstract static class Builder {
+    Builder() {}
+
+    /**
+     * Sets the {@link Account} that must match filtered {@link DataFileGroup}s.
+     *
+     * <p>Similar to other MDD APIs, file groups that are <em>account-dependent</em> must have that
+     * account provided in order to perform a requested operation; file groups that are
+     * <em>account-independent</em> must have no account provided in order to perform a requested
+     * operation.
+     *
+     * <p>Account filtering works the same way: if an account is provided, only
+     * <em>account-dependent</em> file groups matching that account are considered for removal; if
+     * an account is <b>not</b> provided, only <em>account-independent</em> file groups are
+     * considered for removal.
+     */
+    public abstract Builder setAccountOptional(Optional<Account> accountOptional);
+
+    public abstract RemoveFileGroupsByFilterRequest build();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/RemoveFileGroupsByFilterResponse.java b/java/com/google/android/libraries/mobiledatadownload/RemoveFileGroupsByFilterResponse.java
new file mode 100644
index 0000000..b54db78
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/RemoveFileGroupsByFilterResponse.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+import com.google.auto.value.AutoValue;
+import javax.annotation.concurrent.Immutable;
+
+/** Response of MDD's {@link MobileDataDownload#removeFileGroupsByFilter} API. */
+@AutoValue
+@Immutable
+public abstract class RemoveFileGroupsByFilterResponse {
+  RemoveFileGroupsByFilterResponse() {}
+
+  public abstract int removedFileGroupsCount();
+
+  public static Builder newBuilder() {
+    return new AutoValue_RemoveFileGroupsByFilterResponse.Builder();
+  }
+
+  /** Builder for {@link RemoveFileGroupsByFilterResponse}. */
+  @AutoValue.Builder
+  public abstract static class Builder {
+    Builder() {}
+
+    /**
+     * Sets the number of file groups successfully removed by {@link
+     * MobileDataDownload#removeFileGroupsByFilter}.
+     */
+    public abstract Builder setRemovedFileGroupsCount(int removedFileGroupsCount);
+
+    public abstract RemoveFileGroupsByFilterResponse build();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/SilentFeedback.java b/java/com/google/android/libraries/mobiledatadownload/SilentFeedback.java
new file mode 100644
index 0000000..7bc5cc6
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/SilentFeedback.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+import com.google.errorprone.annotations.FormatMethod;
+import com.google.errorprone.annotations.FormatString;
+
+/** Responsible to send Silent Feebacks for MDD. */
+public interface SilentFeedback {
+  /** Asynchronously sends silent feedback with given throwable. */
+  @FormatMethod
+  void send(Throwable throwable, @FormatString String description, Object... args);
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/SingleFileDownloadListener.java b/java/com/google/android/libraries/mobiledatadownload/SingleFileDownloadListener.java
new file mode 100644
index 0000000..14b6a7c
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/SingleFileDownloadListener.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+import com.google.common.util.concurrent.ListenableFuture;
+
+/**
+ * Listener for {@link MobileDataDownload#downloadFile} to respond to download events.
+ *
+ * <p>Supports registering for download progress update. Callbacks will be executed
+ * on @MddControlExecutor. If you need to do heavy work, please offload to a background task.
+ */
+public interface SingleFileDownloadListener {
+  /**
+   * Will be triggered periodically with the current downloaded size of the being downloaded file.
+   * This could be used to show progressbar to users.
+   */
+  void onProgress(long currentSize);
+
+  /**
+   * This will be called when the download is completed successfully. MDD will keep the Foreground
+   * Download Service alive so that the onComplete can finish without being killed by Android.
+   */
+  ListenableFuture<Void> onComplete();
+
+  /** This will be called when the download failed. */
+  void onFailure(Throwable t);
+
+  /**
+   * Callback triggered when all downloads are in a state waiting for connectivity, and no download
+   * progress is happening until connectivity resumes.
+   */
+  void onPausedForConnectivity();
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/SingleFileDownloadRequest.java b/java/com/google/android/libraries/mobiledatadownload/SingleFileDownloadRequest.java
new file mode 100644
index 0000000..e21df2b
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/SingleFileDownloadRequest.java
@@ -0,0 +1,160 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+import android.net.Uri;
+import android.util.Pair;
+import com.google.android.libraries.mobiledatadownload.downloader.DownloadConstraints;
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Optional;
+import com.google.common.collect.ImmutableList;
+
+/**
+ * Request to download a single file.
+ *
+ * <p>This differs from {@link DownloadFileGroupRequest} in two main ways:
+ *
+ * <p>1) Only a single file is downloaded rather than a group of files.
+ *
+ * <p>2) MDD does NOT manage the file after download. The caller specifies that destination of the
+ * download and is responsible for managing the file after download.
+ */
+@AutoValue
+public abstract class SingleFileDownloadRequest {
+
+  // Default value for Traffic Tag if not set by clients.
+  // MDD will not tag the traffic if the TrafficTag is not set to a valid value (>0).
+  private static final int UNSPECIFIED_TRAFFIC_TAG = -1;
+
+  SingleFileDownloadRequest() {}
+
+  // The Destination File Uri to download the file at.
+  public abstract Uri destinationFileUri();
+
+  // The url to download the file from.
+  public abstract String urlToDownload();
+
+  // Conditions under which this file should be downloaded.
+  public abstract DownloadConstraints downloadConstraints();
+
+  /** If present, will receive download progress update. */
+  public abstract Optional<SingleFileDownloadListener> listenerOptional();
+
+  // Traffic tag used for this request.
+  // If not set, it will take the default value of UNSPECIFIED_TRAFFIC_TAG and MDD will not tag the
+  // traffic.
+  public abstract int trafficTag();
+
+  // The extra HTTP headers for this request.
+  public abstract ImmutableList<Pair<String, String>> extraHttpHeaders();
+
+  // The size of the being downloaded file in bytes.
+  // This is used to display the progressbar.
+  // If not specified, an indeterminate progressbar will be displayed.
+  // https://developer.android.com/reference/android/app/Notification.Builder.html#setProgress(int,%20int,%20boolean)
+  public abstract int fileSizeBytes();
+
+  // Used only by Foreground download.
+  // The Content Title of the associated Notification for this download.
+  public abstract String notificationContentTitle();
+
+  // Used only by Foreground download.
+  // If Present, the Content Text (description) of the associated Notification for this download.
+  // Otherwise, the Content Text will be the url to download.
+  public abstract Optional<String> notificationContentTextOptional();
+
+  // Whether to show the downloaded notification. If false, MDD will automatically remove this
+  // notification when the download finished.
+  public abstract boolean showDownloadedNotification();
+
+  public static Builder newBuilder() {
+    return new AutoValue_SingleFileDownloadRequest.Builder()
+        .setTrafficTag(UNSPECIFIED_TRAFFIC_TAG)
+        .setExtraHttpHeaders(ImmutableList.of())
+        .setFileSizeBytes(0)
+        .setShowDownloadedNotification(true)
+        .setDownloadConstraints(DownloadConstraints.NONE);
+  }
+
+  /** Builder for {@link DownloadRequest}. */
+  @AutoValue.Builder
+  public abstract static class Builder {
+    Builder() {}
+
+    /** Sets the destination file uri. */
+    public abstract Builder setDestinationFileUri(Uri fileUri);
+
+    /** Sets the url to download. */
+    public abstract Builder setUrlToDownload(String urlToDownload);
+
+    /** Sets the DowloadConstraints. */
+    public abstract Builder setDownloadConstraints(DownloadConstraints downloadConstraints);
+
+    /** Sets the optional download listener. If present, will receive download progress update. */
+    public abstract Builder setListenerOptional(
+        Optional<SingleFileDownloadListener> listenerOptional);
+
+    /** Sets the traffic tag for this request. */
+    public abstract Builder setTrafficTag(int trafficTag);
+
+    /** Sets the extra HTTP headers for this request. */
+    public abstract Builder setExtraHttpHeaders(
+        ImmutableList<Pair<String, String>> extraHttpHeaders);
+
+    /**
+     * The size of the being downloaded file in bytes. This is used to display the progressbar. If
+     * not specified, a indeterminate progressbar will be displayed.
+     * https://developer.android.com/reference/android/app/Notification.Builder.html#setProgress(int,%20int,%20boolean)
+     */
+    public abstract Builder setFileSizeBytes(int fileSizeBytes);
+
+    /** Sets the Notification Content Tile which will be used for foreground download */
+    public abstract Builder setNotificationContentTitle(String notificationContentTitle);
+
+    /**
+     * Sets the Notification Context Text which will be used for foreground downloads.
+     *
+     * <p>If not set, the url to download will be used instead.
+     */
+    public abstract Builder setNotificationContentTextOptional(
+        Optional<String> notificationContentTextOptional);
+
+    /**
+     * Sets to show Downloaded Notification after the download finished successfully. This is only
+     * be used for foreground download. Default value is to show the downloaded notification.
+     */
+    public abstract Builder setShowDownloadedNotification(boolean showDownloadedNotification);
+
+    /** Builds {@link SingleFileDownloadRequest}. */
+    public final SingleFileDownloadRequest build() {
+      // If notification content title is not provided, use urlToDownload as a fallback
+      if (!notificationContentTitle().isPresent()) {
+        setNotificationContentTitle(urlToDownload());
+      }
+      // Use AutoValue's generated build to finish building.
+      return autoBuild();
+    }
+
+    // private getter generated by AutoValue for access in build().
+    abstract String urlToDownload();
+
+    // private getter generated by AutoValue for access in build().
+    abstract Optional<String> notificationContentTitle();
+
+    // private build method to be generated by AutoValue.
+    abstract SingleFileDownloadRequest autoBuild();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/TaskScheduler.java b/java/com/google/android/libraries/mobiledatadownload/TaskScheduler.java
new file mode 100644
index 0000000..c06c22b
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/TaskScheduler.java
@@ -0,0 +1,151 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Optional;
+
+/** Interface for task scheduling */
+public interface TaskScheduler {
+
+  /**
+   * Tag for daily mdd maintenance task, that *should* be run once and only once every 24 hours.
+   *
+   * <p>By default, this task runs on charging.
+   */
+  String MAINTENANCE_PERIODIC_TASK = "MDD.MAINTENANCE.PERIODIC.GCM.TASK";
+
+  /**
+   * Tag for mdd task that doesn't require any network. This is used to perform some routine
+   * operation that do not require network, in case a device doesn't connect to any network for a
+   * long time.
+   *
+   * <p>By default, this task runs on charging once every 6 hours.
+   */
+  String CHARGING_PERIODIC_TASK = "MDD.CHARGING.PERIODIC.TASK";
+
+  /**
+   * Tag for mdd task that runs on cellular network. This is used to primarily download file groups
+   * that can be download on cellular network.
+   *
+   * <p>By default, this task runs on charging once every 6 hours. This task can be skipped if
+   * nothing is downloaded on cellular.
+   */
+  String CELLULAR_CHARGING_PERIODIC_TASK = "MDD.CELLULAR.CHARGING.PERIODIC.TASK";
+
+  /**
+   * Tag for mdd task that runs on wifi network. This is used to primarily download file groups that
+   * can be download only on wifi network.
+   *
+   * <p>By default, this task runs on charging once every 6 hours. This task can be skipped if
+   * nothing is restricted to wifi.
+   */
+  String WIFI_CHARGING_PERIODIC_TASK = "MDD.WIFI.CHARGING.PERIODIC.TASK";
+
+  /** Required network state of the device when to run the task. */
+  enum NetworkState {
+    // Metered or unmetered network available.
+    NETWORK_STATE_CONNECTED,
+
+    // Unmetered network available.
+    NETWORK_STATE_UNMETERED,
+
+    // Network not required.
+    NETWORK_STATE_ANY,
+  }
+
+  /** ConstraintOverrides to allow clients to override background task constraints. */
+  @AutoValue
+  abstract class ConstraintOverrides {
+    ConstraintOverrides() {}
+
+    public abstract boolean requiresDeviceIdle();
+
+    public abstract boolean requiresCharging();
+
+    public abstract boolean requiresBatteryNotLow();
+
+    public static Builder newBuilder() {
+      // Setting default values.
+      return new AutoValue_TaskScheduler_ConstraintOverrides.Builder()
+          .setRequiresDeviceIdle(true)
+          .setRequiresCharging(true)
+          .setRequiresBatteryNotLow(false);
+    }
+
+    /** Builder for {@link ConstraintOverrides}. */
+    @AutoValue.Builder
+    public abstract static class Builder {
+
+      Builder() {}
+
+      /**
+       * Sets whether device should be idle for the MDD task to run. The default value is {@code
+       * true}.
+       *
+       * @param requiresDeviceIdle {@code true} if device must be idle for the work to run
+       */
+      public abstract Builder setRequiresDeviceIdle(boolean requiresDeviceIdle);
+
+      /**
+       * Sets whether device should be charging for the MDD task to run. The default value is {@code
+       * true}.
+       *
+       * @param requiresCharging {@code true} if device must be charging for the work to run
+       */
+      public abstract Builder setRequiresCharging(boolean requiresCharging);
+
+      /**
+       * Sets whether device battery should be at an acceptable level for the MDD task to run. The
+       * default value is {@code false}.
+       *
+       * @param requiresBatteryNotLow {@code true} if the battery should be at an acceptable level
+       *     for the work to run
+       */
+      public abstract Builder setRequiresBatteryNotLow(boolean requiresBatteryNotLow);
+
+      public abstract ConstraintOverrides build();
+    }
+  }
+
+  /**
+   * Schedule a periodic using one of GCM, FJD or Work Manager. If you need to override idle and/or
+   * charging requirements, call the method that takes in the constraintOverrides instead.
+   *
+   * @param tag tag of the scheduled task.
+   * @param period period with which the scheduled task should be run.
+   * @param networkState network state when to run the task.
+   */
+  void schedulePeriodicTask(String tag, long period, NetworkState networkState);
+
+  /**
+   * Schedule a periodic using Work Manager.
+   *
+   * @param tag tag of the scheduled task.
+   * @param period period with which the scheduled task should be run.
+   * @param networkState network state when to run the task.
+   * @param constraintOverrides allow overriding the charging and idle requirements.
+   */
+  default void schedulePeriodicTask(
+      String tag,
+      long period,
+      NetworkState networkState,
+      Optional<ConstraintOverrides> constraintOverrides) {
+    // Default implementation will not override any constraints. Without this, we will have to
+    // update all clients.
+    schedulePeriodicTask(tag, period, networkState);
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/TimeSource.java b/java/com/google/android/libraries/mobiledatadownload/TimeSource.java
new file mode 100644
index 0000000..d632382
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/TimeSource.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+/**
+ * Interface through which the SystemClock can be read.
+ *
+ * <p>This interface is analogous to {@code com.google.common.time.TimeSource#now#toEpochMilli}
+ * without the dependency on Java8.
+ */
+public interface TimeSource {
+  /** Returns the current system time in milliseconds since January 1, 1970 00:00:00 UTC. */
+  long currentTimeMillis();
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/UsageEvent.java b/java/com/google/android/libraries/mobiledatadownload/UsageEvent.java
new file mode 100644
index 0000000..0ec6a60
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/UsageEvent.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+import com.google.auto.value.AutoValue;
+import com.google.mobiledatadownload.ClientConfigProto.ClientFileGroup;
+
+/**
+ * Represents errors with or usage of a file group. This information will be reported to MDD which
+ * will log it to clearcut.
+ */
+@AutoValue
+public abstract class UsageEvent {
+  public abstract int eventCode();
+
+  public abstract long appVersion();
+
+  public abstract ClientFileGroup clientFileGroup();
+
+  public static Builder builder() {
+    return new AutoValue_UsageEvent.Builder();
+  }
+
+  /** Builder for UsageEvent. */
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder setEventCode(int eventCode);
+
+    public abstract Builder setAppVersion(long appVersion);
+
+    public abstract Builder setClientFileGroup(ClientFileGroup clientFileGroup);
+
+    public abstract UsageEvent build();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/account/AccountManagerAccountSource.java b/java/com/google/android/libraries/mobiledatadownload/account/AccountManagerAccountSource.java
new file mode 100644
index 0000000..b30f5cb
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/account/AccountManagerAccountSource.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.account;
+
+import android.accounts.Account;
+import android.accounts.AccountManager;
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.os.Build.VERSION_CODES;
+import com.google.android.libraries.mobiledatadownload.AccountSource;
+import com.google.common.collect.ImmutableList;
+
+/** An MDD AccountSource backed by AccountManager with type "com.google". */
+public final class AccountManagerAccountSource implements AccountSource {
+  private static final String GOOGLE_ACCOUNT_TYPE = "com.google";
+
+  private final String packageName;
+  private final AccountManager accountManager;
+
+  public AccountManagerAccountSource(Context context) {
+    this.packageName = context.getPackageName();
+    this.accountManager = AccountManager.get(context);
+  }
+
+  @TargetApi(VERSION_CODES.JELLY_BEAN_MR2)
+  @Override
+  public ImmutableList<Account> getAllAccounts() {
+    Account[] accounts =
+        accountManager.getAccountsByTypeForPackage(GOOGLE_ACCOUNT_TYPE, packageName);
+    return ImmutableList.copyOf(accounts);
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/account/AccountUtil.java b/java/com/google/android/libraries/mobiledatadownload/account/AccountUtil.java
new file mode 100644
index 0000000..012226e
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/account/AccountUtil.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.account;
+
+import android.accounts.Account;
+import com.google.android.libraries.mobiledatadownload.internal.MddConstants;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil;
+import javax.annotation.Nullable;
+
+/** Utils to help with account manipulation. */
+public final class AccountUtil {
+  private static final String TAG = "AccountUtil";
+  private static final String ACCOUNT_DELIMITER = ":";
+
+  private AccountUtil() {}
+
+  /**
+   * Creates {@link Account} from name and type after validation.
+   *
+   * @return The account instance with the given name and type. Returns null if there is any error.
+   */
+  @Nullable
+  public static Account create(String name, String type) {
+    if (!validate(name) || !validate(type)) {
+      LogUtil.e("%s: Unable to create Account with name = '%s', type = '%s'", TAG, name, type);
+      return null;
+    }
+    return new Account(name, type);
+  }
+
+  /** Serializes an {@link Account} into a string. */
+  public static String serialize(Account account) {
+    return account.type + ACCOUNT_DELIMITER + account.name;
+  }
+
+  /**
+   * Deserializes a string into an {@link Account}.
+   *
+   * @return The account parsed from string. Returns null if there is any error during parse.
+   */
+  @Nullable
+  public static Account deserialize(String accountStr) {
+    int splitIndex = accountStr.indexOf(ACCOUNT_DELIMITER);
+    if (splitIndex < 0) {
+      LogUtil.e("%s: Unable to parse Account with string = '%s'", TAG, accountStr);
+      return null;
+    }
+    String type = accountStr.substring(0, splitIndex);
+    String name = accountStr.substring(splitIndex + 1);
+    return create(name, type);
+  }
+
+  /**
+   * Validates whether the field is valid. Returns false if the field is empty or contains delimiter
+   * or contains split char.
+   */
+  private static boolean validate(String field) {
+    return field != null
+        && !field.isEmpty()
+        && !field.contains(ACCOUNT_DELIMITER)
+        && !field.contains(MddConstants.SPLIT_CHAR);
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/account/BUILD b/java/com/google/android/libraries/mobiledatadownload/account/BUILD
new file mode 100644
index 0000000..cd9bd61
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/account/BUILD
@@ -0,0 +1,40 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_library")
+
+package(
+    default_visibility = [
+        "//visibility:public",
+    ],
+    licenses = ["notice"],
+)
+
+android_library(
+    name = "AccountUtil",
+    srcs = ["AccountUtil.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/internal:MddConstants",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LogUtil",
+        "@com_google_code_findbugs_jsr305",
+    ],
+)
+
+android_library(
+    name = "AccountManagerAccountSource",
+    srcs = ["AccountManagerAccountSource.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload:AccountSource",
+        "@com_google_guava_guava",
+    ],
+)
diff --git a/java/com/google/android/libraries/mobiledatadownload/annotations/BUILD b/java/com/google/android/libraries/mobiledatadownload/annotations/BUILD
new file mode 100644
index 0000000..9bc3d32
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/annotations/BUILD
@@ -0,0 +1,30 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_library")
+
+package(
+    default_visibility = [
+        "//visibility:public",
+    ],
+    licenses = ["notice"],
+)
+
+android_library(
+    name = "annotations",
+    srcs = glob(["*.java"]),
+    deps = [
+        "@com_google_dagger",
+        "@javax_inject",
+    ],
+)
diff --git a/java/com/google/android/libraries/mobiledatadownload/annotations/ForegroundService.java b/java/com/google/android/libraries/mobiledatadownload/annotations/ForegroundService.java
new file mode 100644
index 0000000..ef817fd
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/annotations/ForegroundService.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.annotations;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Target;
+import javax.inject.Qualifier;
+
+/** A Class reference to the ForegroundService that should be used for MDD Foreground downloads. */
+@Target({ElementType.PARAMETER, ElementType.METHOD, ElementType.FIELD})
+@Qualifier
+public @interface ForegroundService {}
diff --git a/java/com/google/android/libraries/mobiledatadownload/annotations/InstanceId.java b/java/com/google/android/libraries/mobiledatadownload/annotations/InstanceId.java
new file mode 100644
index 0000000..f5333e3
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/annotations/InstanceId.java
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.annotations;
+
+import javax.inject.Qualifier;
+
+/** Qualifier for the InstanceId */
+@Qualifier
+public @interface InstanceId {}
diff --git a/java/com/google/android/libraries/mobiledatadownload/annotations/MddControlExecutor.java b/java/com/google/android/libraries/mobiledatadownload/annotations/MddControlExecutor.java
new file mode 100644
index 0000000..6f66a48
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/annotations/MddControlExecutor.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.annotations;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Target;
+import javax.inject.Qualifier;
+
+/** An executor on which MDD runs control execution flow which will touch I/O. */
+@Target({ElementType.PARAMETER, ElementType.METHOD, ElementType.FIELD})
+@Qualifier
+public @interface MddControlExecutor {}
diff --git a/java/com/google/android/libraries/mobiledatadownload/annotations/MddDownloadExecutor.java b/java/com/google/android/libraries/mobiledatadownload/annotations/MddDownloadExecutor.java
new file mode 100644
index 0000000..21676bb
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/annotations/MddDownloadExecutor.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.annotations;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Target;
+import javax.inject.Qualifier;
+
+/**
+ * An executor on which MDD runs downloading tasks which will touch I/O. This should be bound
+ * to @BlockingExecutor or a new thread pool because it blocks inside <internal>. More detail in
+ * <internal>
+ */
+@Target({ElementType.PARAMETER, ElementType.METHOD, ElementType.FIELD})
+@Qualifier
+public @interface MddDownloadExecutor {}
diff --git a/java/com/google/android/libraries/mobiledatadownload/annotations/MddWorkerFactory.java b/java/com/google/android/libraries/mobiledatadownload/annotations/MddWorkerFactory.java
new file mode 100644
index 0000000..ee7c009
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/annotations/MddWorkerFactory.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.annotations;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Target;
+import javax.inject.Qualifier;
+
+/** Annotation for custom MddWorkerFactory that can create Mdd Worker. */
+@Target({ElementType.PARAMETER, ElementType.METHOD, ElementType.FIELD})
+@Qualifier
+public @interface MddWorkerFactory {}
diff --git a/java/com/google/android/libraries/mobiledatadownload/annotations/MdiLocationManager.java b/java/com/google/android/libraries/mobiledatadownload/annotations/MdiLocationManager.java
new file mode 100644
index 0000000..442dc3d
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/annotations/MdiLocationManager.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.annotations;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Target;
+import javax.inject.Qualifier;
+
+/** LocationManager provided to Mdi library */
+@Target({ElementType.PARAMETER, ElementType.METHOD, ElementType.FIELD})
+@Qualifier
+public @interface MdiLocationManager {}
diff --git a/java/com/google/android/libraries/mobiledatadownload/annotations/SocketTrafficTag.java b/java/com/google/android/libraries/mobiledatadownload/annotations/SocketTrafficTag.java
new file mode 100644
index 0000000..e05fec0
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/annotations/SocketTrafficTag.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.annotations;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Target;
+import javax.inject.Qualifier;
+
+/** Used to tag traffic through a Socket. */
+@Target({ElementType.PARAMETER, ElementType.METHOD, ElementType.FIELD})
+@Qualifier
+public @interface SocketTrafficTag {}
diff --git a/java/com/google/android/libraries/mobiledatadownload/delta/BUILD b/java/com/google/android/libraries/mobiledatadownload/delta/BUILD
new file mode 100644
index 0000000..50556e2
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/delta/BUILD
@@ -0,0 +1,29 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_library")
+
+package(
+    default_visibility = [
+        "//visibility:public",
+    ],
+    licenses = ["notice"],
+)
+
+android_library(
+    name = "DeltaDecoder",
+    srcs = ["DeltaDecoder.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+    ],
+)
diff --git a/java/com/google/android/libraries/mobiledatadownload/delta/DeltaDecoder.java b/java/com/google/android/libraries/mobiledatadownload/delta/DeltaDecoder.java
new file mode 100644
index 0000000..b2479e3
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/delta/DeltaDecoder.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.delta;
+
+import android.net.Uri;
+import com.google.mobiledatadownload.internal.MetadataProto.DeltaFile.DiffDecoder;
+import java.io.IOException;
+
+/**
+ * Delta decoder Interface.
+ *
+ * <p>A delta decoder is to generate full content with a much smaller delta content providing a base
+ * content.
+ */
+public interface DeltaDecoder {
+
+  /** Throws when delta decode fails. */
+  class DeltaDecodeException extends IOException {
+    public DeltaDecodeException(Throwable cause) {
+      super(cause);
+    }
+
+    public DeltaDecodeException(String msg) {
+      super(msg);
+    }
+  }
+
+  /**
+   * Decode file from base file and delta file and writes to the target uri.
+   *
+   * @param baseUri The input base file URI
+   * @param deltaUri The input delta file URI
+   * @param targetUri The target decoded output file URI
+   * @throws DeltaDecodeException
+   */
+  void decode(Uri baseUri, Uri deltaUri, Uri targetUri) throws DeltaDecodeException;
+
+  /** Get the supported delta decoder name. */
+  DiffDecoder getDecoderName();
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/downloader/BUILD b/java/com/google/android/libraries/mobiledatadownload/downloader/BUILD
new file mode 100644
index 0000000..3831c2e
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/downloader/BUILD
@@ -0,0 +1,50 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_library")
+
+package(
+    default_visibility = [
+        "//visibility:public",
+    ],
+    licenses = ["notice"],
+)
+
+android_library(
+    name = "FileDownloader",
+    srcs = [
+        "CheckContentChangeRequest.java",
+        "CheckContentChangeResponse.java",
+        "DownloadConstraints.java",
+        "DownloadRequest.java",
+        "FileDownloader.java",
+        "InlineDownloadParams.java",
+        "MultiSchemeFileDownloader.java",
+        "OAuthTokenProvider.java",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload:DownloadException",
+        "//java/com/google/android/libraries/mobiledatadownload:FileSource",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:MddConstants",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LogUtil",
+        "@androidx_annotation_annotation",
+        "@com_google_auto_value",
+        "@com_google_code_findbugs_jsr305",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "Constants",
+    srcs = ["Constants.java"],
+)
diff --git a/java/com/google/android/libraries/mobiledatadownload/downloader/CheckContentChangeRequest.java b/java/com/google/android/libraries/mobiledatadownload/downloader/CheckContentChangeRequest.java
new file mode 100644
index 0000000..7ba620a
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/downloader/CheckContentChangeRequest.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.downloader;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Optional;
+
+/** Request for checking content change. */
+@AutoValue
+public abstract class CheckContentChangeRequest {
+
+  CheckContentChangeRequest() {}
+
+  /** The target url. */
+  public abstract String url();
+
+  /**
+   * The optional cached ETag stored on device, which was previously fetched from the url. When the
+   * value is {@like Optional#absent()}, it means either it is the first fetch, or the url does not
+   * respond with ETag in the last fetch.
+   */
+  public abstract Optional<String> cachedETagOptional();
+
+  public static CheckContentChangeRequest.Builder newBuilder() {
+    return new AutoValue_CheckContentChangeRequest.Builder();
+  }
+
+  /** Builder for {@link CheckContentChangeRequest} */
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    Builder() {}
+
+    /** Sets the url. */
+    public abstract Builder setUrl(String url);
+
+    /** Sets the cached ETag. */
+    public abstract Builder setCachedETagOptional(Optional<String> cachedETagOptional);
+
+    public abstract CheckContentChangeRequest build();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/downloader/CheckContentChangeResponse.java b/java/com/google/android/libraries/mobiledatadownload/downloader/CheckContentChangeResponse.java
new file mode 100644
index 0000000..c3506e8
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/downloader/CheckContentChangeResponse.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.downloader;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Optional;
+
+/** Response for checking content change. */
+@AutoValue
+public abstract class CheckContentChangeResponse {
+
+  CheckContentChangeResponse() {}
+
+  /** Whether or not the content is changed. */
+  public abstract boolean contentChanged();
+
+  /**
+   * The optional fresh ETag that is being fetched from the url. When the value euquals {@link
+   * Optional#absent()}, it means that the target url does not support ETag.
+   */
+  public abstract Optional<String> freshETagOptional();
+
+  public static Builder newBuilder() {
+    return new AutoValue_CheckContentChangeResponse.Builder();
+  }
+
+  /** Builder for {@link CheckContentChangeResponse}. */
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    Builder() {}
+
+    /** Sets whether the content is changed, which is required. */
+    public abstract Builder setContentChanged(boolean contentChanged);
+
+    /** Sets the fresh ETag. */
+    public abstract Builder setFreshETagOptional(Optional<String> freshETagOptional);
+
+    public abstract CheckContentChangeResponse build();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/downloader/Constants.java b/java/com/google/android/libraries/mobiledatadownload/downloader/Constants.java
new file mode 100644
index 0000000..dacf2a6
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/downloader/Constants.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.downloader;
+
+/** Network related constants. */
+public final class Constants {
+
+  private Constants() {}
+
+  /** The HTTP {@code HEAD} request method name. */
+  public static final String HEAD_REQUEST_METHOD = "HEAD";
+
+  /** The HTTP {@code ETag} header field name. This is used to detect content change. */
+  public static final String ETAG_HEADER = "ETag";
+
+  /**
+   * The HTTP {@code If-None-Match} header field name. This is used to send ETag to server to detect
+   * content change.
+   */
+  public static final String IF_NONE_MATCH_HEADER = "If-None-Match";
+
+  public static final int HTTP_RESPONSE_OK = 200;
+  public static final int HTTP_RESPONSE_NOT_MODIFIED = 304;
+  public static final int HTTP_RESPONSE_NOT_FOUND = 404;
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/downloader/DownloadConstraints.java b/java/com/google/android/libraries/mobiledatadownload/downloader/DownloadConstraints.java
new file mode 100644
index 0000000..e6489c9
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/downloader/DownloadConstraints.java
@@ -0,0 +1,119 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.downloader;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
+import java.util.EnumSet;
+import java.util.Set;
+
+/** Possible constraints that a download request requires. */
+@AutoValue
+public abstract class DownloadConstraints {
+
+  DownloadConstraints() {}
+
+  /**
+   * Special value of {@code DownloadConstraints}. If this value is specified, no constraint checks
+   * are performed, even if no network is present at all!
+   */
+  public static final DownloadConstraints NONE =
+      DownloadConstraints.builder()
+          .setRequiredNetworkTypes(EnumSet.noneOf(NetworkType.class))
+          .setRequireUnmeteredNetwork(false)
+          .build();
+
+  /**
+   * Common value to indicate that the active network must be unmetered. This value permits any
+   * network type as long as it doesn't indicate it is metered in some way.
+   */
+  public static final DownloadConstraints NETWORK_UNMETERED =
+      DownloadConstraints.builder()
+          .setRequiredNetworkTypes(EnumSet.of(NetworkType.ANY))
+          .setRequireUnmeteredNetwork(true)
+          .build();
+
+  /**
+   * Common value to indicate that the required network must simply be connected in some way, and
+   * otherwise doesn't have any restrictions. Any network type is allowed.
+   *
+   * <p>This is the default value for download requests.
+   */
+  public static final DownloadConstraints NETWORK_CONNECTED =
+      DownloadConstraints.builder()
+          .setRequiredNetworkTypes(EnumSet.of(NetworkType.ANY))
+          .setRequireUnmeteredNetwork(false)
+          .build();
+
+  /**
+   * The type of network that is required. This is a subset of the network types enumerated by
+   * {@link android.net.ConnectivityManager}.
+   */
+  public enum NetworkType {
+    /** Special network type to allow any type of network, even if it not one of the known types. */
+    ANY,
+    /** Equivalent to {@link android.net.NetworkCapabilities#TRANSPORT_BLUETOOTH} */
+    BLUETOOTH,
+    /** Equivalent to {@link android.net.NetworkCapabilities#TRANSPORT_ETHERNET} */
+    ETHERNET,
+    /** Equivalent to {@link android.net.NetworkCapabilities#TRANSPORT_CELLULAR} */
+    CELLULAR,
+    /** Equivalent to {@link android.net.NetworkCapabilities#TRANSPORT_WIFI} */
+    WIFI,
+  }
+
+  /**
+   * Whether the connection must be unmetered to pass connectivity checks. See {@link
+   * androidx.core.net.ConnectivityManagerCompat#isActiveNetworkMetered} for more details on this
+   * variable.
+   *
+   * <p>False by default.
+   */
+  public abstract boolean requireUnmeteredNetwork();
+
+  /**
+   * The types of networks that are allowed for the request to pass connectivity checks. The
+   * currently active network type must be one of the values in this set. This set may not be empty.
+   */
+  public abstract ImmutableSet<NetworkType> requiredNetworkTypes();
+
+  /** Creates a {@code DownloadConstraints.Builder} instance. */
+  public static Builder builder() {
+    return new AutoValue_DownloadConstraints.Builder().setRequireUnmeteredNetwork(false);
+  }
+
+  /** Converts this instance to a builder for modifications. */
+  public abstract Builder toBuilder();
+
+  /** Builder for creating instances of {@link DownloadConstraints}. */
+  @AutoValue.Builder
+  public abstract static class Builder {
+    Builder() {}
+
+    public abstract Builder setRequiredNetworkTypes(Set<NetworkType> networkTypes);
+
+    abstract ImmutableSet.Builder<NetworkType> requiredNetworkTypesBuilder();
+
+    public final Builder addRequiredNetworkType(NetworkType networkType) {
+      requiredNetworkTypesBuilder().add(networkType);
+      return this;
+    }
+
+    public abstract Builder setRequireUnmeteredNetwork(boolean requireUnmeteredNetwork);
+
+    public abstract DownloadConstraints build();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/downloader/DownloadRequest.java b/java/com/google/android/libraries/mobiledatadownload/downloader/DownloadRequest.java
new file mode 100644
index 0000000..e938efd
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/downloader/DownloadRequest.java
@@ -0,0 +1,142 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.downloader;
+
+import static com.google.android.libraries.mobiledatadownload.internal.MddConstants.INLINE_FILE_URL_SCHEME;
+import static com.google.common.base.Preconditions.checkArgument;
+
+import android.net.Uri;
+import android.util.Pair;
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Optional;
+import com.google.common.collect.ImmutableList;
+import javax.annotation.concurrent.Immutable;
+
+/** Request to download a file. */
+@Immutable
+@AutoValue
+public abstract class DownloadRequest {
+
+  // Default value for Traffic Tag if not set by clients.
+  // MDD will not tag the traffic if the TrafficTag is not set to a valid value (>0).
+  private static final int UNSPECIFIED_TRAFFIC_TAG = -1;
+
+  DownloadRequest() {}
+
+  /** The File Uri to download the file at. */
+  public abstract Uri fileUri();
+
+  /** The url to download the file from. */
+  public abstract String urlToDownload();
+
+  /**
+   * Conditions under which this file should be downloaded.
+   *
+   * <p>These conditions relate to the type of network that should be used when downloading and must
+   * be provided when performing a network download.
+   *
+   * <p>When performing in-memory downloads (using the "inlinefile" url scheme), this will not be
+   * used.
+   */
+  public abstract DownloadConstraints downloadConstraints();
+
+  /**
+   * Traffic tag used for this request.
+   *
+   * <p>If not set, it will take the default value of UNSPECIFIED_TRAFFIC_TAG and MDD will not tag
+   * the traffic.
+   */
+  public abstract int trafficTag();
+
+  /** The extra HTTP headers for this request. */
+  public abstract ImmutableList<Pair<String, String>> extraHttpHeaders();
+
+  /**
+   * Parameters for inline file downloads.
+   *
+   * <p>An instance of {@link InlineDownloadParams} must be included in the request to support
+   * in-memory downloads (see <internal> for more info on the "inlinefile" url scheme).
+   *
+   * <p>Implementations of {@link FileDownloader} that support downloading from an inline file
+   * should ensure that 1) the "inlinefile" url scheme is used and 2) an {@link
+   * InlineDownloadParams} is provided.
+   */
+  public abstract Optional<InlineDownloadParams> inlineDownloadParamsOptional();
+
+  public static Builder newBuilder() {
+    return new AutoValue_DownloadRequest.Builder()
+        .setTrafficTag(UNSPECIFIED_TRAFFIC_TAG)
+        .setExtraHttpHeaders(ImmutableList.of());
+  }
+
+  /** Builder for {@link DownloadRequest}. */
+  @AutoValue.Builder
+  public abstract static class Builder {
+    Builder() {}
+
+    /** Sets the on-device destination uri of the file. */
+    public abstract Builder setFileUri(Uri fileUri);
+
+    /** Sets the url from where file content should be downloaded. */
+    public abstract Builder setUrlToDownload(String urlToDownload);
+
+    /**
+     * Sets the network constraints that should be used for the download.
+     *
+     * <p>Only required when performing network downloads. If performing an in-memory download, this
+     * is not required.
+     */
+    public abstract Builder setDownloadConstraints(DownloadConstraints downloadConstraints);
+
+    /** Sets the traffic tag for this request. */
+    public abstract Builder setTrafficTag(int trafficTag);
+
+    /** Sets the extra HTTP headers for this request. */
+    public abstract Builder setExtraHttpHeaders(
+        ImmutableList<Pair<String, String>> extraHttpHeaders);
+
+    /**
+     * Sets the parameters for an inline file download.
+     *
+     * <p>Only required when performing an in-memory download (using an "inlinefile" url scheme). If
+     * performing a network download, this is not required.
+     */
+    public abstract Builder setInlineDownloadParamsOptional(
+        InlineDownloadParams inlineDownloadParams);
+
+    /** Builds a {@link DownloadRequest} and checks for correct data. */
+    public final DownloadRequest build() {
+      // Ensure that inlinefile: requests include InlineDownloadParams
+      if (urlToDownload().startsWith(INLINE_FILE_URL_SCHEME)) {
+        checkArgument(
+            inlineDownloadParamsOptional().isPresent(),
+            "InlineDownloadParams must be set when using inlinefile: scheme");
+
+        // inline file request doesn't require download constraints, so set to NONE.
+        setDownloadConstraints(DownloadConstraints.NONE);
+      }
+
+      return autoBuild();
+    }
+
+    /** Tells AutoValue to generate an automated builder used in {@link #build} */
+    abstract DownloadRequest autoBuild();
+
+    abstract String urlToDownload();
+
+    abstract Optional<InlineDownloadParams> inlineDownloadParamsOptional();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/downloader/FileDownloader.java b/java/com/google/android/libraries/mobiledatadownload/downloader/FileDownloader.java
new file mode 100644
index 0000000..d096ac5
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/downloader/FileDownloader.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.downloader;
+
+import com.google.android.libraries.mobiledatadownload.DownloadException;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.errorprone.annotations.CheckReturnValue;
+
+/**
+ * Responsible for downloading files in MDD.
+ *
+ * <p>Implement this interface to replace the MDD networking stack.
+ */
+public interface FileDownloader {
+  /**
+   * Start downloading the file. The download result is provided asynchronously as a {@link
+   * ListenableFuture} that resolves when the download is complete.
+   *
+   * <p>The download can be cancelled by calling {@link ListenableFuture#cancel} on the future
+   * instance returned by this method. Cancellation is best-effort and does not guarantee that the
+   * download will stop immediately, as it is impossible to stop a thread that is in the middle of
+   * reading bytes off the network.
+   *
+   * @param downloadRequest the download request.
+   * @return - A ListenableFuture representing the download state of the file. The ListenableFuture
+   *     fails with {@link DownloadException} if downloading fails.
+   */
+  @CheckReturnValue
+  ListenableFuture<Void> startDownloading(DownloadRequest downloadRequest);
+
+  /**
+   * Checks if the content of url is changed. The ListenableFuture throws {@link DownloadException}
+   * when it fails.
+   */
+  @CheckReturnValue
+  default ListenableFuture<CheckContentChangeResponse> isContentChanged(
+      CheckContentChangeRequest checkContentChangeRequest) {
+    return Futures.immediateFailedFuture(new UnsupportedOperationException("Not implemented"));
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/downloader/InlineDownloadParams.java b/java/com/google/android/libraries/mobiledatadownload/downloader/InlineDownloadParams.java
new file mode 100644
index 0000000..06db1cf
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/downloader/InlineDownloadParams.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.downloader;
+
+import com.google.android.libraries.mobiledatadownload.FileSource;
+import com.google.auto.value.AutoValue;
+import javax.annotation.concurrent.Immutable;
+
+/** Parameters for downloading inline (in-memory and URI) files. */
+@Immutable
+@AutoValue
+public abstract class InlineDownloadParams {
+  InlineDownloadParams() {}
+
+  /** The data that should be copied to MDD internal storage. */
+  public abstract FileSource inlineFileContent();
+
+  /** Creates a Builder for {@link InlineDownloadParams} */
+  public static Builder newBuilder() {
+    return new AutoValue_InlineDownloadParams.Builder();
+  }
+
+  /** Builder for {@link InlineDownloadParams} */
+  @AutoValue.Builder
+  public abstract static class Builder {
+    Builder() {}
+
+    /** Sets the in-memory data that should be copied to MDD internal storage. */
+    public abstract Builder setInlineFileContent(FileSource inlineFileContent);
+
+    /** Builds a {@link InlineDownloadParams}. */
+    public abstract InlineDownloadParams build();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/downloader/MultiSchemeFileDownloader.java b/java/com/google/android/libraries/mobiledatadownload/downloader/MultiSchemeFileDownloader.java
new file mode 100644
index 0000000..c0b82a3
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/downloader/MultiSchemeFileDownloader.java
@@ -0,0 +1,133 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.downloader;
+
+import android.net.Uri;
+import androidx.annotation.VisibleForTesting;
+import com.google.android.libraries.mobiledatadownload.DownloadException;
+import com.google.android.libraries.mobiledatadownload.DownloadException.DownloadResultCode;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil;
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.errorprone.annotations.CheckReturnValue;
+import java.net.MalformedURLException;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * A composite {@link FileDownloader} that delegates to specific registered FileDownloaders based on
+ * URL scheme.
+ */
+public final class MultiSchemeFileDownloader implements FileDownloader {
+  private static final String TAG = "MultiSchemeFileDownloader";
+
+  /** Builder for {@link MultiSchemeFileDownloader}. */
+  public static final class Builder {
+    private final Map<String, FileDownloader> schemeToDownloader = new HashMap<>();
+
+    /** Associates a url scheme (e.g. "http") with a specific {@link FileDownloader} delegate. */
+    public MultiSchemeFileDownloader.Builder addScheme(String scheme, FileDownloader downloader) {
+      schemeToDownloader.put(
+          Preconditions.checkNotNull(scheme), Preconditions.checkNotNull(downloader));
+      return this;
+    }
+
+    public MultiSchemeFileDownloader build() {
+      return new MultiSchemeFileDownloader(this);
+    }
+  }
+
+  private final ImmutableMap<String, FileDownloader> schemeToDownloader;
+
+  /** Returns a Builder for {@link MultiSchemeFileDownloader}. */
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  private MultiSchemeFileDownloader(Builder builder) {
+    this.schemeToDownloader = ImmutableMap.copyOf(builder.schemeToDownloader);
+  }
+
+  @Override
+  @CheckReturnValue
+  public ListenableFuture<Void> startDownloading(DownloadRequest downloadRequest) {
+    FileDownloader delegate;
+    try {
+      delegate = getDelegate(downloadRequest.urlToDownload());
+    } catch (DownloadException e) {
+      return Futures.immediateFailedFuture(e);
+    }
+    return delegate.startDownloading(downloadRequest);
+  }
+
+  @Override
+  @CheckReturnValue
+  public ListenableFuture<CheckContentChangeResponse> isContentChanged(
+      CheckContentChangeRequest checkContentChangeRequest) {
+    FileDownloader delegate;
+    try {
+      delegate = getDelegate(checkContentChangeRequest.url());
+    } catch (DownloadException e) {
+      return Futures.immediateFailedFuture(e);
+    }
+    return delegate.isContentChanged(checkContentChangeRequest);
+  }
+
+  /** Extract the scheme of a url string. */
+  @VisibleForTesting
+  static String getScheme(String url) throws MalformedURLException {
+    Uri parsed = Uri.parse(url);
+    if (parsed == null) {
+      throw new MalformedURLException("Could not parse URL.");
+    }
+    String scheme = parsed.getScheme();
+    if (scheme == null) {
+      throw new MalformedURLException("URL contained no scheme.");
+    }
+    return scheme;
+  }
+
+  /**
+   * Lookup the delegate FileDownloader that can handle a url, based on the url's scheme.
+   *
+   * @throws DownloadException If an appropriate delegate FileDownloader could not be found.
+   */
+  FileDownloader getDelegate(String url) throws DownloadException {
+    String scheme;
+    try {
+      scheme = getScheme(url);
+    } catch (MalformedURLException e) {
+      LogUtil.e("%s: The download url is malformed, url = %s", TAG, url);
+      throw DownloadException.builder()
+          .setDownloadResultCode(DownloadResultCode.MALFORMED_DOWNLOAD_URL)
+          .setCause(e)
+          .build();
+    }
+
+    FileDownloader downloader = schemeToDownloader.get(scheme);
+    if (downloader == null) {
+      LogUtil.e(
+          "%s: No registered downloader supports the download url scheme, scheme = %s",
+          TAG, scheme);
+      throw DownloadException.builder()
+          .setDownloadResultCode(DownloadResultCode.UNSUPPORTED_DOWNLOAD_URL_SCHEME)
+          .build();
+    }
+    return downloader;
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/downloader/OAuthTokenProvider.java b/java/com/google/android/libraries/mobiledatadownload/downloader/OAuthTokenProvider.java
new file mode 100644
index 0000000..5cea38b
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/downloader/OAuthTokenProvider.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.downloader;
+
+import javax.annotation.Nullable;
+
+/**
+ * Provider interface for supplying OAuth tokens for a request. The tokens generated by this method
+ * are added to requests as http headers. For more details, see the official spec for this
+ * authorization mechanism: https://tools.ietf.org/html/rfc6750#page-5
+ */
+public interface OAuthTokenProvider {
+  /** Provides an OAuth bearer token for the given URL. */
+  @Nullable
+  String provideOAuthToken(String url);
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/downloader/inline/BUILD b/java/com/google/android/libraries/mobiledatadownload/downloader/inline/BUILD
new file mode 100644
index 0000000..a09dd65
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/downloader/inline/BUILD
@@ -0,0 +1,37 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_library")
+
+package(
+    default_visibility = [
+        "//visibility:public",
+    ],
+    licenses = ["notice"],
+)
+
+android_library(
+    name = "InlineFileDownloader",
+    srcs = [
+        "InlineFileDownloader.java",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload:DownloadException",
+        "//java/com/google/android/libraries/mobiledatadownload/downloader:FileDownloader",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:stream",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:MddConstants",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LogUtil",
+        "@com_google_guava_guava",
+    ],
+)
diff --git a/java/com/google/android/libraries/mobiledatadownload/downloader/inline/InlineFileDownloader.java b/java/com/google/android/libraries/mobiledatadownload/downloader/inline/InlineFileDownloader.java
new file mode 100644
index 0000000..8f3d472
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/downloader/inline/InlineFileDownloader.java
@@ -0,0 +1,110 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.downloader.inline;
+
+import static com.google.android.libraries.mobiledatadownload.internal.MddConstants.INLINE_FILE_URL_SCHEME;
+
+import com.google.android.libraries.mobiledatadownload.DownloadException;
+import com.google.android.libraries.mobiledatadownload.DownloadException.DownloadResultCode;
+import com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest;
+import com.google.android.libraries.mobiledatadownload.downloader.FileDownloader;
+import com.google.android.libraries.mobiledatadownload.downloader.InlineDownloadParams;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.openers.ReadStreamOpener;
+import com.google.android.libraries.mobiledatadownload.file.openers.WriteStreamOpener;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil;
+import com.google.common.io.ByteStreams;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.concurrent.Executor;
+
+/**
+ * An implementation of {@link FileDownloader} that supports copying in-memory data to disk.
+ *
+ * <p>This implementation only supports copying for "inlinefile:" url schemes. For more details see
+ * <internal>.
+ *
+ * <p>NOTE: copying in-memory data can be thought of as "inline file downloading," hence the naming
+ * of this class.
+ */
+public final class InlineFileDownloader implements FileDownloader {
+  private static final String TAG = "InlineFileDownloader";
+
+  private final SynchronousFileStorage fileStorage;
+  private final Executor downloadExecutor;
+
+  /**
+   * Construct InlineFileDownloader instance.
+   *
+   * @param fileStorage a file storage instance used to perform I/O
+   * @param downloadExecutor executor that will perfrom the download. This should be
+   *     the @MddDownloadExecutor
+   */
+  public InlineFileDownloader(SynchronousFileStorage fileStorage, Executor downloadExecutor) {
+    this.fileStorage = fileStorage;
+    this.downloadExecutor = downloadExecutor;
+  }
+
+  @Override
+  public ListenableFuture<Void> startDownloading(DownloadRequest downloadRequest) {
+    if (!downloadRequest.urlToDownload().startsWith(INLINE_FILE_URL_SCHEME)) {
+      LogUtil.e(
+          "%s: Invalid url given, expected to start with 'inlinefile:', but was %s",
+          TAG, downloadRequest.urlToDownload());
+      return Futures.immediateFailedFuture(
+          DownloadException.builder()
+              .setDownloadResultCode(DownloadResultCode.INVALID_INLINE_FILE_URL_SCHEME)
+              .setMessage("InlineFileDownloader only supports copying inlinefile: scheme")
+              .build());
+    }
+    // DownloadRequest requires InlineDownloadParams to be present when building a request with
+    // inlinefile scheme, so we can access it directly here.
+    InlineDownloadParams inlineDownloadParams =
+        downloadRequest.inlineDownloadParamsOptional().get();
+
+    return Futures.submitAsync(
+        () -> {
+          try (InputStream inlineFileStream = getInputStream(inlineDownloadParams);
+              OutputStream destinationStream =
+                  fileStorage.open(downloadRequest.fileUri(), WriteStreamOpener.create())) {
+            ByteStreams.copy(inlineFileStream, destinationStream);
+            destinationStream.flush();
+          } catch (IOException e) {
+            LogUtil.e(e, "%s: Unable to copy file content.", TAG);
+            return Futures.immediateFailedFuture(
+                DownloadException.builder()
+                    .setCause(e)
+                    .setDownloadResultCode(DownloadResultCode.INLINE_FILE_IO_ERROR)
+                    .build());
+          }
+          return Futures.immediateVoidFuture();
+        },
+        downloadExecutor);
+  }
+
+  private InputStream getInputStream(InlineDownloadParams params) throws IOException {
+    switch (params.inlineFileContent().getKind()) {
+      case URI:
+        return fileStorage.open(params.inlineFileContent().uri(), ReadStreamOpener.create());
+      case BYTESTRING:
+        return params.inlineFileContent().byteString().newInput();
+    }
+    throw new IllegalStateException("unreachable");
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/downloader/offroad/BUILD b/java/com/google/android/libraries/mobiledatadownload/downloader/offroad/BUILD
new file mode 100644
index 0000000..4774127
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/downloader/offroad/BUILD
@@ -0,0 +1,69 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_library")
+
+package(
+    default_visibility = [
+        "//visibility:public",
+    ],
+    licenses = ["notice"],
+)
+
+android_library(
+    name = "Offroad2FileDownloader",
+    srcs = [
+        "Offroad2FileDownloader.java",
+    ],
+    deps = [
+        ":ExceptionHandler",
+        "//java/com/google/android/libraries/mobiledatadownload:DownloadException",
+        "//java/com/google/android/libraries/mobiledatadownload/downloader:FileDownloader",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "//java/com/google/android/libraries/mobiledatadownload/file/integration/downloader:downloader2",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LogUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/tracing:concurrent",
+        "@com_google_code_findbugs_jsr305",
+        "@com_google_guava_guava",
+        "@downloader",
+    ],
+)
+
+android_library(
+    name = "ExceptionHandler",
+    srcs = [
+        "ExceptionHandler.java",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload:DownloadException",
+        "@downloader",
+    ],
+)
+
+android_library(
+    name = "TrafficStatsSocketFactory",
+    srcs = ["TrafficStatsSocketFactory.java"],
+)
+
+android_library(
+    name = "ThrottlingExecutor",
+    srcs = [
+        "ThrottlingExecutor.java",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LogUtil",
+        "@com_google_code_findbugs_jsr305",
+        "@com_google_guava_guava",
+    ],
+)
diff --git a/java/com/google/android/libraries/mobiledatadownload/downloader/offroad/ExceptionHandler.java b/java/com/google/android/libraries/mobiledatadownload/downloader/offroad/ExceptionHandler.java
new file mode 100644
index 0000000..759c805
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/downloader/offroad/ExceptionHandler.java
@@ -0,0 +1,152 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.downloader.offroad;
+
+import com.google.android.downloader.RequestException;
+import com.google.android.libraries.mobiledatadownload.DownloadException;
+import com.google.android.libraries.mobiledatadownload.DownloadException.DownloadResultCode;
+
+/**
+ * Handles mapping exceptions from Downloader2 into the equivalent MDD {@link DownloadException}.
+ *
+ * <p>Common exceptions are parsed and handled by default, but the underlying network stack may
+ * include special Exceptions and/or error codes that need to be parsed. If this is the case, a
+ * {@link NetworkStackExceptionHandler} can be provided to perform this parsing.
+ */
+public final class ExceptionHandler {
+  /**
+   * The maximum amount of attempts we recurse before stopping in {@link
+   * #mapExceptionToDownloadResultCode}.
+   */
+  private static final int EXCEPTION_TO_CODE_RECURSION_LIMIT = 5;
+
+  /** The handler of underlying network stack failures. */
+  private final NetworkStackExceptionHandler internalExceptionHandler;
+
+  private ExceptionHandler(NetworkStackExceptionHandler internalExceptionHandler) {
+    this.internalExceptionHandler = internalExceptionHandler;
+  }
+
+  /** Convenience method to return a new ExceptionHandler with default handling. */
+  public static ExceptionHandler withDefaultHandling() {
+    return new ExceptionHandler(new NetworkStackExceptionHandler() {});
+  }
+
+  /** Return a new instance with specific handling for a network stack. */
+  public static ExceptionHandler withNetworkStackHandling(
+      NetworkStackExceptionHandler internalExceptionHandler) {
+    return new ExceptionHandler(internalExceptionHandler);
+  }
+
+  /**
+   * Map given failure to a {@link DownloadException}.
+   *
+   * <p>For most cases, this method does not need to be overridden.
+   *
+   * <p><em>NOTE:</em> If the given throwable is already a {@link DownloadException}, it is returned
+   * immediately. In this case, the given message will <b>not</b> be used (the message from the
+   * given throwable will be used instead).
+   *
+   * @param message top-level message that should be used for the returned {@link DownloadException}
+   * @param throwable generic throwable that should be mapped to {@link DownloadException}
+   * @return {@link DownloadException} that wraps around given throwable with appropriate {@link
+   *     DownloadResultCode}
+   */
+  public DownloadException mapToDownloadException(String message, Throwable throwable) {
+    if (throwable instanceof DownloadException) {
+      // Exception is already an MDD DownloadException -- return it.
+      return (DownloadException) throwable;
+    }
+
+    DownloadResultCode code = mapExceptionToDownloadResultCode(throwable, /* iteration = */ 0);
+
+    return DownloadException.builder()
+        .setMessage(message)
+        .setDownloadResultCode(code)
+        .setCause(throwable)
+        .build();
+  }
+
+  /**
+   * Map exception to {@link DownloadResultCode}.
+   *
+   * @param throwable the exception to map to a {@link DownloadResultCode}
+   */
+  private DownloadResultCode mapExceptionToDownloadResultCode(Throwable throwable, int iteration) {
+    // Check recursion limit and return unknown error if it is hit.
+    if (iteration >= EXCEPTION_TO_CODE_RECURSION_LIMIT) {
+      return DownloadResultCode.UNKNOWN_ERROR;
+    }
+
+    DownloadResultCode networkStackMapperResult =
+        internalExceptionHandler.mapFromNetworkStackException(throwable);
+    if (!networkStackMapperResult.equals(DownloadResultCode.UNKNOWN_ERROR)) {
+      // network stack mapper returned known result code -- return it instead of performing common
+      // mapping.
+      return networkStackMapperResult;
+    }
+
+    if (throwable instanceof DownloadException) {
+      // exception in the chain is already an MDD DownloadException -- use its code
+      return ((DownloadException) throwable).getDownloadResultCode();
+    }
+
+    if (throwable instanceof RequestException) {
+      // Check error details for http status code error.
+      if (((RequestException) throwable).getErrorDetails().getHttpStatusCode() != -1) {
+        // error code has an associated http status code, mark it as HTTP_ERROR
+        return DownloadResultCode.ANDROID_DOWNLOADER_HTTP_ERROR;
+      }
+    }
+
+    if (throwable.getCause() != null) {
+      // Exception has an underlying cause -- attempt mapping on that cause.
+      return mapExceptionToDownloadResultCode(throwable.getCause(), iteration + 1);
+    }
+
+    if (throwable instanceof com.google.android.downloader.DownloadException) {
+      // If DownloadException is not wrapping anything, we can't determine the error further -- mark
+      // it as a general Downloader2 error.
+      return DownloadResultCode.ANDROID_DOWNLOADER2_ERROR;
+    }
+
+    // We couldn't parse any common errors, return an unknown error
+    return DownloadResultCode.UNKNOWN_ERROR;
+  }
+
+  /**
+   * Interface to handle parsing exceptions from an underlying network stack.
+   *
+   * <p>If an underlying network stack is used which can throw special exceptions or has an error
+   * code map, consider implementing this to provide better handling of exceptions.
+   */
+  public static interface NetworkStackExceptionHandler {
+    /**
+     * Map Custom Exception to {@link DownloadResultCode}.
+     *
+     * <p>Underlying network stacks may have specific exceptions that can be used to determine the
+     * best DownloadResultCode. This method should be overridden to check for such exceptions.
+     *
+     * <p>If a known {@link DownloadResultCode} is returned (i.e not UNKNOWN_ERROR), it will be
+     * used.
+     *
+     * <p>By default, an UNKNOWN_ERROR is returned.
+     */
+    default DownloadResultCode mapFromNetworkStackException(Throwable throwable) {
+      return DownloadResultCode.UNKNOWN_ERROR;
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/downloader/offroad/Offroad2FileDownloader.java b/java/com/google/android/libraries/mobiledatadownload/downloader/offroad/Offroad2FileDownloader.java
new file mode 100644
index 0000000..682045f
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/downloader/offroad/Offroad2FileDownloader.java
@@ -0,0 +1,212 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.downloader.offroad;
+
+import android.net.Uri;
+import android.util.Pair;
+import com.google.android.downloader.DownloadConstraints;
+import com.google.android.downloader.DownloadConstraints.NetworkType;
+import com.google.android.downloader.DownloadDestination;
+import com.google.android.downloader.DownloadRequest;
+import com.google.android.downloader.DownloadResult;
+import com.google.android.downloader.Downloader;
+import com.google.android.downloader.OAuthTokenProvider;
+import com.google.android.libraries.mobiledatadownload.DownloadException;
+import com.google.android.libraries.mobiledatadownload.DownloadException.DownloadResultCode;
+import com.google.android.libraries.mobiledatadownload.downloader.CheckContentChangeRequest;
+import com.google.android.libraries.mobiledatadownload.downloader.CheckContentChangeResponse;
+import com.google.android.libraries.mobiledatadownload.downloader.FileDownloader;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.common.MalformedUriException;
+import com.google.android.libraries.mobiledatadownload.file.integration.downloader.DownloadDestinationOpener;
+import com.google.android.libraries.mobiledatadownload.file.integration.downloader.DownloadMetadataStore;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil;
+import com.google.android.libraries.mobiledatadownload.tracing.PropagatedFluentFuture;
+import com.google.android.libraries.mobiledatadownload.tracing.PropagatedFutures;
+import com.google.common.base.Optional;
+import com.google.common.base.Strings;
+import com.google.common.util.concurrent.FluentFuture;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import java.io.IOException;
+import java.net.URI;
+import java.util.concurrent.Executor;
+import javax.annotation.Nullable;
+
+/**
+ * An implementation of the {@link
+ * com.google.android.libraries.mobiledatadownload.downloader.FileDownloader} using <internal>
+ */
+public final class Offroad2FileDownloader implements FileDownloader {
+  private static final String TAG = "Offroad2FileDownloader";
+
+  private final Downloader downloader;
+  private final SynchronousFileStorage fileStorage;
+  private final Executor downloadExecutor;
+  private final DownloadMetadataStore downloadMetadataStore;
+  private final ExceptionHandler exceptionHandler;
+  private final Optional<Integer> defaultTrafficTag;
+  @Nullable private final OAuthTokenProvider authTokenProvider;
+
+  // TODO(b/208703042): refactor injection to remove dependency on ProtoDataStore
+  public Offroad2FileDownloader(
+      Downloader downloader,
+      SynchronousFileStorage fileStorage,
+      Executor downloadExecutor,
+      @Nullable OAuthTokenProvider authTokenProvider,
+      DownloadMetadataStore downloadMetadataStore,
+      ExceptionHandler exceptionHandler,
+      Optional<Integer> defaultTrafficTag) {
+    this.downloader = downloader;
+    this.fileStorage = fileStorage;
+    this.downloadExecutor = downloadExecutor;
+    this.authTokenProvider = authTokenProvider;
+    this.downloadMetadataStore = downloadMetadataStore;
+    this.exceptionHandler = exceptionHandler;
+    this.defaultTrafficTag = defaultTrafficTag;
+  }
+
+  @Override
+  public ListenableFuture<Void> startDownloading(
+      com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest
+          fileDownloaderRequest) {
+    String fileName = Strings.nullToEmpty(fileDownloaderRequest.fileUri().getLastPathSegment());
+
+    DownloadDestination downloadDestination;
+    try {
+      downloadDestination = buildDownloadDestination(fileDownloaderRequest.fileUri());
+    } catch (DownloadException e) {
+      return Futures.immediateFailedFuture(e);
+    }
+
+    DownloadRequest offroad2DownloadRequest =
+        buildDownloadRequest(fileDownloaderRequest, downloadDestination);
+
+    FluentFuture<DownloadResult> resultFuture = downloader.execute(offroad2DownloadRequest);
+
+    LogUtil.d(
+        "%s: Data download scheduled for file: %s", TAG, fileDownloaderRequest.urlToDownload());
+
+    return PropagatedFluentFuture.from(resultFuture)
+        .catchingAsync(
+            Exception.class,
+            cause -> {
+              LogUtil.d(
+                  cause,
+                  "%s: Failed to download file %s due to: %s",
+                  TAG,
+                  fileName,
+                  Strings.nullToEmpty(cause.getMessage()));
+
+              DownloadException exception =
+                  exceptionHandler.mapToDownloadException("failure in download!", cause);
+
+              return Futures.immediateFailedFuture(exception);
+            },
+            downloadExecutor)
+        .transformAsync(
+            (DownloadResult result) -> {
+              LogUtil.d(
+                  "%s: Downloaded file %s, bytes written: %d",
+                  TAG, fileName, result.bytesWritten());
+              return PropagatedFutures.catchingAsync(
+                  downloadMetadataStore.delete(fileDownloaderRequest.fileUri()),
+                  Exception.class,
+                  e -> {
+                    // Failing to clean up metadata shouldn't cause a failure in the future, log and
+                    // return void.
+                    LogUtil.d(e, "%s: Failed to cleanup metadata", TAG);
+                    return Futures.immediateVoidFuture();
+                  },
+                  downloadExecutor);
+            },
+            downloadExecutor);
+  }
+
+  @Override
+  public ListenableFuture<CheckContentChangeResponse> isContentChanged(
+      CheckContentChangeRequest checkContentChangeRequest) {
+    return Futures.immediateFailedFuture(
+        new UnsupportedOperationException(
+            "Checking for content changes is currently unsupported for Downloader2"));
+  }
+
+  private DownloadDestination buildDownloadDestination(Uri destinationUri)
+      throws DownloadException {
+    try {
+      // Create DownloadDestination using mobstore
+      return fileStorage.open(
+          destinationUri, DownloadDestinationOpener.create(downloadMetadataStore));
+    } catch (IOException e) {
+      if (e instanceof MalformedUriException || e.getCause() instanceof IllegalArgumentException) {
+        LogUtil.e("%s: The file uri is invalid, uri = %s", TAG, destinationUri);
+        throw DownloadException.builder()
+            .setDownloadResultCode(DownloadResultCode.MALFORMED_FILE_URI_ERROR)
+            .setCause(e)
+            .build();
+      } else {
+        LogUtil.e(e, "%s: Unable to create DownloadDestination for file %s", TAG, destinationUri);
+        // TODO: the result code is the most equivalent to downloader1 -- consider
+        // creating a separate result code that's more appropriate for downloader2.
+        throw DownloadException.builder()
+            .setDownloadResultCode(
+                DownloadResultCode.UNABLE_TO_CREATE_MOBSTORE_RESPONSE_WRITER_ERROR)
+            .setCause(e)
+            .build();
+      }
+    }
+  }
+
+  private DownloadRequest buildDownloadRequest(
+      com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest
+          fileDownloaderRequest,
+      DownloadDestination downloadDestination) {
+    DownloadRequest.Builder requestBuilder =
+        downloader.newRequestBuilder(
+            URI.create(fileDownloaderRequest.urlToDownload()), downloadDestination);
+
+    requestBuilder.setOAuthTokenProvider(authTokenProvider);
+
+    if (com.google.android.libraries.mobiledatadownload.downloader.DownloadConstraints
+            .NETWORK_CONNECTED
+        == fileDownloaderRequest.downloadConstraints()) {
+      requestBuilder.setDownloadConstraints(DownloadConstraints.NETWORK_CONNECTED);
+    } else {
+      // Use all network types except cellular and require unmetered network.
+      requestBuilder.setDownloadConstraints(
+          DownloadConstraints.builder()
+              .addRequiredNetworkType(NetworkType.WIFI)
+              .addRequiredNetworkType(NetworkType.ETHERNET)
+              .addRequiredNetworkType(NetworkType.BLUETOOTH)
+              .setRequireUnmeteredNetwork(true)
+              .build());
+    }
+
+    if (fileDownloaderRequest.trafficTag() > 0) {
+      // Prefer traffic tag from request.
+      requestBuilder.setTrafficStatsTag(fileDownloaderRequest.trafficTag());
+    } else if (defaultTrafficTag.isPresent() && defaultTrafficTag.get() > 0) {
+      // Use default traffic tag as a fallback if present.
+      requestBuilder.setTrafficStatsTag(defaultTrafficTag.get());
+    }
+
+    for (Pair<String, String> header : fileDownloaderRequest.extraHttpHeaders()) {
+      requestBuilder.addHeader(header.first, header.second);
+    }
+
+    return requestBuilder.build();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/downloader/offroad/ThrottlingExecutor.java b/java/com/google/android/libraries/mobiledatadownload/downloader/offroad/ThrottlingExecutor.java
new file mode 100644
index 0000000..9cebf11
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/downloader/offroad/ThrottlingExecutor.java
@@ -0,0 +1,152 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.downloader.offroad;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil;
+import java.util.ArrayDeque;
+import java.util.Queue;
+import java.util.concurrent.Executor;
+import javax.annotation.concurrent.GuardedBy;
+
+/**
+ * Passes tasks to a delegate {@link Executor} for execution, ensuring that no more than a fixed
+ * number of them are submitted at any one time. If the limit is reached, then new tasks will be
+ * queued up and submitted when possible.
+ *
+ * <p>If the delegate {@link Executor} is only accessed via some {@link ThrottlingExecutor}, this
+ * effectively limits the number of concurrent tasks/threads. Alternatively, we can wrap a shared
+ * {@link Executor} in a {@link ThrottlingExecutor} before passing it to some component, to limit
+ * the amount of concurrency that component can request.
+ *
+ * <p>If the delegate {@link Executor} rejects queued tasks, they will be silently dropped. To keep
+ * the implementation simple, should the delegate {@link Executor} reject tasks, then we may end up
+ * with some tasks queued even if fewer tasks are actually running than our bound allows. We will
+ * keep submitting new tasks, however, so that transient problems in the underlying {@link Executor}
+ * do not prevent new tasks from running.
+ */
+public class ThrottlingExecutor implements Executor {
+  private static final String TAG = "ThrottlingExecutor";
+
+  private final Executor delegateExecutor;
+  private final int maximumThreads;
+
+  private final Object lock = new Object();
+
+  @GuardedBy("lock")
+  private int count = 0;
+
+  @GuardedBy("lock")
+  private Queue<Runnable> queue = new ArrayDeque<>();
+
+  public ThrottlingExecutor(Executor delegate, int maximumThreads) {
+    delegateExecutor = delegate;
+    this.maximumThreads = maximumThreads;
+  }
+
+  @Override
+  public void execute(Runnable runnable) {
+    checkNotNull(runnable);
+
+    synchronized (lock) {
+      if (count >= maximumThreads) {
+        queue.add(runnable);
+        return;
+      }
+
+      // We're going to run the task immediately, outside this synchronized block
+      count++;
+    }
+
+    try {
+      delegateExecutor.execute(new WrappedRunnable(runnable));
+    } catch (Throwable t) {
+      synchronized (lock) {
+        count--;
+      }
+      throw t;
+    }
+  }
+
+  /**
+   * Submits the next task from the {@link Queue}, decrementing the count of actively running tasks
+   * if there aren't any. This method is immediately after a {@link Runnable} has completed.
+   *
+   * <p>There are a couple of design points here.
+   *
+   * <p>Firstly, how do we run the next task? We could either submit the next task using {@link
+   * Executor#execute()}, or we could run it inline. The first approach is simpler, but has the
+   * drawback that the delegate Executor can see more than {@code maximumThreads} tasks running
+   * simultaneously: as we are submitted here from the end of an old task, the Executor briefly
+   * considers both the old and new task to be running. The second approach avoids this and may be
+   * slightly more efficient, but has added complexity (in particular in exception handling - we
+   * still want any unchecked Thowables thrown from a task to be propagated on). Also, if other
+   * tasks are waiting to run on the delegate {@link Executor} without passing through this
+   * throttle, then the second approach can prevent those tasks from having a chance to run. If in
+   * doubt, keep it simple - so we adopt the first approach.
+   *
+   * <p>Secondly, we want any Throwables thrown during the task to be propagated on to the delegate
+   * {@link Executor}, as if the {@link ThrottlingExecutor} wasn't in the way. But what about
+   * exceptions thrown by the {@link Executor#execute} method? That method could throw {@link
+   * RejectedExecutionException} in particular if the executor has been shut down but also for any
+   * other reason. It could also throw the usual range of unchecked exceptions. In either situation
+   * we simply decrement the count so that newly submitted tasks can still be attempted. Note that
+   * this approach, while simple, can leave some tasks "stranded" on the queue until other tasks are
+   * submitted and finish - if the underlying executor has been shutdown or permanently broken then
+   * this makes no difference; otherwise should never arise but if they do, at least new tasks can
+   * attempt to be run.
+   */
+  private void submitNextTaskOrDecrementCount() {
+    Runnable toSubmit;
+    synchronized (lock) {
+      toSubmit = queue.poll();
+      if (toSubmit == null) {
+        count--;
+        return;
+      }
+    }
+
+    try {
+      delegateExecutor.execute(new WrappedRunnable(toSubmit));
+    } catch (Throwable t) {
+      // Suppress this exception, which is probably a RejectedExecutionException. We're called from
+      // the finally block after some other task has completed, and we don't want to suppress any
+      // exception from the just-completed task.
+      LogUtil.e(t, "%s: Task submission failed: %s", TAG, toSubmit);
+      synchronized (lock) {
+        count--;
+      }
+    }
+  }
+
+  private class WrappedRunnable implements Runnable {
+    private final Runnable delegateRunnable;
+
+    public WrappedRunnable(Runnable delegate) {
+      delegateRunnable = delegate;
+    }
+
+    @Override
+    public void run() {
+      try {
+        delegateRunnable.run();
+      } finally {
+        submitNextTaskOrDecrementCount();
+      }
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/downloader/offroad/TrafficStatsSocketFactory.java b/java/com/google/android/libraries/mobiledatadownload/downloader/offroad/TrafficStatsSocketFactory.java
new file mode 100644
index 0000000..5dd5e8b
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/downloader/offroad/TrafficStatsSocketFactory.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.downloader.offroad;
+
+import android.net.TrafficStats;
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.Socket;
+import javax.net.SocketFactory;
+
+/** A custom SocketFactory that tags the traffic going through the socket created by it. */
+// TODO(b/141362798): Make package-private when OkHttpFileDownloaderModule supports non-framework
+// apps.
+public final class TrafficStatsSocketFactory extends SocketFactory {
+
+  private final SocketFactory delegate;
+  private final int trafficTag;
+
+  public TrafficStatsSocketFactory(SocketFactory delegate, int trafficTag) {
+    this.delegate = delegate;
+    this.trafficTag = trafficTag;
+  }
+
+  @Override
+  public Socket createSocket() throws IOException {
+    Socket socket = delegate.createSocket();
+    TrafficStats.setThreadStatsTag(trafficTag);
+    TrafficStats.tagSocket(socket);
+    return socket;
+  }
+
+  @Override
+  public Socket createSocket(String host, int port) throws IOException {
+    Socket socket = delegate.createSocket(host, port);
+    TrafficStats.setThreadStatsTag(trafficTag);
+    TrafficStats.tagSocket(socket);
+    return socket;
+  }
+
+  @Override
+  public Socket createSocket(String host, int port, InetAddress localHost, int localPort)
+      throws IOException {
+    Socket socket = delegate.createSocket(host, port, localHost, localPort);
+    TrafficStats.setThreadStatsTag(trafficTag);
+    TrafficStats.tagSocket(socket);
+    return socket;
+  }
+
+  @Override
+  public Socket createSocket(InetAddress host, int port) throws IOException {
+    Socket socket = delegate.createSocket(host, port);
+    TrafficStats.setThreadStatsTag(trafficTag);
+    TrafficStats.tagSocket(socket);
+    return socket;
+  }
+
+  @Override
+  public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort)
+      throws IOException {
+    Socket socket = delegate.createSocket(address, port, localAddress, localPort);
+    TrafficStats.setThreadStatsTag(trafficTag);
+    TrafficStats.tagSocket(socket);
+    return socket;
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/downloader/offroad/dagger/BUILD b/java/com/google/android/libraries/mobiledatadownload/downloader/offroad/dagger/BUILD
new file mode 100644
index 0000000..60814e8
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/downloader/offroad/dagger/BUILD
@@ -0,0 +1,31 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_library")
+
+package(
+    default_visibility = [
+        "//visibility:public",
+    ],
+    licenses = ["notice"],
+)
+
+android_library(
+    name = "BaseOffroadFileDownloaderModule",
+    srcs = ["BaseOffroadFileDownloaderModule.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/annotations",
+        "//java/com/google/android/libraries/mobiledatadownload/downloader:FileDownloader",
+        "@com_google_dagger",
+    ],
+)
diff --git a/java/com/google/android/libraries/mobiledatadownload/downloader/offroad/dagger/BaseOffroadFileDownloaderModule.java b/java/com/google/android/libraries/mobiledatadownload/downloader/offroad/dagger/BaseOffroadFileDownloaderModule.java
new file mode 100644
index 0000000..b78bc42
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/downloader/offroad/dagger/BaseOffroadFileDownloaderModule.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.downloader.offroad.dagger;
+
+import com.google.android.libraries.mobiledatadownload.annotations.SocketTrafficTag;
+import com.google.android.libraries.mobiledatadownload.downloader.OAuthTokenProvider;
+import dagger.BindsOptionalOf;
+import dagger.Module;
+
+/**
+ * Dagger module for providing shared bindings required for OffroadFileDownloader.
+ *
+ * <p>The bindings included here are optional bindings used by the different providers. This module
+ * is included directly in the other modules so there is only 1 place these bindings exist.
+ */
+@Module
+public abstract class BaseOffroadFileDownloaderModule {
+
+  /**
+   * Optional OAuthTokenProvider.
+   *
+   * <p>If OAuth tokens should be provided when downloading, clients should provide a binding.
+   *
+   * <p>Used by Cronet, OkHttp3 and OkHttp2.
+   */
+  @BindsOptionalOf
+  abstract OAuthTokenProvider optionalOAuthTokenProvider();
+
+  /**
+   * Optional Traffic Tag.
+   *
+   * <p>If network traffic should be tagged, clients should provide a binding.
+   *
+   * <p>Used by OkHttp3 and OkHttp2.
+   */
+  @BindsOptionalOf
+  @SocketTrafficTag
+  abstract Integer optionalTrafficTag();
+
+  private BaseOffroadFileDownloaderModule() {}
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/downloader/offroad/dagger/downloader2/BUILD b/java/com/google/android/libraries/mobiledatadownload/downloader/offroad/dagger/downloader2/BUILD
new file mode 100644
index 0000000..13f05e0
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/downloader/offroad/dagger/downloader2/BUILD
@@ -0,0 +1,55 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_library")
+
+package(
+    default_visibility = [
+        "//visibility:public",
+    ],
+    licenses = ["notice"],
+)
+
+android_library(
+    name = "base",
+    srcs = ["BaseFileDownloaderModule.java"],
+    deps = [
+        ":base_deps",
+        "//java/com/google/android/libraries/mobiledatadownload:Flags",
+        "//java/com/google/android/libraries/mobiledatadownload/annotations",
+        "//java/com/google/android/libraries/mobiledatadownload/downloader:FileDownloader",
+        "//java/com/google/android/libraries/mobiledatadownload/downloader/offroad:ExceptionHandler",
+        "//java/com/google/android/libraries/mobiledatadownload/downloader/offroad:Offroad2FileDownloader",
+        "//java/com/google/android/libraries/mobiledatadownload/downloader/offroad/dagger:BaseOffroadFileDownloaderModule",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/integration/downloader:downloader2",
+        "//java/com/google/android/libraries/mobiledatadownload/monitor:DownloadProgressMonitor",
+        "@androidx_annotation_annotation",
+        "@com_google_code_findbugs_jsr305",
+        "@com_google_dagger",
+        "@com_google_guava_guava",
+        "@downloader",
+        "@javax_inject",
+    ],
+)
+
+android_library(
+    name = "base_deps",
+    srcs = ["BaseFileDownloaderDepsModule.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/downloader/offroad:ExceptionHandler",
+        "@androidx_annotation_annotation",
+        "@com_google_dagger",
+        "@downloader",
+    ],
+)
diff --git a/java/com/google/android/libraries/mobiledatadownload/downloader/offroad/dagger/downloader2/BaseFileDownloaderDepsModule.java b/java/com/google/android/libraries/mobiledatadownload/downloader/offroad/dagger/downloader2/BaseFileDownloaderDepsModule.java
new file mode 100644
index 0000000..f98d13f
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/downloader/offroad/dagger/downloader2/BaseFileDownloaderDepsModule.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.downloader.offroad.dagger.downloader2;
+
+import androidx.annotation.VisibleForTesting;
+import com.google.android.downloader.UrlEngine;
+import com.google.android.libraries.mobiledatadownload.downloader.offroad.ExceptionHandler;
+import dagger.BindsOptionalOf;
+import dagger.Module;
+
+/**
+ * Dagger module for providing the common depenendecies of FileDownloaders backed by Android
+ * Downloader2.
+ *
+ * <p>The bindings included here are optional bindings to allow platform-specific implementations to
+ * be provided (i.e. providing a Cronet-specific ExceptionHandler), and common bindings that will be
+ * used across all FileDownloaders backed by Android Downloader2.
+ */
+@Module
+@VisibleForTesting
+public abstract class BaseFileDownloaderDepsModule {
+
+  /**
+   * Platform specific {@link ExceptionHandler}.
+   *
+   * <p>If no specific exception handler is available, the default one will be used.
+   */
+  @BindsOptionalOf
+  abstract ExceptionHandler platformSpecificExceptionHandler();
+
+  /**
+   * Platform specific {@link UrlEngine}.
+   *
+   * <p>If no specific engine is provided, the platform engine will be used.
+   */
+  @BindsOptionalOf
+  abstract UrlEngine platformSpecificUrlEngine();
+
+  private BaseFileDownloaderDepsModule() {}
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/downloader/offroad/dagger/downloader2/BaseFileDownloaderModule.java b/java/com/google/android/libraries/mobiledatadownload/downloader/offroad/dagger/downloader2/BaseFileDownloaderModule.java
new file mode 100644
index 0000000..425608c
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/downloader/offroad/dagger/downloader2/BaseFileDownloaderModule.java
@@ -0,0 +1,178 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.downloader.offroad.dagger.downloader2;
+
+import static com.google.common.util.concurrent.Futures.immediateFuture;
+
+import android.content.Context;
+import androidx.annotation.VisibleForTesting;
+import com.google.android.downloader.AndroidConnectivityHandler;
+import com.google.android.downloader.Downloader;
+import com.google.android.downloader.Downloader.StateChangeCallback;
+import com.google.android.downloader.FloggerDownloaderLogger;
+import com.google.android.downloader.PlatformAndroidTrafficStatsTagger;
+import com.google.android.downloader.PlatformUrlEngine;
+import com.google.android.downloader.UrlEngine;
+import com.google.android.libraries.mobiledatadownload.Flags;
+import com.google.android.libraries.mobiledatadownload.annotations.MddControlExecutor;
+import com.google.android.libraries.mobiledatadownload.annotations.MddDownloadExecutor;
+import com.google.android.libraries.mobiledatadownload.annotations.SocketTrafficTag;
+import com.google.android.libraries.mobiledatadownload.downloader.FileDownloader;
+import com.google.android.libraries.mobiledatadownload.downloader.OAuthTokenProvider;
+import com.google.android.libraries.mobiledatadownload.downloader.offroad.ExceptionHandler;
+import com.google.android.libraries.mobiledatadownload.downloader.offroad.Offroad2FileDownloader;
+import com.google.android.libraries.mobiledatadownload.downloader.offroad.dagger.BaseOffroadFileDownloaderModule;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.integration.downloader.DownloadMetadataStore;
+import com.google.android.libraries.mobiledatadownload.monitor.DownloadProgressMonitor;
+import com.google.common.base.Optional;
+import com.google.common.base.Supplier;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import dagger.Lazy;
+import dagger.Module;
+import dagger.Provides;
+import dagger.multibindings.IntoMap;
+import dagger.multibindings.StringKey;
+import java.util.concurrent.ScheduledExecutorService;
+import javax.annotation.Nullable;
+import javax.inject.Singleton;
+
+/**
+ * Dagger module for providing FileDownloader that uses Android Downloader2.
+ *
+ * <p>This module should only be used when {@link MultiSchemeFileDownloader} is being provided by
+ * {@link FileDownloaderModule}. That module includes a map of FileDownloader Suppliers, which this
+ * module assumes is available to bind into.
+ */
+@Module(
+    includes = {
+      BaseOffroadFileDownloaderModule.class,
+      BaseFileDownloaderDepsModule.class,
+    })
+@VisibleForTesting
+public abstract class BaseFileDownloaderModule {
+  @Provides
+  @Singleton
+  @IntoMap
+  @StringKey("https")
+  static Supplier<FileDownloader> provideFileDownloader(
+      Context context,
+      @MddDownloadExecutor ScheduledExecutorService downloadExecutor,
+      @MddControlExecutor ListeningExecutorService controlExecutor,
+      SynchronousFileStorage fileStorage,
+      DownloadMetadataStore downloadMetadataStore,
+      Optional<DownloadProgressMonitor> downloadProgressMonitor,
+      Optional<Lazy<UrlEngine>> urlEngineOptional,
+      Optional<Lazy<ExceptionHandler>> exceptionHandlerOptional,
+      Optional<Lazy<OAuthTokenProvider>> authTokenProviderOptional,
+      @SocketTrafficTag Optional<Integer> trafficTag,
+      Flags flags) {
+    return () ->
+        createOffroad2FileDownloader(
+            context,
+            downloadExecutor,
+            controlExecutor,
+            fileStorage,
+            downloadMetadataStore,
+            downloadProgressMonitor,
+            urlEngineOptional,
+            exceptionHandlerOptional,
+            authTokenProviderOptional,
+            trafficTag,
+            flags);
+  }
+
+  @VisibleForTesting
+  public static Offroad2FileDownloader createOffroad2FileDownloader(
+      Context context,
+      ScheduledExecutorService downloadExecutor,
+      ListeningExecutorService controlExecutor,
+      SynchronousFileStorage fileStorage,
+      DownloadMetadataStore downloadMetadataStore,
+      Optional<DownloadProgressMonitor> downloadProgressMonitor,
+      Optional<Lazy<UrlEngine>> urlEngineOptional,
+      Optional<Lazy<ExceptionHandler>> exceptionHandlerOptional,
+      Optional<Lazy<OAuthTokenProvider>> authTokenProviderOptional,
+      Optional<Integer> trafficTag,
+      Flags flags) {
+    @Nullable
+    com.google.android.downloader.OAuthTokenProvider authTokenProvider =
+        authTokenProviderOptional.isPresent()
+            ? convertToDownloaderAuthTokenProvider(authTokenProviderOptional.get().get())
+            : null;
+
+    ExceptionHandler handler =
+        exceptionHandlerOptional.transform(Lazy::get).or(ExceptionHandler.withDefaultHandling());
+
+    UrlEngine urlEngine;
+    if (urlEngineOptional.isPresent()) {
+      urlEngine = urlEngineOptional.get().get();
+    } else {
+      // Use {@link PlatformUrlEngine} if one was not provided.
+      urlEngine =
+          new PlatformUrlEngine(
+              controlExecutor,
+              /* connectTimeoutMs = */ flags.timeToWaitForDownloader(),
+              /* readTimeoutMs = */ flags.timeToWaitForDownloader(),
+              new PlatformAndroidTrafficStatsTagger());
+    }
+
+    AndroidConnectivityHandler connectivityHandler =
+        new AndroidConnectivityHandler(
+            context, downloadExecutor, /* timeoutMillis = */ flags.timeToWaitForDownloader());
+
+    FloggerDownloaderLogger logger = new FloggerDownloaderLogger();
+
+    Downloader downloader =
+        new Downloader.Builder()
+            .withIOExecutor(controlExecutor)
+            .withConnectivityHandler(connectivityHandler)
+            .withMaxConcurrentDownloads(flags.downloaderMaxThreads())
+            .withLogger(logger)
+            .addUrlEngine("https", urlEngine)
+            .build();
+
+    if (downloadProgressMonitor.isPresent()) {
+      // Wire up downloader's state changes to DownloadProgressMonitor to handle connectivity
+      // pauses.
+      StateChangeCallback callback =
+          state -> {
+            if (state.getNumDownloadsPendingConnectivity() > 0
+                && state.getNumDownloadsInFlight() == 0) {
+              // Handle network connectivity pauses
+              downloadProgressMonitor.get().pausedForConnectivity();
+            }
+          };
+      downloader.registerStateChangeCallback(callback, controlExecutor);
+    }
+
+    return new Offroad2FileDownloader(
+        downloader,
+        fileStorage,
+        downloadExecutor,
+        authTokenProvider,
+        downloadMetadataStore,
+        handler,
+        trafficTag);
+  }
+
+  private static com.google.android.downloader.OAuthTokenProvider
+      convertToDownloaderAuthTokenProvider(OAuthTokenProvider authTokenProvider) {
+    return uri -> immediateFuture(authTokenProvider.provideOAuthToken(uri.toString()));
+  }
+
+  private BaseFileDownloaderModule() {}
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/BUILD b/java/com/google/android/libraries/mobiledatadownload/file/BUILD
new file mode 100644
index 0000000..34950b9
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/BUILD
@@ -0,0 +1,43 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_library")
+
+package(
+    default_visibility = [
+        "//visibility:public",
+    ],
+    licenses = ["notice"],
+)
+
+android_library(
+    name = "file",
+    srcs = [
+        "Behavior.java",
+        "MonitorInputStream.java",
+        "MonitorOutputStream.java",
+        "OpenContext.java",
+        "Opener.java",
+        "SynchronousFileStorage.java",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/internal:forwarding_stream",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/internal:lite_transform_fragments",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/internal:preconditions",
+        "//java/com/google/android/libraries/mobiledatadownload/file/spi",
+        "@com_google_code_findbugs_jsr305",
+        "@com_google_errorprone_error_prone_annotations",
+        "@com_google_guava_guava",
+    ],
+)
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/Behavior.java b/java/com/google/android/libraries/mobiledatadownload/file/Behavior.java
new file mode 100644
index 0000000..44fb7e2
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/Behavior.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.List;
+
+/**
+ * Interface for adding behavior to how a file is opened that's independent from the Opener and
+ * Transforms. For example, this is used to support file locking and syncing. Instances are passed
+ * the whole chain of streams (which includes backends, transforms and monitors). The chain is
+ * ordered so that the first stream is the one that the client sees, and the last stream the
+ * backend. While the interface only sees Input/OutputStreams, most implementations will rely on
+ * <code>instanceof</code> to see if there are other features available on the stream.
+ */
+public interface Behavior {
+
+  /**
+   * Inform this Behavior about this input chain.
+   *
+   * @param chain The transforms, monitors, and backend (in that order).
+   */
+  default void forInputChain(List<InputStream> chain) throws IOException {}
+
+  /**
+   * Inform this Behavior about this output chain.
+   *
+   * @param chain The transforms, monitors, and backend (in that order).
+   */
+  default void forOutputChain(List<OutputStream> chain) throws IOException {}
+
+  /**
+   * Perform any aspects of the behavior that are required to be executed immediately prior to
+   * closing the stream.
+   */
+  default void commit() throws IOException {}
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/MonitorInputStream.java b/java/com/google/android/libraries/mobiledatadownload/file/MonitorInputStream.java
new file mode 100644
index 0000000..81c511f
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/MonitorInputStream.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.common.Sizable;
+import com.google.android.libraries.mobiledatadownload.file.common.internal.ForwardingInputStream;
+import com.google.android.libraries.mobiledatadownload.file.common.internal.Preconditions;
+import com.google.android.libraries.mobiledatadownload.file.spi.Monitor;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+import javax.annotation.Nullable;
+
+/** Stream that invokes input stream monitors. */
+final class MonitorInputStream extends ForwardingInputStream implements Sizable {
+  private final List<Monitor.InputMonitor> inputMonitors;
+
+  private MonitorInputStream(InputStream input, List<Monitor.InputMonitor> inputMonitors) {
+    super(input);
+    this.inputMonitors = inputMonitors;
+    Preconditions.checkArgument(input != null, "Input was null");
+  }
+
+  /**
+   * Wraps {@code in} with a new stream that orchestrates {@code monitors}, or returns null if this
+   * IO doesn't need to be monitored.
+   */
+  @Nullable
+  public static MonitorInputStream newInstance(List<Monitor> monitors, Uri uri, InputStream in) {
+    List<Monitor.InputMonitor> inputMonitors = new ArrayList<>();
+    for (Monitor monitor : monitors) {
+      Monitor.InputMonitor inputMonitor = monitor.monitorRead(uri);
+      if (inputMonitor != null) {
+        inputMonitors.add(inputMonitor);
+      }
+    }
+    return !inputMonitors.isEmpty() ? new MonitorInputStream(in, inputMonitors) : null;
+  }
+
+  @Override
+  public int read() throws IOException {
+    int result = in.read();
+    if (result != -1) {
+      byte[] b = new byte[] {(byte) result};
+      for (Monitor.InputMonitor inputMonitor : inputMonitors) {
+        inputMonitor.bytesRead(b, 0, 1);
+      }
+    }
+    return result;
+  }
+
+  @Override
+  public int read(byte[] b) throws IOException {
+    int result = in.read(b);
+    if (result != -1) {
+      for (Monitor.InputMonitor inputMonitor : inputMonitors) {
+        inputMonitor.bytesRead(b, 0, result);
+      }
+    }
+    return result;
+  }
+
+  @Override
+  public int read(byte[] b, int off, int len) throws IOException {
+    int result = in.read(b, off, len);
+    if (result != -1) {
+      for (Monitor.InputMonitor inputMonitor : inputMonitors) {
+        inputMonitor.bytesRead(b, off, result);
+      }
+    }
+    return result;
+  }
+
+  @Nullable
+  @Override
+  public Long size() throws IOException {
+    if (!(in instanceof Sizable)) {
+      return null;
+    }
+    return ((Sizable) in).size();
+  }
+
+  @Override
+  public void close() throws IOException {
+    for (Monitor.InputMonitor inputMonitor : inputMonitors) {
+      try {
+        inputMonitor.close();
+      } catch (Throwable t) {
+        // Ignore.
+      }
+    }
+    super.close();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/MonitorOutputStream.java b/java/com/google/android/libraries/mobiledatadownload/file/MonitorOutputStream.java
new file mode 100644
index 0000000..b177bab
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/MonitorOutputStream.java
@@ -0,0 +1,108 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.common.internal.ForwardingOutputStream;
+import com.google.android.libraries.mobiledatadownload.file.common.internal.Preconditions;
+import com.google.android.libraries.mobiledatadownload.file.spi.Monitor;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.List;
+import javax.annotation.Nullable;
+
+/** Stream that invokes output stream monitors. */
+final class MonitorOutputStream extends ForwardingOutputStream {
+  private final List<Monitor.OutputMonitor> outputMonitors;
+
+  private MonitorOutputStream(OutputStream output, List<Monitor.OutputMonitor> outputMonitors) {
+    super(output);
+    this.outputMonitors = outputMonitors;
+    Preconditions.checkArgument(output != null, "Output was null");
+  }
+
+  /**
+   * Wraps {@code out} with a new stream that orchestrates {@code monitors}, or returns null if this
+   * IO doesn't need to be monitored.
+   */
+  @Nullable
+  public static MonitorOutputStream newInstanceForWrite(
+      List<Monitor> monitors, Uri uri, OutputStream out) {
+    List<Monitor.OutputMonitor> outputMonitors = new ArrayList<>();
+    for (Monitor monitor : monitors) {
+      Monitor.OutputMonitor outputMonitor = monitor.monitorWrite(uri);
+      if (outputMonitor != null) {
+        outputMonitors.add(outputMonitor);
+      }
+    }
+    return !outputMonitors.isEmpty() ? new MonitorOutputStream(out, outputMonitors) : null;
+  }
+
+  /**
+   * Wraps {@code out} with a new stream that orchestrates {@code monitors}, or returns null if this
+   * IO doesn't need to be monitored.
+   */
+  @Nullable
+  public static MonitorOutputStream newInstanceForAppend(
+      List<Monitor> monitors, Uri uri, OutputStream out) {
+    List<Monitor.OutputMonitor> outputMonitors = new ArrayList<>();
+    for (Monitor monitor : monitors) {
+      Monitor.OutputMonitor outputMonitor = monitor.monitorAppend(uri);
+      if (outputMonitor != null) {
+        outputMonitors.add(outputMonitor);
+      }
+    }
+    return !outputMonitors.isEmpty() ? new MonitorOutputStream(out, outputMonitors) : null;
+  }
+
+  @Override
+  public void write(int b) throws IOException {
+    out.write(b);
+    byte[] bs = new byte[] {(byte) b};
+    for (Monitor.OutputMonitor outputMonitor : outputMonitors) {
+      outputMonitor.bytesWritten(bs, 0, 1);
+    }
+  }
+
+  @Override
+  public void write(byte[] b) throws IOException {
+    out.write(b);
+    for (Monitor.OutputMonitor outputMonitor : outputMonitors) {
+      outputMonitor.bytesWritten(b, 0, b.length);
+    }
+  }
+
+  @Override
+  public void write(byte[] b, int off, int len) throws IOException {
+    out.write(b, off, len);
+    for (Monitor.OutputMonitor outputMonitor : outputMonitors) {
+      outputMonitor.bytesWritten(b, off, len);
+    }
+  }
+
+  @Override
+  public void close() throws IOException {
+    for (Monitor.OutputMonitor outputMonitor : outputMonitors) {
+      try {
+        outputMonitor.close();
+      } catch (Throwable t) {
+        // Ignore.
+      }
+    }
+    super.close();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/OpenContext.java b/java/com/google/android/libraries/mobiledatadownload/file/OpenContext.java
new file mode 100644
index 0000000..ddcb968
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/OpenContext.java
@@ -0,0 +1,201 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.spi.Backend;
+import com.google.android.libraries.mobiledatadownload.file.spi.Monitor;
+import com.google.android.libraries.mobiledatadownload.file.spi.Transform;
+import com.google.common.collect.Iterables;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Encapsulates state for a single open call including selected backend, transforms, etc. This class
+ * is used as single parameter to {@link Opener#open} call.
+ */
+public final class OpenContext {
+
+  private final SynchronousFileStorage storage;
+  private final Backend backend;
+  private final List<Transform> transforms;
+  private final List<Monitor> monitors;
+  private final Uri originalUri;
+  private final Uri encodedUri;
+
+  /** Builder for constructing an OpenContext. */
+  static class Builder {
+    private SynchronousFileStorage storage;
+    private Backend backend;
+    private List<Transform> transforms;
+    private List<Monitor> monitors;
+    private Uri originalUri;
+    private Uri encodedUri;
+
+    private Builder() {}
+
+    Builder setStorage(SynchronousFileStorage storage) {
+      this.storage = storage;
+      return this;
+    }
+
+    Builder setBackend(Backend backend) {
+      this.backend = backend;
+      return this;
+    }
+
+    Builder setTransforms(List<Transform> transforms) {
+      this.transforms = transforms;
+      return this;
+    }
+
+    Builder setMonitors(List<Monitor> monitors) {
+      this.monitors = monitors;
+      return this;
+    }
+
+    Builder setEncodedUri(Uri encodedUri) {
+      this.encodedUri = encodedUri;
+      return this;
+    }
+
+    Builder setOriginalUri(Uri originalUri) {
+      this.originalUri = originalUri;
+      return this;
+    }
+
+    OpenContext build() {
+      return new OpenContext(this);
+    }
+  }
+
+  OpenContext(Builder builder) {
+    this.storage = builder.storage;
+    this.backend = builder.backend;
+    this.transforms = builder.transforms;
+    this.monitors = builder.monitors;
+    this.originalUri = builder.originalUri;
+    this.encodedUri = builder.encodedUri;
+  }
+
+  public static OpenContext.Builder builder() {
+    return new OpenContext.Builder();
+  }
+
+  /** Gets a reference to the same storage instance. */
+  public SynchronousFileStorage storage() {
+    return storage;
+  }
+
+  /** Access the backend selected by the URI. */
+  public Backend backend() {
+    return backend;
+  }
+
+  /**
+   * Return the URI after encoding of the filename and stripping of the fragment. This is what the
+   * backend sees.
+   */
+  public Uri encodedUri() {
+    return encodedUri;
+  }
+
+  /** Get the original URI. This is the one the caller passed to the storage API. */
+  public Uri originalUri() {
+    return originalUri;
+  }
+
+  /**
+   * Composes an input stream by chaining {@link MonitorInputStream} and {@link
+   * Transform#wrapForRead}s.
+   *
+   * @return All of the input streams in the chain. The first is returned to client, and the last is
+   *     the one produced by the backend.
+   */
+  public List<InputStream> chainTransformsForRead(InputStream in) throws IOException {
+    List<InputStream> chain = new ArrayList<>();
+    chain.add(in);
+    if (!monitors.isEmpty()) {
+      MonitorInputStream monitorStream = MonitorInputStream.newInstance(monitors, originalUri, in);
+      if (monitorStream != null) {
+        chain.add(monitorStream);
+      }
+    }
+    for (Transform transform : transforms) {
+      chain.add(transform.wrapForRead(originalUri, Iterables.getLast(chain)));
+    }
+    Collections.reverse(chain);
+    return chain;
+  }
+
+  /**
+   * Composes an output stream by chaining {@link MonitorOutputStream} and {@link
+   * Transform#wrapForWrite}s.
+   *
+   * @return All of the output streams in the chain. The first is returned to client, and the last
+   *     is the one produced by the backend.
+   */
+  public List<OutputStream> chainTransformsForWrite(OutputStream out) throws IOException {
+    List<OutputStream> chain = new ArrayList<>();
+    chain.add(out);
+    if (!monitors.isEmpty()) {
+      MonitorOutputStream monitorStream =
+          MonitorOutputStream.newInstanceForWrite(monitors, originalUri, out);
+      if (monitorStream != null) {
+        chain.add(monitorStream);
+      }
+    }
+    for (Transform transform : transforms) {
+      chain.add(transform.wrapForWrite(originalUri, Iterables.getLast(chain)));
+    }
+    Collections.reverse(chain);
+    return chain;
+  }
+
+  /**
+   * Composes an output stream by chaining {@link MonitorOutputStream} and {@link
+   * Transform#wrapForAppend}s.
+   *
+   * @return All of the output streams in the chain. The first is returned to client, and the last
+   *     is the one produced by the backend.
+   */
+  public List<OutputStream> chainTransformsForAppend(OutputStream out) throws IOException {
+    List<OutputStream> chain = new ArrayList<>();
+    chain.add(out);
+    if (!monitors.isEmpty()) {
+      MonitorOutputStream monitorStream =
+          MonitorOutputStream.newInstanceForAppend(monitors, originalUri, out);
+      if (monitorStream != null) {
+        chain.add(monitorStream);
+      }
+    }
+    for (Transform transform : transforms) {
+      chain.add(transform.wrapForAppend(originalUri, Iterables.getLast(chain)));
+    }
+    Collections.reverse(chain);
+    return chain;
+  }
+
+  /** Tells whether there are any transforms configured for this open request. */
+  public boolean hasTransforms() {
+    // NOTE: a more intelligent API might check for any transforms that aren't Sizable
+    return !transforms.isEmpty();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/Opener.java b/java/com/google/android/libraries/mobiledatadownload/file/Opener.java
new file mode 100644
index 0000000..2247c29
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/Opener.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file;
+
+import java.io.IOException;
+
+/**
+ * Interface for opening a file. Implementation is passed an {@link OpenContext} which provides
+ * access to the backend, transforms, and other information that can be used to fulfill the open
+ * request. For example, a ProtoOpener could simply invoke {@link
+ * OpenContext#chainTransformsForRead} and pass that to the proto parser.
+ *
+ * <p>Openers also behave a little like builders in that their behavior can be parameterized. For
+ * example, if an extension registry is required, it can be set on the opener before passing to file
+ * storage.
+ */
+public interface Opener<T> {
+  /** Invoked to open a file. */
+  T open(OpenContext openContext) throws IOException;
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/SynchronousFileStorage.java b/java/com/google/android/libraries/mobiledatadownload/file/SynchronousFileStorage.java
new file mode 100644
index 0000000..1a55e9d
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/SynchronousFileStorage.java
@@ -0,0 +1,408 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file;
+
+import android.net.Uri;
+import android.text.TextUtils;
+import android.util.Log;
+import com.google.android.libraries.mobiledatadownload.file.common.GcParam;
+import com.google.android.libraries.mobiledatadownload.file.common.UnsupportedFileStorageOperation;
+import com.google.android.libraries.mobiledatadownload.file.common.internal.LiteTransformFragments;
+import com.google.android.libraries.mobiledatadownload.file.spi.Backend;
+import com.google.android.libraries.mobiledatadownload.file.spi.Monitor;
+import com.google.android.libraries.mobiledatadownload.file.spi.Transform;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Sets;
+import com.google.errorprone.annotations.CheckReturnValue;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.ListIterator;
+import java.util.Map;
+
+/**
+ * FileStorage is an abstraction over platform File I/O that supports pluggable backends and
+ * transforms. This is the synchronous variant which is useful for background processing and
+ * implementing Openers.
+ *
+ * <p>For testing, it is recommended to use a real backend such as JavaFileBackend, rather than
+ * mock.
+ *
+ * <p>See <internal> for details.
+ */
+public final class SynchronousFileStorage {
+
+  private static final String TAG = "MobStore.FileStorage";
+
+  private final Map<String, Backend> backends = new HashMap<>();
+  private final Map<String, Transform> transforms = new HashMap<>();
+  private final List<Monitor> monitors = new ArrayList<>();
+
+  /**
+   * Constructs a new SynchronousFileStorage with the specified executors, backends, transforms, and
+   * monitors.
+   *
+   * <p>In the case of a collision, the later backend/transform replaces any earlier ones.
+   *
+   * <p>FileStorage is expected to be a singleton provided by dependency injection. Transforms and
+   * backends should be registered once when producing that singleton.
+   *
+   * <p>All monitors are executed between transforms and the backend. For example, if you had a
+   * compression transform, the monitor would see the compressed bytes.
+   *
+   * @param backends Registers these backends.
+   * @param transforms Registers these transforms.
+   * @param monitors Registers these monitors.
+   */
+  public SynchronousFileStorage(
+      List<Backend> backends, List<Transform> transforms, List<Monitor> monitors) {
+    registerPlugins(backends, transforms, monitors);
+  }
+
+  /** Constructs a new FileStorage with Transforms but no Monitors. */
+  public SynchronousFileStorage(List<Backend> backends, List<Transform> transforms) {
+    this(backends, transforms, Collections.emptyList());
+  }
+
+  /** Constructs a new FileStorage with no Transforms or Monitors. */
+  public SynchronousFileStorage(List<Backend> backends) {
+    this(backends, Collections.emptyList(), Collections.emptyList());
+  }
+
+  /**
+   * Registers backends, transforms and monitors to SynchronousFileStorage.
+   *
+   * @throws IllegalArgumentException for attempts to override existing backends or transforms
+   */
+  private void registerPlugins(
+      List<Backend> backends, List<Transform> transforms, List<Monitor> monitors) {
+    for (Backend backend : backends) {
+      if (TextUtils.isEmpty(backend.name())) {
+        Log.w(TAG, "Cannot register backend, name empty");
+        continue;
+      }
+
+      Backend oldValue = this.backends.put(backend.name(), backend);
+      if (oldValue != null) {
+        throw new IllegalArgumentException(
+            "Cannot override Backend "
+                + oldValue.getClass().getCanonicalName()
+                + " with "
+                + backend.getClass().getCanonicalName());
+      }
+    }
+    for (Transform transform : transforms) {
+      if (TextUtils.isEmpty(transform.name())) {
+        Log.w(TAG, "Cannot register transform, name empty");
+        continue;
+      }
+      Transform oldValue = this.transforms.put(transform.name(), transform);
+      if (oldValue != null) {
+        throw new IllegalArgumentException(
+            "Cannot to override Transform "
+                + oldValue.getClass().getCanonicalName()
+                + " with "
+                + transform.getClass().getCanonicalName());
+      }
+    }
+    this.monitors.addAll(monitors);
+  }
+
+  /**
+   * Returns a String listing registered backends, transforms and monitors for debugging purposes.
+   */
+  public String getDebugInfo() {
+    String backendsDebugString =
+        TextUtils.join(
+            ",\n",
+            Sets.newTreeSet(
+                Iterables.transform(
+                    backends.keySet(),
+                    key ->
+                        String.format(
+                            "protocol: %1$s, class: %2$s",
+                            key, backends.get(key).getClass().getSimpleName()))));
+
+    String transformsDebugString =
+        TextUtils.join(
+            ",\n",
+            Sets.newTreeSet(
+                Iterables.transform(
+                    transforms.values(), transform -> transform.getClass().getSimpleName())));
+
+    String monitorsDebugString =
+        TextUtils.join(
+            ",\n",
+            Sets.newTreeSet(
+                Iterables.transform(monitors, monitor -> monitor.getClass().getSimpleName())));
+
+    return String.format(
+        "Registered Mobstore Plugins:\n\nBackends:\n%1$s\n\nTransforms:\n%2$s\n\nMonitors:\n%3$s",
+        backendsDebugString, transformsDebugString, monitorsDebugString);
+  }
+
+  /**
+   * Open URI with an Opener. The Opener determines the return type, eg, a Stream or a Proto and is
+   * responsible for implementing any additional behavior such as locking.
+   *
+   * @param uri The URI to open.
+   * @param opener The generic opener to use.
+   * @param <T> The kind of thing the opener opens.
+   * @return The result of the open operation.
+   */
+  @CheckReturnValue
+  public <T> T open(Uri uri, Opener<T> opener) throws IOException {
+    OpenContext context = getContext(uri);
+    return opener.open(context);
+  }
+
+  /**
+   * Deletes the file denoted by {@code uri}.
+   *
+   * @throws IOException if the file could not be deleted for any reason
+   */
+  public void deleteFile(Uri uri) throws IOException {
+    OpenContext context = getContext(uri);
+    context.backend().deleteFile(context.encodedUri());
+  }
+
+  /**
+   * Deletes the directory denoted by {@code uri}. The directory must be empty in order to be
+   * deleted.
+   *
+   * @throws IOException if the directory could not be deleted for any reason
+   */
+  public void deleteDirectory(Uri uri) throws IOException {
+    Backend backend = getBackend(uri.getScheme());
+    backend.deleteDirectory(stripFragment(uri));
+  }
+
+  /**
+   * Delete a file or directory and all its contents at a specified location.
+   *
+   * @param uri the location to delete
+   * @return true if and only if the file or directory specified at {@code uri} was deleted.
+   */
+  @Deprecated // see {@link
+  // com.google.android.libraries.mobiledatadownload.file.openers.RecursiveDeleteOpener}
+  public boolean deleteRecursively(Uri uri) throws IOException {
+    if (!exists(uri)) {
+      return false;
+    }
+    if (!isDirectory(uri)) {
+      deleteFile(uri);
+      return true;
+    }
+    for (Uri child : children(uri)) {
+      deleteRecursively(child);
+    }
+    deleteDirectory(uri);
+    return true;
+  }
+
+  /**
+   * Tells whether this file or directory exists.
+   *
+   * <p>The last segment of the uri path is interpreted as a file name and may be encoded by a
+   * transform. Callers should consider using {@link #isDirectory}, stripping fragments, or adding a
+   * trailing slash to avoid accidentally encoding a directory name.
+   *
+   * @param uri
+   * @return the success value of the operation.
+   */
+  @CheckReturnValue
+  public boolean exists(Uri uri) throws IOException {
+    OpenContext context = getContext(uri);
+    return context.backend().exists(context.encodedUri());
+  }
+
+  /**
+   * Tells whether this uri refers to a directory.
+   *
+   * @param uri
+   * @return the success value of the operation.
+   */
+  @CheckReturnValue
+  public boolean isDirectory(Uri uri) throws IOException {
+    Backend backend = getBackend(uri.getScheme());
+    return backend.isDirectory(stripFragment(uri));
+  }
+
+  /**
+   * Creates a new directory. Any non-existent parent directories will also be created.
+   *
+   * @throws IOException if the directory could not be created for any reason
+   */
+  public void createDirectory(Uri uri) throws IOException {
+    Backend backend = getBackend(uri.getScheme());
+    backend.createDirectory(stripFragment(uri));
+  }
+
+  /**
+   * Gets the file size.
+   *
+   * <p>If the uri refers to a directory or non-existent, returns 0.
+   *
+   * @param uri
+   * @return the size in bytes of the file.
+   */
+  @CheckReturnValue
+  public long fileSize(Uri uri) throws IOException {
+    OpenContext context = getContext(uri);
+    return context.backend().fileSize(context.encodedUri());
+  }
+
+  /**
+   * Renames the file or directory from one location to another. This can only be performed if the
+   * schemes of the Uris map to the same backend instance.
+   *
+   * <p>The last segment of the uri path is interpreted as a file name and may be encoded by a
+   * transform. Callers should ensure a trailing slash is included for directory names or strip
+   * transforms to avoid accidentally encoding a directory name.
+   *
+   * @throws IOException if the file could not be renamed for any reason
+   */
+  public void rename(Uri from, Uri to) throws IOException {
+    OpenContext fromContext = getContext(from);
+    OpenContext toContext = getContext(to);
+    // Even if it's the same provider, require that the backend instances be the same
+    // for a rename operation. (Can make less restrictive if necessary.)
+    if (fromContext.backend() != toContext.backend()) {
+      throw new UnsupportedFileStorageOperation("Cannot rename file across backends");
+    }
+    fromContext.backend().rename(fromContext.encodedUri(), toContext.encodedUri());
+  }
+
+  /**
+   * Lists children of a parent directory Uri.
+   *
+   * @param parentUri The parent directory to list.
+   * @return the list of children.
+   */
+  @CheckReturnValue
+  public Iterable<Uri> children(Uri parentUri) throws IOException {
+    Backend backend = getBackend(parentUri.getScheme());
+    List<Transform> enabledTransforms = getEnabledTransforms(parentUri);
+    List<Uri> result = new ArrayList<Uri>();
+    String encodedFragment = parentUri.getEncodedFragment();
+    for (Uri child : backend.children(stripFragment(parentUri))) {
+      Uri decodedChild =
+          decodeFilename(
+              enabledTransforms, child.buildUpon().encodedFragment(encodedFragment).build());
+      result.add(decodedChild);
+    }
+    return result;
+  }
+
+  /** Retrieves the {@link GcParam} associated with the given URI. */
+  public GcParam getGcParam(Uri uri) throws IOException {
+    OpenContext context = getContext(uri);
+    return context.backend().getGcParam(context.encodedUri());
+  }
+
+  /** Sets the {@link GcParam} associated with the given URI. */
+  public void setGcParam(Uri uri, GcParam param) throws IOException {
+    OpenContext context = getContext(uri);
+    context.backend().setGcParam(context.encodedUri(), param);
+  }
+
+  private OpenContext getContext(Uri uri) throws IOException {
+    List<Transform> enabledTransforms = getEnabledTransforms(uri);
+    return OpenContext.builder()
+        .setStorage(this)
+        .setBackend(getBackend(uri.getScheme()))
+        .setMonitors(monitors)
+        .setTransforms(enabledTransforms)
+        .setOriginalUri(uri)
+        .setEncodedUri(encodeFilename(enabledTransforms, uri))
+        .build();
+  }
+
+  private Backend getBackend(String scheme) throws IOException {
+    Backend backend = backends.get(scheme);
+    if (backend == null) {
+      throw new UnsupportedFileStorageOperation(
+          String.format("Cannot open, unregistered backend: %s", scheme));
+    }
+    return backend;
+  }
+
+  private ImmutableList<Transform> getEnabledTransforms(Uri uri)
+      throws UnsupportedFileStorageOperation {
+    ImmutableList.Builder<Transform> builder = ImmutableList.builder();
+    for (String name : LiteTransformFragments.parseTransformNames(uri)) {
+      Transform transform = transforms.get(name);
+      if (transform == null) {
+        throw new UnsupportedFileStorageOperation("No such transform: " + name + ": " + uri);
+      }
+      builder.add(transform);
+    }
+    return builder.build().reverse();
+  }
+
+  private static final Uri stripFragment(Uri uri) {
+    return uri.buildUpon().fragment(null).build();
+  }
+
+  /**
+   * Give transforms the opportunity to encode the file part (last segment for file operations) of
+   * the uri. Also strips fragment.
+   */
+  private static final Uri encodeFilename(List<Transform> transforms, Uri uri) {
+    if (transforms.isEmpty()) {
+      return uri;
+    }
+    List<String> segments = new ArrayList<String>(uri.getPathSegments());
+    // This Uri implementation's getPathSegments() ignores trailing "/".
+    if (segments.isEmpty() || uri.getPath().endsWith("/")) {
+      return uri;
+    }
+    String filename = segments.get(segments.size() - 1);
+    // Reverse transforms, restoring their original order. (In all other places the reverse order
+    // is more convenient.)
+    for (ListIterator<Transform> iter = transforms.listIterator(transforms.size());
+        iter.hasPrevious(); ) {
+      Transform transform = iter.previous();
+      filename = transform.encode(uri, filename);
+    }
+    segments.set(segments.size() - 1, filename);
+    return uri.buildUpon().path(TextUtils.join("/", segments)).encodedFragment(null).build();
+  }
+
+  /**
+   * Give transforms the opportunity to decode the file part (last segment for file operations) of
+   * the uri. Reverses encodeFilename().
+   */
+  private static final Uri decodeFilename(List<Transform> transforms, Uri uri) {
+    if (transforms.isEmpty()) {
+      return uri;
+    }
+    List<String> segments = new ArrayList<String>(uri.getPathSegments());
+    // This Uri implementation's getPathSegments() ignores trailing "/".
+    if (segments.isEmpty() || uri.getPath().endsWith("/")) {
+      return uri;
+    }
+    String filename = Iterables.getLast(segments);
+    for (Transform transform : transforms) {
+      filename = transform.decode(uri, filename);
+    }
+    segments.set(segments.size() - 1, filename);
+    return uri.buildUpon().path(TextUtils.join("/", segments)).build();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/backends/AccountManager.java b/java/com/google/android/libraries/mobiledatadownload/file/backends/AccountManager.java
new file mode 100644
index 0000000..26e3226
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/backends/AccountManager.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.backends;
+
+import android.accounts.Account;
+import com.google.common.util.concurrent.ListenableFuture;
+
+/** Helper for Uri classes to manage Android accounts. */
+public interface AccountManager {
+
+  /**
+   * Returns the ID associated with {@code account}, or assigns and returns a new id if the account
+   * is unrecognized.
+   */
+  ListenableFuture<Integer> getAccountId(Account account);
+
+  /** Returns the account associated with {@code accountId}, or fails if the id is unrecognized. */
+  ListenableFuture<Account> getAccount(int accountId);
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/backends/AccountSerialization.java b/java/com/google/android/libraries/mobiledatadownload/file/backends/AccountSerialization.java
new file mode 100644
index 0000000..8566dde
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/backends/AccountSerialization.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.backends;
+
+import android.accounts.Account;
+import com.google.android.libraries.mobiledatadownload.file.common.internal.Preconditions;
+
+/** Helper for Uri classes to serialize Android accounts. */
+public final class AccountSerialization {
+
+  private static final String SHARED_ACCOUNT_STR = "shared";
+
+  /** A common {@link Account} with no associated user; it appears as "shared" on the filesystem. */
+  public static final Account SHARED_ACCOUNT = new Account(SHARED_ACCOUNT_STR, "mobstore");
+
+  /**
+   * Validates and serializes an {@link Account} into a string representation "<type>:<name>", with
+   * the exception of {@link #SHARED_ACCOUNT} which is serialized as {@link #SHARED_ACCOUNT_STR}.
+   *
+   * <p>The account will be URL encoded in its URI representation (so, eg, "<internal>@gmail.com"
+   * will appear as "you%40gmail.com"), but not in the file path representation used to access disk.
+   * {@link #SHARED_ACCOUNT} shows up as "shared" on the filesystem.
+   *
+   * <p>This method performs some account validation. Android Account itself requires that both the
+   * type and name fields be present. In addition to this requirement, this method requires that the
+   * type contain no colons (as these are the delimiter used internally for the account
+   * serialization), and that neither the type nor the name include any slashes (as these are file
+   * separators).
+   *
+   * <p>Note the Linux filesystem accepts filenames composed of any bytes except "/" and NULL.
+   *
+   * @throws IllegalArgumentException if the account does not conform to the specifications above
+   */
+  public static String serialize(Account account) {
+    validate(account);
+    if (isSharedAccount(account)) {
+      return SHARED_ACCOUNT_STR;
+    }
+    return account.type + ":" + account.name;
+  }
+
+  /**
+   * Parses an account string generated by {@link #serialize} back into an {@link Account}, or
+   * throws {@link IllegalArgumentException} if {@code accountStr} has an unrecognized format.
+   */
+  public static Account deserialize(String accountStr) {
+    if (isSharedAccount(accountStr)) {
+      return SHARED_ACCOUNT;
+    }
+    int colonIdx = accountStr.indexOf(':');
+    Preconditions.checkArgument(colonIdx > -1, "Malformed account");
+    String type = accountStr.substring(0, colonIdx);
+    String name = accountStr.substring(colonIdx + 1);
+    return new Account(name, type);
+  }
+
+  static boolean isSharedAccount(String accountStr) {
+    return SHARED_ACCOUNT_STR.equals(accountStr);
+  }
+
+  static boolean isSharedAccount(Account account) {
+    return SHARED_ACCOUNT.equals(account);
+  }
+
+  private static void validate(Account account) {
+    // Android Account already validates that name and type are not empty.
+    Preconditions.checkArgument(account.type.indexOf(':') == -1, "Account type contains ':'.");
+    Preconditions.checkArgument(account.type.indexOf('/') == -1, "Account type contains '/'.");
+    Preconditions.checkArgument(account.name.indexOf('/') == -1, "Account name contains '/'.");
+  }
+
+  private AccountSerialization() {}
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/backends/AndroidFileBackend.java b/java/com/google/android/libraries/mobiledatadownload/file/backends/AndroidFileBackend.java
new file mode 100644
index 0000000..e1d8b9d
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/backends/AndroidFileBackend.java
@@ -0,0 +1,268 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.backends;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.net.Uri;
+import android.os.Build;
+import android.text.TextUtils;
+import android.util.Pair;
+import androidx.annotation.VisibleForTesting;
+import com.google.android.libraries.mobiledatadownload.file.common.FileStorageUnavailableException;
+import com.google.android.libraries.mobiledatadownload.file.common.LockScope;
+import com.google.android.libraries.mobiledatadownload.file.common.MalformedUriException;
+import com.google.android.libraries.mobiledatadownload.file.common.internal.Preconditions;
+import com.google.android.libraries.mobiledatadownload.file.spi.Backend;
+import com.google.android.libraries.mobiledatadownload.file.spi.ForwardingBackend;
+import java.io.Closeable;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.GuardedBy;
+
+/** A backend that implements "android:" scheme using {@link JavaFileBackend}. */
+public final class AndroidFileBackend extends ForwardingBackend {
+
+  private final Context context;
+  private final Backend backend;
+  private final DirectBootChecker directBootChecker;
+  @Nullable private final Backend remoteBackend;
+  @Nullable private final AccountManager accountManager;
+
+  private final Object lock = new Object();
+
+  @GuardedBy("lock")
+  @Nullable
+  private String lazyDpsDataDirPath; // Initialized and accessed via getDpsDataDirPath()
+
+  /**
+   * Returns an {@link AndroidFileBackend} builder for the calling {@code context}. Most options are
+   * disabled by default; see javadoc in {@link Builder} for further configuration documentation.
+   */
+  public static Builder builder(Context context) {
+    return new Builder(context);
+  }
+
+  /**
+   * Returns an {@link AndroidFileBackend} with the customized {@code backend}. Should only be used
+   * in test where a customized backend is needed for simulating file operation failures or delays.
+   */
+  @VisibleForTesting
+  public static Builder builderWithOverrideForTest(Context context, Backend backend) {
+    Preconditions.checkArgument(
+        backend != null, "Cannot invoke builderWithOverrideForTest with null supplied as Backend.");
+    Builder builder = new Builder(context);
+    builder.backend = backend;
+    return builder;
+  }
+
+  /** Builder for the {@link AndroidFileBackend} class. */
+  public static final class Builder {
+    // Required parameters
+    private final Context context;
+
+    // Optional parameters
+    @Nullable private Backend remoteBackend;
+    @Nullable private AccountManager accountManager;
+    @Nullable private Backend backend;
+    private LockScope lockScope = new LockScope();
+
+    private Builder(Context context) {
+      Preconditions.checkArgument(context != null, "Context cannot be null");
+      this.context = context.getApplicationContext();
+    }
+
+    /**
+     * Sets the remote backend that is invoked when the URI's authority refers to a package other
+     * than your own. The only methods called on {@code remoteBackend} are {@link #openForRead} and
+     * {@link #openForNativeRead}, though this may expand in the future. Defaults to {@code null}.
+     */
+    public Builder setRemoteBackend(Backend remoteBackend) {
+      this.remoteBackend = remoteBackend;
+      return this;
+    }
+
+    /**
+     * Sets the {@link AccountManager} invoked to resolve "managed" URIs. Defaults to {@code null},
+     * in which case operations on "managed" URIs will fail.
+     */
+    public Builder setAccountManager(AccountManager accountManager) {
+      this.accountManager = accountManager;
+      return this;
+    }
+
+    /**
+     * Overrides the default backend-scoped {@link LockScope} with the given {@code lockScope}. This
+     * injection is only necessary if there are multiple backend instances in the same process and
+     * there's a risk of them acquiring a lock on the same underlying file.
+     */
+    public Builder setLockScope(LockScope lockScope) {
+      Preconditions.checkArgument(
+          backend == null,
+          "LockScope will not be used in the custom backend. Only call builderWithOverrideForTest"
+              + " if you want to override the backend for testing, or call builder together with"
+              + " setLockScope to set a new lock scope.");
+      this.lockScope = lockScope;
+      return this;
+    }
+
+    public AndroidFileBackend build() {
+      return new AndroidFileBackend(this);
+    }
+  }
+
+  private AndroidFileBackend(Builder builder) {
+    backend = builder.backend != null ? builder.backend : new JavaFileBackend(builder.lockScope);
+    context = builder.context;
+    remoteBackend = builder.remoteBackend;
+    accountManager = builder.accountManager;
+
+    directBootChecker = unusedContext -> true;
+  }
+
+  @Override
+  protected Backend delegate() {
+    return backend;
+  }
+
+  @Override
+  public String name() {
+    return "android";
+  }
+
+  /**
+   * {@inheritDoc}
+   *
+   * <p>URI may belong to a different authority.
+   */
+  @Override
+  public InputStream openForRead(Uri uri) throws IOException {
+    if (isRemoteAuthority(uri)) {
+      throwIfRemoteBackendUnavailable();
+      return remoteBackend.openForRead(uri);
+    }
+    return super.openForRead(uri);
+  }
+
+  /**
+   * {@inheritDoc}
+   *
+   * <p>URI may belong to a different authority.
+   */
+  @Override
+  public Pair<Uri, Closeable> openForNativeRead(Uri uri) throws IOException {
+    if (isRemoteAuthority(uri)) {
+      throwIfRemoteBackendUnavailable();
+      return remoteBackend.openForNativeRead(uri);
+    }
+    return super.openForNativeRead(uri);
+  }
+
+  /**
+   * {@inheritDoc}
+   *
+   * <p>URI may belong to a different authority.
+   */
+  @Override
+  public boolean exists(Uri uri) throws IOException {
+    if (isRemoteAuthority(uri)) {
+      throwIfRemoteBackendUnavailable();
+      return remoteBackend.exists(uri);
+    }
+    return super.exists(uri);
+  }
+
+  private boolean isRemoteAuthority(Uri uri) {
+    return !TextUtils.isEmpty(uri.getAuthority())
+        && !context.getPackageName().equals(uri.getAuthority());
+  }
+
+  private void throwIfRemoteUri(Uri uri) throws IOException {
+    if (isRemoteAuthority(uri)) {
+      throw new IOException("operation is not permitted in other authorities.");
+    }
+  }
+
+  private void throwIfRemoteBackendUnavailable() throws FileStorageUnavailableException {
+    if (remoteBackend == null) {
+      throw new FileStorageUnavailableException(
+          "Android backend cannot perform remote operations without a remote backend");
+    }
+  }
+
+  @Override
+  protected Uri rewriteUri(Uri uri) throws IOException {
+    // Converts from android -> file
+    if (isRemoteAuthority(uri)) {
+      throw new MalformedUriException("Operation across authorities is not allowed.");
+    }
+    File file = toFile(uri);
+    Uri fileUri = FileUri.builder().fromFile(file).build();
+    return fileUri;
+  }
+
+  @Override
+  protected Uri reverseRewriteUri(Uri uri) throws IOException {
+    // Converts from file -> android
+    try {
+      return AndroidUri.builder(context).fromAbsolutePath(uri.getPath(), accountManager).build();
+    } catch (IllegalArgumentException e) {
+      throw new MalformedUriException(e);
+    }
+  }
+
+  @Override
+  public File toFile(Uri uri) throws IOException {
+    throwIfRemoteUri(uri);
+    File file = AndroidUriAdapter.forContext(context, accountManager).toFile(uri);
+    throwIfStorageIsLocked(file);
+    return file;
+  }
+
+  /** Utilities for interacting with Android Direct Boot mode. */
+  private interface DirectBootChecker {
+    /** Returns true if the device doesn't support direct boot or the user is unlocked. */
+    boolean isUserUnlocked(Context context);
+  }
+
+  private void throwIfStorageIsLocked(File file) throws FileStorageUnavailableException {
+    // If the device doesn't support DirectBoot or has been unlocked, all files are available.
+    if (directBootChecker.isUserUnlocked(context)) {
+      return;
+    }
+
+    // During DirectBoot, only files in device-protected storage are available.
+    String dpsDataDirPath = getDpsDataDirPath();
+    String filePath = file.getAbsolutePath();
+    if (!filePath.startsWith(dpsDataDirPath)) {
+      throw new FileStorageUnavailableException(
+          "Cannot access credential-protected data from direct boot");
+    }
+  }
+
+  @TargetApi(Build.VERSION_CODES.N)
+  private String getDpsDataDirPath() {
+    synchronized (lock) {
+      if (lazyDpsDataDirPath == null) {
+        File dpsDataDir = AndroidFileEnvironment.getDeviceProtectedDataDir(context);
+        lazyDpsDataDirPath = dpsDataDir.getAbsolutePath();
+      }
+      return lazyDpsDataDirPath;
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/backends/AndroidFileEnvironment.java b/java/com/google/android/libraries/mobiledatadownload/file/backends/AndroidFileEnvironment.java
new file mode 100644
index 0000000..975aae2
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/backends/AndroidFileEnvironment.java
@@ -0,0 +1,241 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.backends;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.os.Build;
+import android.os.Environment;
+import android.os.StatFs;
+import android.os.SystemClock;
+import android.util.Log;
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Provides access to high-level information about the Android file environment. These utilities are
+ * neither intended nor available for use outside of the MobStore library implementation.
+ */
+public final class AndroidFileEnvironment {
+
+  private static final String TAG = "AndroidFileEnvironment";
+
+  /** Returns all {@code dirs} that are currently mounted with full read/write access. */
+  public static List<File> getMountedExternalDirs(List<File> dirs) {
+    List<File> result = new ArrayList<>();
+    for (File dir : dirs) {
+      if (dir == null) {
+        continue;
+      }
+      String state = getStorageState(dir);
+      if (Log.isLoggable(TAG, Log.DEBUG)) {
+        Log.d(TAG, String.format("External storage: [%s] is [%s]", dir.getAbsolutePath(), state));
+      }
+      if (Environment.MEDIA_MOUNTED.equals(state)) {
+        result.add(dir);
+      }
+    }
+    return result;
+  }
+
+  /**
+   * Returns the current state of the shared/external storage media at the given path. This is a
+   * private API to support {@link Environment#getStorageState(File)} across all sdk levels.
+   */
+  private static String getStorageState(File dir) {
+    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
+      return getStorageStateKK(dir);
+    } else {
+      return getStorageStateICS(dir);
+    }
+  }
+
+  /** Private API to support {@link #getStorageState} on sdk KK and higher. */
+  @TargetApi(Build.VERSION_CODES.KITKAT)
+  private static String getStorageStateKK(File dir) {
+    return Environment.getStorageState(dir);
+  }
+
+  /** Private API to support {@link #getStorageState} on lower sdk levels. */
+  private static String getStorageStateICS(File dir) {
+    // Implementation taken directly from EnvironmentCompat#getStorageState. Note that JB and below
+    // only support one external storage partition, thus can only return a meaningful value for a
+    // directory under that partition.
+    try {
+      String dirPath = dir.getCanonicalPath();
+      String externalPath = Environment.getExternalStorageDirectory().getCanonicalPath();
+      if (dirPath.startsWith(externalPath)) {
+        return Environment.getExternalStorageState();
+      }
+    } catch (IOException e) {
+      Log.w(TAG, "Failed to resolve canonical path", e);
+    }
+    return "unknown"; // == Environment.MEDIA_UNKNOWN, which isn't available below KK
+  }
+
+  /**
+   * Returns all available non-emulated external cache directories. This method does not guarantee
+   * that the returned paths are mounted.
+   */
+  public static List<File> getNonEmulatedExternalCacheDirs(Context context) {
+    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+      return getNonEmulatedExternalCacheDirsLP(context);
+    } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
+      return getNonEmulatedExternalCacheDirsKK(context);
+    } else {
+      return getNonEmulatedExternalCacheDirsICS(context);
+    }
+  }
+
+  /**
+   * Private API; please use {@link #getNonEmulatedExternalCacheDirs} directly. This implementation
+   * of {@link #getNonEmulatedExternalCacheDirs} uses the new APIs available on LOLLIPOP and later
+   * in order to query each external storage partition for emulation.
+   */
+  @TargetApi(Build.VERSION_CODES.LOLLIPOP)
+  private static List<File> getNonEmulatedExternalCacheDirsLP(Context context) {
+    List<File> result = new ArrayList<>();
+
+    for (File dir : Arrays.asList(context.getExternalCacheDirs())) {
+      try {
+        if (dir != null && !Environment.isExternalStorageEmulated(dir)) {
+          result.add(dir);
+        }
+      } catch (IllegalArgumentException e) {
+        // NOTE: on some devices and API levels, Environment.isExternalStorageEmulated(File)
+        // will throw an exception if the partition is not mounted. In any case this means the dir
+        // is unavailable, so we can continue past it. See b/29833349 for more info.
+        // TODO(b/64078707): enable Robolectric to throw exceptions here to increase test coverage
+        Log.w(
+            TAG,
+            String.format("isExternalStorageEmulated(File) failed for [%s]", dir.getAbsolutePath()),
+            e);
+        continue;
+      }
+    }
+
+    return result;
+  }
+
+  /**
+   * Private API; please use {@link #getNonEmulatedExternalCacheDirs} directly. This implementation
+   * of {@link #getNonEmulatedExternalCacheDirs} supports lower SDK levels and can't query secondary
+   * partitions for emulation. However, only the primary partition can be emulated on such devices.
+   */
+  @TargetApi(Build.VERSION_CODES.KITKAT)
+  private static List<File> getNonEmulatedExternalCacheDirsKK(Context context) {
+    List<File> result = new ArrayList<>();
+
+    // If the primary external storage is non-emulated, return it
+    File[] dirs = context.getExternalCacheDirs();
+    if (!Environment.isExternalStorageEmulated() && dirs[0] != null) {
+      result.add(dirs[0]);
+    }
+
+    // Check secondary storage. We skip the first dir (primary), which we already checked. Secondary
+    // dirs cannot be explicitly checked for emulation because of the API level, but are assumed
+    // to be non-emulated. See {@link https://source.android.com/devices/storage/config-example} and
+    // {@link com.google.android.apps.gmm.shared.util.FileUtil#getNonEmulatedExternalFilesDirKK}.
+    for (int i = 1; i < dirs.length; i++) {
+      if (dirs[i] != null) {
+        result.add(dirs[i]);
+      }
+    }
+
+    return result;
+  }
+
+  /**
+   * Private API; please use {@link #getNonEmulatedExternalCacheDirs} directly. This implementation
+   * supports sdk levels below KitKat, and due to the limited API can only return a single external
+   * storage partition (which may be emulated, in which case none are returned).
+   */
+  private static List<File> getNonEmulatedExternalCacheDirsICS(Context context) {
+    File dir = context.getExternalCacheDir();
+    if (!Environment.isExternalStorageEmulated() && dir != null) {
+      return Arrays.asList(dir);
+    }
+    return Collections.emptyList();
+  }
+
+  /** Returns the number of bytes free and available on the file system of {@code dir}. */
+  public static long getAvailableStorageSpace(File dir) {
+    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) {
+      return getAvailableStorageSpaceJBMR2(dir);
+    } else {
+      return getAvailableStorageSpaceICS(dir);
+    }
+  }
+
+  /**
+   * Based on cl/189267818. Paraphrased here:
+   *
+   * <p>According to AGSA bug b/30959609 and similar bugs in other 1st party apps, the Context can
+   * return a null filesDir on SDK versions before N. The root cause is a race condition between two
+   * threads that try to initialize the application's directory structure immediately following
+   * installation. One thread waits, and the other returns a null File pointer. The bug is fixed in
+   * Android N. The workaround for older releases is to wait. A short while after failing, the
+   * directory structure is initialized, and the previously failing Context returns a valid File
+   * pointer. If that doesn't work, then the Context must be broken for other reasons. We throw an
+   * IllegalStateException in this case.
+   */
+  // TODO(b/70255835): rename to not suggest N is safe since bug affects up to and including sdk N
+  public static File getFilesDirWithPreNWorkaround(Context context) {
+    File filesDir = context.getFilesDir();
+    // According to Android docs, this can't happen, but a pre-N bug makes this sometimes return
+    // null. See b/30959609 for details.
+    if (filesDir == null) {
+      // The cause is an internal race condition. Sleep and try again.
+      SystemClock.sleep(100);
+      filesDir = context.getFilesDir();
+      if (filesDir == null) {
+        throw new IllegalStateException("getFilesDir returned null twice.");
+      }
+    }
+    return filesDir;
+  }
+
+  /** Private API to support {@link #getAvailableStorageSpace} on sdk JB-MR2 and higher. */
+  @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2)
+  private static long getAvailableStorageSpaceJBMR2(File dir) {
+    StatFs stat = new StatFs(dir.getPath());
+    return stat.getAvailableBytes();
+  }
+
+  /** Private API to support {@link #getAvailableStorageSpace} on lower sdk levels. */
+  private static long getAvailableStorageSpaceICS(File dir) {
+    StatFs stat = new StatFs(dir.getPath());
+    return (long) stat.getBlockSize() * stat.getAvailableBlocks();
+  }
+
+  /**
+   * Returns the data directory of {@code context} in the DirectBoot storage partition. Each call to
+   * this method creates a new instance of {@link Context}, so the result should reused if possible.
+   */
+  @TargetApi(Build.VERSION_CODES.N)
+  public static File getDeviceProtectedDataDir(Context context) {
+    Context dpsContext = context.createDeviceProtectedStorageContext();
+    File dpsFilesDir = getFilesDirWithPreNWorkaround(dpsContext);
+    File dpsDataDir = dpsFilesDir.getParentFile();
+    return dpsDataDir;
+  }
+
+  private AndroidFileEnvironment() {}
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/backends/AndroidUri.java b/java/com/google/android/libraries/mobiledatadownload/file/backends/AndroidUri.java
new file mode 100644
index 0000000..da6bc2e
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/backends/AndroidUri.java
@@ -0,0 +1,370 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.backends;
+
+import android.accounts.Account;
+import android.content.Context;
+import android.net.Uri;
+import android.os.Build;
+import com.google.android.libraries.mobiledatadownload.file.common.MalformedUriException;
+import com.google.android.libraries.mobiledatadownload.file.common.internal.LiteTransformFragments;
+import com.google.android.libraries.mobiledatadownload.file.common.internal.Preconditions;
+import com.google.android.libraries.mobiledatadownload.file.transforms.TransformProtos;
+import com.google.common.collect.ImmutableList;
+import com.google.mobiledatadownload.TransformProto;
+import java.io.File;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.regex.Pattern;
+import javax.annotation.Nullable;
+
+/** Helper class for "android:" URIs. */
+public final class AndroidUri {
+
+  /**
+   * Returns an android: scheme URI builder for package {@code packageName}. If no setter is called
+   * before {@link Builder#build}, the resultant URI will point to the common internal app storage,
+   * i.e. "android://<packageName>/files/common/shared/"
+   *
+   * @param context The android environment.
+   */
+  public static Builder builder(Context context) {
+    return new Builder(context);
+  }
+
+  private AndroidUri() {}
+
+  // Module names are non-empty strings of [a-z] with interleaved underscores
+  private static final Pattern MODULE_PATTERN = Pattern.compile("[a-z]+(_[a-z]+)*");
+
+  // Name registered for the Android backend
+  static final String SCHEME_NAME = "android";
+
+  // URI path fragments with special meaning
+  static final String FILES_LOCATION = "files";
+  static final String MANAGED_LOCATION = "managed";
+  static final String CACHE_LOCATION = "cache";
+  // See https://developer.android.com/training/articles/direct-boot.html
+  static final String DIRECT_BOOT_FILES_LOCATION = "directboot-files";
+  static final String DIRECT_BOOT_CACHE_LOCATION = "directboot-cache";
+  static final String EXTERNAL_LOCATION = "external";
+
+  // The "managed" location maps to a subdirectory within /files/.
+  static final String MANAGED_FILES_DIR_SUBDIRECTORY = "managed";
+
+  static final String COMMON_MODULE = "common";
+  static final Account SHARED_ACCOUNT = AccountSerialization.SHARED_ACCOUNT;
+
+  // Module names reserved for future use or that are otherwise disallowed. Note that ImmutableSet
+  // is avoided in order to avoid guava dependency.
+  private static final Set<String> RESERVED_MODULES =
+      Collections.unmodifiableSet(
+          new HashSet<>(
+              Arrays.asList(
+                  "default", "unused", "special", "reserved", "shared", "virtual", "managed")));
+
+  private static final Set<String> VALID_LOCATIONS =
+      Collections.unmodifiableSet(
+          new HashSet<>(
+              Arrays.asList(
+                  FILES_LOCATION,
+                  CACHE_LOCATION,
+                  MANAGED_LOCATION,
+                  DIRECT_BOOT_FILES_LOCATION,
+                  DIRECT_BOOT_CACHE_LOCATION,
+                  EXTERNAL_LOCATION)));
+
+  /**
+   * Validates the {@code location} of an Android URI path; "files" and "directboot" are the only
+   * valid strings.
+   */
+  static void validateLocation(String location) {
+    Preconditions.checkArgument(
+        VALID_LOCATIONS.contains(location),
+        "The only supported locations are %s: %s",
+        VALID_LOCATIONS,
+        location);
+  }
+  /**
+   * Validates the {@code module} of an Android URI path. Any non-empty string of [a-z] with
+   * interleaved underscores that is not listed as reserved is valid.
+   */
+  static void validateModule(String module) {
+    Preconditions.checkArgument(
+        MODULE_PATTERN.matcher(module).matches(), "Module must match [a-z]+(_[a-z]+)*: %s", module);
+    Preconditions.checkArgument(
+        !RESERVED_MODULES.contains(module),
+        "Module name is reserved and cannot be used: %s",
+        module);
+  }
+
+  /**
+   * Validates the {@code unusedRelativePath} of an Android URI path. At present time this is a
+   * no-op.
+   *
+   * @param unusedRelativePath Not used.
+   */
+  static void validateRelativePath(String unusedRelativePath) {
+    // No-op
+  }
+
+  /** Builder for Android Uris. */
+  public static class Builder {
+
+    // URI authority; required
+    private final Context context;
+
+    // URI path components; optional
+    private String packageName; // TODO: should default be ""?
+    private String location = AndroidUri.FILES_LOCATION;
+    private String module = AndroidUri.COMMON_MODULE;
+    private Account account = AndroidUri.SHARED_ACCOUNT;
+    private String relativePath = "";
+
+    private final ImmutableList.Builder<String> encodedSpecs = ImmutableList.builder();
+
+    private Builder(Context context) {
+      Preconditions.checkArgument(context != null, "Context cannot be null");
+      this.context = context;
+      this.packageName = context.getPackageName();
+    }
+
+    /**
+     * Sets the package to use in the android uri AUTHORITY. Default is context.getPackageName().
+     */
+    public Builder setPackage(String packageName) {
+      this.packageName = packageName;
+      return this;
+    }
+
+    private Builder setLocation(String location) {
+      AndroidUri.validateLocation(location);
+      this.location = location;
+      return this;
+    }
+
+    public Builder setManagedLocation() {
+      return setLocation(MANAGED_LOCATION);
+    }
+
+    public Builder setExternalLocation() {
+      return setLocation(EXTERNAL_LOCATION);
+    }
+
+    public Builder setDirectBootFilesLocation() {
+      return setLocation(DIRECT_BOOT_FILES_LOCATION);
+    }
+
+    public Builder setDirectBootCacheLocation() {
+      return setLocation(DIRECT_BOOT_CACHE_LOCATION);
+    }
+
+    /** Internal location, aka "files", is the default location. */
+    public Builder setInternalLocation() {
+      return setLocation(FILES_LOCATION);
+    }
+
+    public Builder setCacheLocation() {
+      return setLocation(CACHE_LOCATION);
+    }
+
+    public Builder setModule(String module) {
+      AndroidUri.validateModule(module);
+      this.module = module;
+      return this;
+    }
+
+    /**
+     * Sets the account. AndroidUri.SHARED_ACCOUNT is the default, and it shows up as "shared" on
+     * the filesystem.
+     *
+     * <p>This method performs some account validation. Android Account itself requires that both
+     * the type and name fields be present. In addition to this requirement, this backend requires
+     * that the type contain no colons (as these are the delimiter used internally for the account
+     * serialization), and that neither the type nor the name include any slashes (as these are file
+     * separators).
+     *
+     * <p>The account will be URL encoded in its URI representation (so, eg, "<internal>@gmail.com"
+     * will appear as "you%40gmail.com"), but not in the file path representation used to access
+     * disk.
+     *
+     * <p>Note the Linux filesystem accepts filenames composed of any bytes except "/" and NULL.
+     *
+     * @param account The account to set.
+     * @return The fluent Builder.
+     */
+    public Builder setAccount(Account account) {
+      AccountSerialization.serialize(account); // performs validation internally
+      this.account = account;
+      return this;
+    }
+
+    /**
+     * Sets the component of the path after location, module and account. A single leading slash
+     * will be trimmed if present.
+     */
+    public Builder setRelativePath(String relativePath) {
+      if (relativePath.startsWith("/")) {
+        relativePath = relativePath.substring(1);
+      }
+      AndroidUri.validateRelativePath(relativePath);
+      this.relativePath = relativePath;
+      return this;
+    }
+
+    /**
+     * Updates builder with multiple fields from file param: location, module, account and relative
+     * path. This method will fail on "managed" paths (see {@link fromFile(File, AccountManager)}).
+     */
+    public Builder fromFile(File file) {
+      return fromAbsolutePath(file.getAbsolutePath(), /* accountManager= */ null);
+    }
+
+    /**
+     * Updates builder with multiple fields from file param: location, module, account and relative
+     * path. A non-null {@code accountManager} is required to handle "managed" paths.
+     */
+    public Builder fromFile(File file, @Nullable AccountManager accountManager) {
+      return fromAbsolutePath(file.getAbsolutePath(), accountManager);
+    }
+
+    /**
+     * Updates builder with multiple fields from absolute path param: location, module, account and
+     * relative path. This method will fail on "managed" paths (see {@link fromAbsolutePath(String,
+     * AccountManager)}).
+     */
+    public Builder fromAbsolutePath(String absolutePath) {
+      return fromAbsolutePath(absolutePath, /* accountManager= */ null);
+    }
+
+    /**
+     * Updates builder with multiple fields from absolute path param: location, module, account and
+     * relative path. A non-null {@code accountManager} is required to handle "managed" paths.
+     */
+    // TODO(b/129467051): remove requirement for segments after 0th (logical location)
+    public Builder fromAbsolutePath(String absolutePath, @Nullable AccountManager accountManager) {
+      // Get the file's path within internal files, /module/account</relativePath>
+      File filesDir = AndroidFileEnvironment.getFilesDirWithPreNWorkaround(context);
+      String filesDirPath = filesDir.getAbsolutePath();
+      String cacheDirPath = context.getCacheDir().getAbsolutePath();
+      String managedDirPath = new File(filesDir, MANAGED_FILES_DIR_SUBDIRECTORY).getAbsolutePath();
+      String externalDirPath = null;
+      File externalFilesDir = context.getExternalFilesDir(null);
+      if (externalFilesDir != null) {
+        externalDirPath = externalFilesDir.getAbsolutePath();
+      }
+      String directBootFilesPath = null;
+      String directBootCachePath = null;
+      if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+        // TODO(b/143610872): run after checking other dirs to minimize impact of new Context()'s
+        File dpsDataDir = AndroidFileEnvironment.getDeviceProtectedDataDir(context);
+        directBootFilesPath = new File(dpsDataDir, "files").getAbsolutePath();
+        directBootCachePath = new File(dpsDataDir, "cache").getAbsolutePath();
+      }
+
+      String internalPath;
+      if (absolutePath.startsWith(managedDirPath)) {
+        // managedDirPath must be checked before filesDirPath because filesDirPath is a prefix.
+        setLocation(AndroidUri.MANAGED_LOCATION);
+        internalPath = absolutePath.substring(managedDirPath.length());
+      } else if (absolutePath.startsWith(filesDirPath)) {
+        setLocation(AndroidUri.FILES_LOCATION);
+        internalPath = absolutePath.substring(filesDirPath.length());
+      } else if (absolutePath.startsWith(cacheDirPath)) {
+        setLocation(AndroidUri.CACHE_LOCATION);
+        internalPath = absolutePath.substring(cacheDirPath.length());
+      } else if (externalDirPath != null && absolutePath.startsWith(externalDirPath)) {
+        setLocation(AndroidUri.EXTERNAL_LOCATION);
+        internalPath = absolutePath.substring(externalDirPath.length());
+      } else if (directBootFilesPath != null && absolutePath.startsWith(directBootFilesPath)) {
+        setLocation(AndroidUri.DIRECT_BOOT_FILES_LOCATION);
+        internalPath = absolutePath.substring(directBootFilesPath.length());
+      } else if (directBootCachePath != null && absolutePath.startsWith(directBootCachePath)) {
+        setLocation(AndroidUri.DIRECT_BOOT_CACHE_LOCATION);
+        internalPath = absolutePath.substring(directBootCachePath.length());
+      } else {
+        throw new IllegalArgumentException(
+            "Path must be in app-private files dir or external files dir: " + absolutePath);
+      }
+
+      // Extract components according to android: file layout. The 0th element of split() will be
+      // an empty string preceding the first character "/"
+      List<String> pathFragments = Arrays.asList(internalPath.split(File.separator));
+      Preconditions.checkArgument(
+          pathFragments.size() >= 3,
+          "Path must be in module and account subdirectories: %s",
+          absolutePath);
+      setModule(pathFragments.get(1));
+
+      String accountStr = pathFragments.get(2);
+      if (MANAGED_LOCATION.equals(location) && !AccountSerialization.isSharedAccount(accountStr)) {
+        int accountId;
+        try {
+          accountId = Integer.parseInt(accountStr);
+        } catch (NumberFormatException e) {
+          throw new IllegalArgumentException(e);
+        }
+
+        // Blocks on disk IO to read account table.
+        // TODO(b/115940396): surface bad account as FileNotFoundException (change API signature?)
+        Preconditions.checkArgument(accountManager != null, "AccountManager cannot be null");
+        try {
+          setAccount(accountManager.getAccount(accountId).get());
+        } catch (InterruptedException e) {
+          Thread.currentThread().interrupt();
+          throw new IllegalArgumentException(new MalformedUriException(e));
+        } catch (ExecutionException e) {
+          throw new IllegalArgumentException(new MalformedUriException(e.getCause()));
+        }
+      } else {
+        setAccount(AccountSerialization.deserialize(accountStr));
+      }
+
+      setRelativePath(internalPath.substring(module.length() + accountStr.length() + 2));
+      return this;
+    }
+
+    public Builder withTransform(TransformProto.Transform spec) {
+      encodedSpecs.add(TransformProtos.toEncodedSpec(spec));
+      return this;
+    }
+
+    // TODO(b/115940396): add MalformedUriException to signature
+    public Uri build() {
+      String uriPath =
+          "/"
+              + location
+              + "/"
+              + module
+              + "/"
+              + AccountSerialization.serialize(account)
+              + "/"
+              + relativePath;
+      String fragment = LiteTransformFragments.joinTransformSpecs(encodedSpecs.build());
+
+      return new Uri.Builder()
+          .scheme(AndroidUri.SCHEME_NAME)
+          .authority(packageName)
+          .path(uriPath)
+          .encodedFragment(fragment)
+          .build();
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/backends/AndroidUriAdapter.java b/java/com/google/android/libraries/mobiledatadownload/file/backends/AndroidUriAdapter.java
new file mode 100644
index 0000000..7f42232
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/backends/AndroidUriAdapter.java
@@ -0,0 +1,143 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.backends;
+
+import android.accounts.Account;
+import android.content.Context;
+import android.net.Uri;
+import android.os.Build;
+import android.text.TextUtils;
+import com.google.android.libraries.mobiledatadownload.file.common.MalformedUriException;
+import java.io.File;
+import java.util.ArrayList;
+import java.util.concurrent.ExecutionException;
+import javax.annotation.Nullable;
+
+/**
+ * Adapter for converting "android:" URIs into java.io.File. This is considered dangerous since it
+ * ignores parts of the Uri at the caller's peril, and thus is only available to allowlisted clients
+ * (mostly internal).
+ */
+public final class AndroidUriAdapter implements UriAdapter {
+
+  private final Context context;
+  @Nullable private final AccountManager accountManager;
+
+  private AndroidUriAdapter(Context context, @Nullable AccountManager accountManager) {
+    this.context = context;
+    this.accountManager = accountManager;
+  }
+
+  /** This adapter will fail on "managed" URIs (see {@link forContext(Context, AccountManager)}). */
+  public static AndroidUriAdapter forContext(Context context) {
+    return new AndroidUriAdapter(context, /* accountManager= */ null);
+  }
+
+  /** A non-null {@code accountManager} is required to handle "managed" paths. */
+  public static AndroidUriAdapter forContext(Context context, AccountManager accountManager) {
+    return new AndroidUriAdapter(context, accountManager);
+  }
+
+  /* @throws MalformedUriException if the uri is not valid. */
+  public static void validate(Uri uri) throws MalformedUriException {
+    if (!uri.getScheme().equals(AndroidUri.SCHEME_NAME)) {
+      throw new MalformedUriException("Scheme must be 'android'");
+    }
+    if (uri.getPathSegments().isEmpty()) {
+      throw new MalformedUriException(
+          String.format("Path must start with a valid logical location: %s", uri));
+    }
+    if (!TextUtils.isEmpty(uri.getQuery())) {
+      throw new MalformedUriException("Did not expect uri to have query");
+    }
+  }
+
+  @Override
+  public File toFile(Uri uri) throws MalformedUriException {
+    validate(uri);
+    ArrayList<String> pathSegments = new ArrayList<>(uri.getPathSegments()); // allow modification
+    File rootLocation;
+    switch (pathSegments.get(0)) {
+      case AndroidUri.DIRECT_BOOT_FILES_LOCATION:
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+          rootLocation = context.createDeviceProtectedStorageContext().getFilesDir();
+        } else {
+          throw new MalformedUriException(
+              String.format(
+                  "Direct boot only exists on N or greater: current SDK %s",
+                  Build.VERSION.SDK_INT));
+        }
+
+        break;
+      case AndroidUri.DIRECT_BOOT_CACHE_LOCATION:
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+          rootLocation = context.createDeviceProtectedStorageContext().getCacheDir();
+        } else {
+          throw new MalformedUriException(
+              String.format(
+                  "Direct boot only exists on N or greater: current SDK %s",
+                  Build.VERSION.SDK_INT));
+        }
+
+        break;
+      case AndroidUri.FILES_LOCATION:
+        rootLocation = AndroidFileEnvironment.getFilesDirWithPreNWorkaround(context);
+        break;
+      case AndroidUri.CACHE_LOCATION:
+        rootLocation = context.getCacheDir();
+        break;
+      case AndroidUri.MANAGED_LOCATION:
+        File filesDir = AndroidFileEnvironment.getFilesDirWithPreNWorkaround(context);
+        rootLocation = new File(filesDir, AndroidUri.MANAGED_FILES_DIR_SUBDIRECTORY);
+
+        // Transform account segment from logical (plaintext) to physical (integer) representation.
+        if (pathSegments.size() >= 3) {
+          Account account;
+          try {
+            account = AccountSerialization.deserialize(pathSegments.get(2));
+          } catch (IllegalArgumentException e) {
+            throw new MalformedUriException(e);
+          }
+          if (!AccountSerialization.isSharedAccount(account)) {
+            if (accountManager == null) {
+              throw new MalformedUriException("AccountManager cannot be null");
+            }
+            // Blocks on disk IO to read account table.
+            try {
+              int accountId = accountManager.getAccountId(account).get();
+              pathSegments.set(2, Integer.toString(accountId));
+            } catch (InterruptedException e) {
+              Thread.currentThread().interrupt();
+              throw new MalformedUriException(e);
+            } catch (ExecutionException e) {
+              // TODO(b/115940396): surface bad account as FileNotFoundException (change signature?)
+              throw new MalformedUriException(e.getCause());
+            }
+          }
+        }
+
+        break;
+      case AndroidUri.EXTERNAL_LOCATION:
+        rootLocation = context.getExternalFilesDir(null);
+        break;
+      default:
+        throw new MalformedUriException(
+            String.format("Path must start with a valid logical location: %s", uri));
+    }
+    return new File(
+        rootLocation, TextUtils.join(File.separator, pathSegments.subList(1, pathSegments.size())));
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/backends/AssetFileBackend.java b/java/com/google/android/libraries/mobiledatadownload/file/backends/AssetFileBackend.java
new file mode 100644
index 0000000..8688b25
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/backends/AssetFileBackend.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.backends;
+
+import android.content.Context;
+import android.content.res.AssetFileDescriptor;
+import android.content.res.AssetManager;
+import android.net.Uri;
+import android.util.Pair;
+import com.google.android.libraries.mobiledatadownload.file.common.UnsupportedFileStorageOperation;
+import com.google.android.libraries.mobiledatadownload.file.spi.Backend;
+import com.google.common.base.Preconditions;
+import java.io.Closeable;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+
+/** Backend for handling Android's APK embedded assets. */
+public final class AssetFileBackend implements Backend {
+
+  private final AssetManager assetManager;
+
+  public static Builder builder(Context context) {
+    return new Builder(context);
+  }
+
+  /** Builds AssetFileBackend. */
+  public static final class Builder {
+    // Required parameters
+    private final Context context;
+
+    private Builder(Context context) {
+      Preconditions.checkArgument(context != null, "Context cannot be null");
+      this.context = context.getApplicationContext();
+    }
+
+    public AssetFileBackend build() {
+      return new AssetFileBackend(this);
+    }
+  }
+
+  private AssetFileBackend(Builder builder) {
+    assetManager = builder.context.getAssets();
+  }
+
+  @Override
+  public String name() {
+    return "asset";
+  }
+
+  @Override
+  public InputStream openForRead(Uri uri) throws IOException {
+    return assetManager.open(assetPath(uri));
+  }
+
+  @Override
+  public Pair<Uri, Closeable> openForNativeRead(Uri uri) throws UnsupportedFileStorageOperation {
+    throw new UnsupportedFileStorageOperation("Native read not supported (b/210546473)");
+  }
+
+  @Override
+  public boolean exists(Uri uri) throws IOException {
+    try (InputStream in = openForRead(uri)) {
+      return true;
+    } catch (FileNotFoundException e) {
+      return false;
+    }
+  }
+
+  @Override
+  public long fileSize(Uri uri) throws IOException {
+    try (AssetFileDescriptor descriptor = assetManager.openFd(assetPath(uri))) {
+      return descriptor.getLength();
+    }
+  }
+
+  @Override
+  public boolean isDirectory(Uri uri) {
+    return false;
+  }
+
+  private String assetPath(Uri uri) {
+    Preconditions.checkArgument("asset".equals(uri.getScheme()), "scheme must be 'asset'");
+    return uri.getPath().substring(1); // strip leading "/"
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/backends/BUILD b/java/com/google/android/libraries/mobiledatadownload/file/backends/BUILD
new file mode 100644
index 0000000..0e919e5
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/backends/BUILD
@@ -0,0 +1,243 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_library")
+
+package(
+    default_visibility = [
+        "//visibility:public",
+    ],
+    licenses = ["notice"],
+)
+
+# Most clients should depend on this target. It ensures that the "standard" schemes
+# are available. Care will be taken to keep the size small. However, if a client wants
+# even more granular control of dependencies, it can depend on a narrower build targets below.
+android_library(
+    name = "backends",
+    exports = [
+        ":android",
+        ":file",
+        ":file_descriptor",
+    ],
+)
+
+android_library(
+    name = "android",
+    srcs = [
+        "AndroidFileBackend.java",
+        "AndroidUri.java",
+    ],
+    deps = [
+        ":account_manager",
+        ":account_serialization",
+        ":android_adapter",
+        ":android_file_environment",
+        ":file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/internal:lite_transform_fragments",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/internal:preconditions",
+        "//java/com/google/android/libraries/mobiledatadownload/file/spi",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:proto",
+        "//proto:transform_java_proto_lite",
+        "@androidx_annotation_annotation",
+        "@com_google_code_findbugs_jsr305",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "blob_uri",
+    srcs = [
+        "BlobUri.java",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "@com_google_guava_guava",
+    ],
+)
+
+# It needs to be built against a stable android SDK, e.g. --android_sdk=//third_party/java/android/android_sdk_linux/platforms/stable:android_sdk_tools.
+android_library(
+    name = "blobstore_backend",
+    srcs = [
+        "BlobStoreBackend.java",
+    ],
+    deps = [
+        ":blob_uri",
+        ":file_descriptor",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "//java/com/google/android/libraries/mobiledatadownload/file/spi",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "content_resolver",
+    srcs = [
+        "ContentResolverBackend.java",
+    ],
+    deps = [
+        ":file_descriptor",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/internal:preconditions",
+        "//java/com/google/android/libraries/mobiledatadownload/file/spi",
+    ],
+)
+
+android_library(
+    name = "file_descriptor",
+    srcs = [
+        "FileDescriptorUri.java",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        # NOTE: dependency of gmscore client lib <internal>
+    ],
+)
+
+android_library(
+    name = "file",
+    srcs = [
+        "FileUri.java",
+        "JavaFileBackend.java",
+    ],
+    deps = [
+        ":file_adapter",
+        ":file_descriptor",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/internal:backend_stream",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/internal:lite_transform_fragments",
+        "//java/com/google/android/libraries/mobiledatadownload/file/spi",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:proto",
+        "//proto:transform_java_proto_lite",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "memory",
+    srcs = [
+        "MemoryBackend.java",
+        "MemoryUri.java",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/internal:lite_transform_fragments",
+        "//java/com/google/android/libraries/mobiledatadownload/file/spi",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:proto",
+        "//proto:transform_java_proto_lite",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "asset",
+    srcs = [
+        "AssetFileBackend.java",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "//java/com/google/android/libraries/mobiledatadownload/file/spi",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "android_adapter",
+    srcs = [
+        "AndroidUri.java",
+        "AndroidUriAdapter.java",
+        "UriAdapter.java",
+    ],
+    visibility = ["//:__subpackages__"],
+    deps = [
+        ":account_manager",
+        ":account_serialization",
+        ":android_file_environment",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/internal:lite_transform_fragments",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/internal:preconditions",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:proto",
+        "//proto:transform_java_proto_lite",
+        "@com_google_code_findbugs_jsr305",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "generic_adapter",
+    srcs = [
+        "GenericUriAdapter.java",
+        "UriAdapter.java",
+    ],
+    visibility = ["//:__subpackages__"],
+    deps = [
+        ":android_adapter",
+        ":file",
+        ":file_adapter",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+    ],
+)
+
+android_library(
+    name = "file_adapter",
+    srcs = [
+        "FileUriAdapter.java",
+        "UriAdapter.java",
+    ],
+    visibility = ["//:__subpackages__"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+    ],
+)
+
+# Shared library code restricted to the internal package group
+android_library(
+    name = "account_serialization",
+    srcs = [
+        "AccountSerialization.java",
+    ],
+    visibility = ["//:__subpackages__"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/internal:preconditions",
+        # NOTE: dependency of gmscore client lib <internal>
+    ],
+)
+
+android_library(
+    name = "account_manager",
+    srcs = [
+        "AccountManager.java",
+    ],
+    deps = [
+        "@com_google_guava_guava",
+    ],
+)
+
+# Shared library code restricted to the internal package group
+android_library(
+    name = "android_file_environment",
+    srcs = ["AndroidFileEnvironment.java"],
+    visibility = ["//:__subpackages__"],
+)
+
+android_library(
+    name = "uri_normalizer",
+    srcs = [
+        "UriNormalizer.java",
+    ],
+    deps = [
+        "@com_google_guava_guava",
+    ],
+)
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/backends/BlobStoreBackend.java b/java/com/google/android/libraries/mobiledatadownload/file/backends/BlobStoreBackend.java
new file mode 100644
index 0000000..497efc0
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/backends/BlobStoreBackend.java
@@ -0,0 +1,249 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.backends;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import android.annotation.SuppressLint;
+import android.app.blob.BlobHandle;
+import android.app.blob.BlobStoreManager;
+import android.content.Context;
+import android.net.Uri;
+import android.os.ParcelFileDescriptor;
+import android.util.Pair;
+import com.google.android.libraries.mobiledatadownload.file.common.LimitExceededException;
+import com.google.android.libraries.mobiledatadownload.file.spi.Backend;
+import com.google.common.util.concurrent.MoreExecutors;
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+
+/**
+ * Backend for accessing the Android blob Sharing Service.
+ *
+ * <p>For more details see {@link <internal>}.
+ *
+ * <p>Supports reading, writing, deleting and exists; every other operation provided by the {@link
+ * Backend} interface will throw {@link UnsupportedFileStorageOperation}.
+ *
+ * <p>Only available to Android SDK >= R.
+ */
+@SuppressLint({"NewApi", "WrongConstant"})
+@SuppressWarnings("AndroidJdkLibsChecker")
+public final class BlobStoreBackend implements Backend {
+  private static final String SCHEME = "blobstore";
+  // TODO(b/149260496): accept a custom label once available in the file config.
+  private static final String LABEL = "The file is shared to provide a better user experience";
+  // TODO(b/149260496): accept a custom tag once available in the file config.
+  private static final String TAG = "File downloaded through MDDLib";
+  // ExpiryDate set to 0 will be treated as expiryDate non-existent.
+  private static final long EXPIRY_DATE = 0;
+
+  private final BlobStoreManager blobStoreManager;
+
+  public BlobStoreBackend(Context context) {
+    this.blobStoreManager = (BlobStoreManager) context.getSystemService(Context.BLOB_STORE_SERVICE);
+  }
+
+  @Override
+  public String name() {
+    return SCHEME;
+  }
+
+  /** The uri should be: "blobstore://<package_name>/<non_empty_checksum>". */
+  @Override
+  public boolean exists(Uri uri) throws IOException {
+    boolean exists = false;
+    try (ParcelFileDescriptor pfd = openForReadInternal(uri)) {
+      if (pfd != null && pfd.getFileDescriptor().valid()) {
+        exists = true;
+      }
+    } catch (SecurityException e) {
+      // A SecurityException is thrown when the blob does not exist or the caller does not have
+      // access to it.
+    }
+    return exists;
+  }
+
+  /** The uri should be: "blobstore://<package_name>/<non_empty_checksum>". */
+  @Override
+  public InputStream openForRead(Uri uri) throws IOException {
+    return new ParcelFileDescriptor.AutoCloseInputStream(openForReadInternal(uri));
+  }
+
+  /** The uri should be: "blobstore://<package_name>/<non_empty_checksum>". */
+  @Override
+  public Pair<Uri, Closeable> openForNativeRead(Uri uri) throws IOException {
+    return FileDescriptorUri.fromParcelFileDescriptor(openForReadInternal(uri));
+  }
+
+  private ParcelFileDescriptor openForReadInternal(Uri uri) throws IOException {
+    BlobUri.validateUri(uri);
+    byte[] checksum = BlobUri.getChecksum(uri.getPath());
+    // TODO(b/149260496): add option to set a custom expiryDate in the uri.
+    BlobHandle blobHandle = BlobHandle.createWithSha256(checksum, LABEL, EXPIRY_DATE, TAG);
+    return blobStoreManager.openBlob(blobHandle);
+  }
+
+  /**
+   * Two possible URIs are accepted:
+   *
+   * <ul>
+   *   <li>"blobstore://<package_name>/<non_empty_checksum>". A new blob will be written in the blob
+   *       storage.
+   *   <li>"blobstore://<package_name>/<non_empty_checksum>.lease?expiryDateSecs=<expiryDateSecs>.".
+   *       A lease will be acquired on the blob specified by the encoded checksum.
+   * </ul>
+   *
+   * @throws MalformedUriException when the {@code uri} is malformed.
+   * @throws LimitExceededException when the caller is trying to create too many sessions, acquire
+   *     too many leases or acquire leases on too much data.
+   * @throws IOException when there is an I/O error while writing the blob/lease.
+   */
+  @Override
+  public OutputStream openForWrite(Uri uri) throws IOException {
+    BlobUri.validateUri(uri);
+    byte[] checksum = BlobUri.getChecksum(uri.getPath());
+    try {
+      if (BlobUri.isLeaseUri(uri.getPath())) {
+        // TODO(b/149260496): pass blob size from MDD to the backend so that the backend can check
+        // it against the remaining quota.
+        if (blobStoreManager.getRemainingLeaseQuotaBytes() <= 0) {
+          throw new LimitExceededException(
+              "The caller is trying to acquire a lease on too much data.");
+        }
+        long expiryDateMillis = SECONDS.toMillis(BlobUri.getExpiryDateSecs(uri));
+        acquireLease(checksum, expiryDateMillis);
+        return null;
+      }
+
+      BlobHandle blobHandle = BlobHandle.createWithSha256(checksum, LABEL, EXPIRY_DATE, TAG);
+      long sessionId = blobStoreManager.createSession(blobHandle);
+      BlobStoreManager.Session session = blobStoreManager.openSession(sessionId);
+      session.allowPublicAccess();
+      return new SuperFirstAutoCloseOutputStream(session.openWrite(0, -1), session);
+    } catch (android.os.LimitExceededException e) {
+      throw new LimitExceededException(e);
+    } catch (IllegalStateException e) {
+      throw new IOException("Failed to write into BlobStoreManager", e);
+    }
+  }
+
+  /**
+   * Releases the lease(s) on the blob(s) specified through the {@code uri}.
+   *
+   * <p>Two possible URIs are accepted:
+   *
+   * <ul>
+   *   <li>"blobstore://<package_name>/<non_empty_checksum>". The lease on the blob with checksum
+   *       <non_empty_checksum> will be released.
+   *   <li>"blobstore://<package_name>/*.lease.". All leases owned by calling package in the blob
+   *       shared storage will be released.
+   * </ul>
+   */
+  @Override
+  public void deleteFile(Uri uri) throws IOException {
+    BlobUri.validateUri(uri);
+    if (BlobUri.isAllLeasesUri(uri.getPath())) {
+      releaseAllLeases();
+      return;
+    }
+    byte[] checksum = BlobUri.getChecksum(uri.getPath());
+    releaseLease(checksum);
+  }
+
+  private void releaseAllLeases() throws IOException {
+    List<BlobHandle> blobHandles = blobStoreManager.getLeasedBlobs();
+    for (BlobHandle blobHandle : blobHandles) {
+      releaseLease(blobHandle.getSha256Digest());
+    }
+  }
+
+  @Override
+  public boolean isDirectory(Uri uri) {
+    return false;
+  }
+
+  private void acquireLease(byte[] checksum, long expiryDateMillis) throws IOException {
+    BlobHandle blobHandle = BlobHandle.createWithSha256(checksum, LABEL, EXPIRY_DATE, TAG);
+    // TODO(b/149260496): remove hardcoded description.
+    // NOTE: The lease description is meant for specifying why the app needs the data blob and
+    // should be geared towards end users.
+    blobStoreManager.acquireLease(
+        blobHandle,
+        "String description needed for providing a better user experience",
+        expiryDateMillis);
+  }
+
+  private void releaseLease(byte[] checksum) throws IOException {
+    BlobHandle blobHandle = BlobHandle.createWithSha256(checksum, LABEL, EXPIRY_DATE, TAG);
+    try {
+      blobStoreManager.releaseLease(blobHandle);
+    } catch (SecurityException | IllegalStateException | IllegalArgumentException e) {
+      throw new IOException("Failed to release the lease", e);
+    }
+  }
+
+  // NOTE: ParcelFileDescriptor.AutoCloseOutput|InputStream are affected by bug b/118316956. This
+  // was fixed in Android Q and this class requires Android R, so they are safe to use.
+  private static class SuperFirstAutoCloseOutputStream
+      extends ParcelFileDescriptor.AutoCloseOutputStream {
+    private final BlobStoreManager.Session session;
+    private boolean commitAttempted = false;
+
+    public SuperFirstAutoCloseOutputStream(
+        ParcelFileDescriptor pfd, BlobStoreManager.Session session) {
+      super(pfd);
+      this.session = session;
+    }
+
+    @Override
+    public void close() throws IOException {
+      try {
+        super.close();
+      } finally {
+        closeEverything();
+      }
+    }
+
+    private void closeEverything() throws IOException {
+      int result = 0;
+      Exception cause = null;
+      if (!commitAttempted) {
+        // Commit throws IllegalStateException if the session was already finalized, so avoid
+        // calling it more than once. We can assume Closeable closes are idempotent.
+        commitAttempted = true;
+        try {
+          CompletableFuture<Integer> callback = new CompletableFuture<>();
+          session.commit(MoreExecutors.directExecutor(), callback::complete);
+          result = callback.get();
+        } catch (ExecutionException | InterruptedException | RuntimeException e) {
+          result = -1;
+          cause = e;
+        }
+      }
+      try (session) {
+        if (result != 0) {
+          throw new IOException("Commit operation failed", cause);
+        }
+      }
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/backends/BlobUri.java b/java/com/google/android/libraries/mobiledatadownload/file/backends/BlobUri.java
new file mode 100644
index 0000000..28b14e5
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/backends/BlobUri.java
@@ -0,0 +1,180 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.backends;
+
+import android.content.Context;
+import android.net.Uri;
+import android.text.TextUtils;
+import com.google.android.libraries.mobiledatadownload.file.common.MalformedUriException;
+import com.google.common.base.Splitter;
+import com.google.common.io.BaseEncoding;
+import java.util.List;
+
+/** Helper class for "blobstore" URIs. */
+public final class BlobUri {
+  // Uri path constants
+  public static final String SCHEME = "blobstore";
+  private static final String LEASE_URI_SUFFIX = ".lease";
+  private static final String CHECKSUM_SEPARATOR = ".";
+  private static final String ALL_LEASES_PATH = "*" + LEASE_URI_SUFFIX;
+  private static final int PATH_SIZE = 1;
+  // Uri query constants
+  private static final int QUERY_PARAMETERS = 1; // A single query element called expiryDateSecs
+  private static final String EXPIRY_DATE_QUERY_KEY = "expiryDateSecs";
+  // MalformedException message strings
+  private static final String EXPECTED_BLOB_URI_PATH = "<non_empty_checksum>";
+  private static final String EXPECTED_LEASE_URI_PATH = "<non_empty_checksum>.lease";
+  private static final String EXPECTED_LEASE_URI_QUERY = "expiryDateSecs=<expiryDateSecs>";
+
+  private static final Splitter SPLITTER = Splitter.on(CHECKSUM_SEPARATOR);
+
+  /** Returns a "blobstore" scheme URI. */
+  public static Builder builder(Context context) {
+    return new Builder(context);
+  }
+
+  private BlobUri() {}
+
+  static void validateUri(Uri uri) throws MalformedUriException {
+    validatePath(uri);
+    validateQuery(uri);
+  }
+
+  /**
+   * Validates the path of the "blobstore" scheme URI.
+   *
+   * <p>Theare only two permitted paths:
+   *
+   * <ul>
+   *   <li><non_empty_checksum>
+   *   <li><non_empty_checksum>.lease
+   * </ul>
+   */
+  private static void validatePath(Uri uri) throws MalformedUriException {
+    List<String> pathSegments = uri.getPathSegments();
+    if (pathSegments.size() != PATH_SIZE || !hasValidChecksumExtension(pathSegments.get(0))) {
+      throw new MalformedUriException(
+          String.format(
+              "The uri is malformed, expected %s or %s but found %s",
+              EXPECTED_BLOB_URI_PATH, EXPECTED_LEASE_URI_PATH, uri.getPath()));
+    }
+  }
+
+  private static boolean hasValidChecksumExtension(String path) {
+    return SPLITTER.splitToList(path).size() == 1
+        || (isLeaseUri(path) && !TextUtils.equals(path, LEASE_URI_SUFFIX));
+  }
+
+  /** Returns true if the path is of type "<checksum>.lease". */
+  static boolean isLeaseUri(String path) {
+    return path.endsWith(LEASE_URI_SUFFIX);
+  }
+
+  /** Returns true if the path matches "*.lease". */
+  static boolean isAllLeasesUri(String path) {
+    if (path.startsWith("/")) {
+      path = path.substring(1);
+    }
+    return TextUtils.equals(path, ALL_LEASES_PATH);
+  }
+
+  /**
+   * If available, validates the query part of the "blobstore" scheme URI.
+   *
+   * <p>There is one permitted query parameter: expiryDateSecs=<expiryDateSecs>.
+   */
+  private static void validateQuery(Uri uri) throws MalformedUriException {
+    if (TextUtils.isEmpty(uri.getQuery())) {
+      return;
+    }
+    if (uri.getQueryParameterNames().size() != QUERY_PARAMETERS
+        || uri.getQueryParameter(EXPIRY_DATE_QUERY_KEY) == null) {
+      throw new MalformedUriException(
+          String.format(
+              "The uri query is malformed, expected %s but found query %s",
+              EXPECTED_LEASE_URI_QUERY, uri.getQuery()));
+    }
+  }
+
+  /**
+   * Returns the checksum bytes encoded in the {@code path}.
+   *
+   * <p>To decode the bytes from the path, it uses the same encoding used by {@code //
+   * com.google.android.libraries.mobiledatadownload.internal.downloader.FileValidator}.
+   */
+  static byte[] getChecksum(String path) {
+    if (path.startsWith("/")) {
+      path = path.substring(1);
+    }
+    return BaseEncoding.base16().lowerCase().decode(SPLITTER.splitToList(path).get(0));
+  }
+
+  /* Parses the {@code query} and returns the encoded {@code expiryDateSecs}. */
+  static long getExpiryDateSecs(Uri uri) throws MalformedUriException {
+    String query = uri.getQuery();
+    if (TextUtils.isEmpty(query)) {
+      throw new MalformedUriException(
+          String.format("The uri query is null or empty, expected %s", EXPECTED_LEASE_URI_QUERY));
+    }
+    String expiryDateSecsString = uri.getQueryParameter(EXPIRY_DATE_QUERY_KEY);
+    if (expiryDateSecsString == null) {
+      throw new MalformedUriException(
+          String.format(
+              "The uri query is malformed, expected %s but found %s",
+              EXPECTED_LEASE_URI_QUERY, query));
+    }
+    long expiryDateSecs = Long.parseLong(expiryDateSecsString);
+    return expiryDateSecs;
+  }
+
+  /** A builder for "blobstore" scheme Uris. */
+  public static class Builder {
+    private String path = "";
+    private String packageName = "";
+    private long expiryDateSecs;
+
+    private Builder(Context context) {
+      // TODO(b/149260496): remove/change meaning to packageName
+      this.packageName = context.getPackageName();
+    }
+
+    public Builder setBlobParameters(String checksum) {
+      path = checksum;
+      return this;
+    }
+
+    public Builder setLeaseParameters(String checksum, long expiryDateSecs) {
+      path = checksum + LEASE_URI_SUFFIX;
+      this.expiryDateSecs = expiryDateSecs;
+      return this;
+    }
+
+    public Builder setAllLeasesParameters() {
+      path = ALL_LEASES_PATH;
+      return this;
+    }
+
+    public Uri build() throws MalformedUriException {
+      Uri.Builder uriBuilder = new Uri.Builder().scheme(SCHEME).authority(packageName).path(path);
+      if (isLeaseUri(path) && !isAllLeasesUri(path)) {
+        uriBuilder.appendQueryParameter(EXPIRY_DATE_QUERY_KEY, String.valueOf(expiryDateSecs));
+      }
+      Uri uri = uriBuilder.build();
+      validateUri(uri);
+      return uri;
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/backends/ContentResolverBackend.java b/java/com/google/android/libraries/mobiledatadownload/file/backends/ContentResolverBackend.java
new file mode 100644
index 0000000..5c31747
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/backends/ContentResolverBackend.java
@@ -0,0 +1,113 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.backends;
+
+import android.content.Context;
+import android.net.Uri;
+import android.os.ParcelFileDescriptor;
+import android.util.Pair;
+import com.google.android.libraries.mobiledatadownload.file.common.MalformedUriException;
+import com.google.android.libraries.mobiledatadownload.file.common.internal.Preconditions;
+import com.google.android.libraries.mobiledatadownload.file.spi.Backend;
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * A backend for accessing remote content that uses the Android platform content resolver framework.
+ * It can be used standalone or as a remote URI resolver within the {@link AndroidFileBackend}.
+ *
+ * <p>Usage: <code>
+ * AndroidFileBackend backend =
+ *     AndroidFileBackend.builder(context)
+ *         .setRemoteBackend(ContentResolverBackend.builder(context).setEmbedded(true).build())
+ *         .build();
+ * </code>
+ *
+ * <p>NOTE: In most cases, you'll want to use the GmsClientBackend for accessing files from GMS
+ * core. This backend is used to access files from other Apps. Since there are possible security
+ * concerns with doing so, ContentResolverBackend is restricted to the "content_resolver_allowlist".
+ * See <internal> for more information.
+ */
+public final class ContentResolverBackend implements Backend {
+
+  private static final String CONTENT_SCHEME = "content";
+
+  private final Context context;
+  private final boolean isEmbedded;
+
+  public static Builder builder(Context context) {
+    return new Builder(context);
+  }
+
+  /** Builder for {@code ContentResolverBackend}. */
+  public static class Builder {
+    private final Context context;
+    private boolean isEmbedded = false;
+
+    /** Construct a new builder instance. */
+    private Builder(Context context) {
+      this.context = context;
+    }
+
+    /**
+     * Tells whether this backend is expected to be embedded in another backend. If so, rewrites the
+     * scheme to "content"; if not, requires that the scheme be "content".
+     */
+    public Builder setEmbedded(boolean isEmbedded) {
+      this.isEmbedded = isEmbedded;
+      return this;
+    }
+
+    public ContentResolverBackend build() {
+      return new ContentResolverBackend(context, isEmbedded);
+    }
+  }
+
+  private ContentResolverBackend(Context context, boolean isEmbedded) {
+    this.context = context.getApplicationContext();
+    this.isEmbedded = isEmbedded;
+  }
+
+  @Override
+  public String name() {
+    Preconditions.checkState(!isEmbedded, "Misconfigured embedded backend.");
+    return CONTENT_SCHEME;
+  }
+
+  @Override
+  public InputStream openForRead(Uri uri) throws IOException {
+    Uri contentUri = rewriteAndCheckUri(uri);
+    return context.getContentResolver().openInputStream(contentUri);
+  }
+
+  @Override
+  public Pair<Uri, Closeable> openForNativeRead(Uri uri) throws IOException {
+    Uri contentUri = rewriteAndCheckUri(uri);
+    ParcelFileDescriptor pfd = context.getContentResolver().openFileDescriptor(contentUri, "r");
+    return FileDescriptorUri.fromParcelFileDescriptor(pfd);
+  }
+
+  private Uri rewriteAndCheckUri(Uri uri) throws MalformedUriException {
+    if (isEmbedded) {
+      return uri.buildUpon().scheme(CONTENT_SCHEME).build();
+    }
+    if (!CONTENT_SCHEME.equals(uri.getScheme())) {
+      throw new MalformedUriException("Expected scheme to be " + CONTENT_SCHEME);
+    }
+    return uri;
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/backends/FileDescriptorUri.java b/java/com/google/android/libraries/mobiledatadownload/file/backends/FileDescriptorUri.java
new file mode 100644
index 0000000..cc5041a
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/backends/FileDescriptorUri.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.backends;
+
+import android.net.Uri;
+import android.os.ParcelFileDescriptor;
+import android.util.Pair;
+import com.google.android.libraries.mobiledatadownload.file.common.MalformedUriException;
+import java.io.Closeable;
+
+/**
+ * Helper class for "fd:" URIs that refer to file descriptors. The opaque part of these URIs is
+ * simply the file descriptor int value stringified. These URIs are transient and should never be
+ * stored.
+ *
+ * <p>The primary use case is fetching a ParcelFileDescriptor from Java and passing that down to
+ * native code. The URI is paired with a closer which must be called to free system resources. Java
+ * can do so immediately after passing the URI to native; if the native fd backend needs to keep the
+ * file descriptor for longer, it will make a duplicate.
+ *
+ * <p>TODO: Java implementation of file descriptor backend.
+ */
+public final class FileDescriptorUri {
+
+  /** Create a pair of URI and closer for underlying resources from a ParcelFileDescriptor. */
+  public static Pair<Uri, Closeable> fromParcelFileDescriptor(ParcelFileDescriptor pfd) {
+    int fd = pfd.getFd();
+    Uri uri = new Uri.Builder().scheme("fd").opaquePart(String.valueOf(fd)).build();
+    // NOTE: ParcelFileDescriptor doesn't implement Closeable on SDK 15, so we need to wrap
+    return Pair.create(uri, () -> pfd.close());
+  }
+
+  /** Gets the integer file descriptor from this URI. */
+  public static int getFd(Uri uri) throws MalformedUriException {
+    if (!uri.getScheme().equals("fd")) {
+      throw new MalformedUriException("Scheme must be 'fd'");
+    }
+    try {
+      return Integer.parseInt(uri.getSchemeSpecificPart());
+    } catch (NumberFormatException e) {
+      throw new MalformedUriException(e);
+    }
+  }
+
+  private FileDescriptorUri() {}
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/backends/FileUri.java b/java/com/google/android/libraries/mobiledatadownload/file/backends/FileUri.java
new file mode 100644
index 0000000..58f9508
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/backends/FileUri.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.backends;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.common.internal.LiteTransformFragments;
+import com.google.android.libraries.mobiledatadownload.file.transforms.TransformProtos;
+import com.google.common.collect.ImmutableList;
+import com.google.mobiledatadownload.TransformProto;
+import java.io.File;
+
+/** Helper class for "file:" Uris. TODO(b/62106564) Rename to "localfile". */
+public final class FileUri {
+
+  static final String SCHEME_NAME = "file";
+
+  /** Creates a file: scheme URI builder. */
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  /** Builds a URI from a File. */
+  public static Uri fromFile(File file) {
+    return builder().fromFile(file).build();
+  }
+
+  private FileUri() {}
+
+  /** A builder for file: scheme URIs. */
+  public static class Builder {
+    private Uri.Builder uri = new Uri.Builder().scheme("file").authority("").path("/");
+    private final ImmutableList.Builder<String> encodedSpecs = ImmutableList.builder();
+
+    private Builder() {}
+
+    public Builder setPath(String path) {
+      uri.path(path);
+      return this;
+    }
+
+    public Builder fromFile(File file) {
+      uri.path(file.getAbsolutePath());
+      return this;
+    }
+
+    public Builder appendPath(String segment) {
+      uri.appendPath(segment);
+      return this;
+    }
+
+    public Builder withTransform(TransformProto.Transform spec) {
+      encodedSpecs.add(TransformProtos.toEncodedSpec(spec));
+      return this;
+    }
+
+    public Uri build() {
+      String fragment = LiteTransformFragments.joinTransformSpecs(encodedSpecs.build());
+      return uri.encodedFragment(fragment).build();
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/backends/FileUriAdapter.java b/java/com/google/android/libraries/mobiledatadownload/file/backends/FileUriAdapter.java
new file mode 100644
index 0000000..ea73f06
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/backends/FileUriAdapter.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.backends;
+
+import android.net.Uri;
+import android.text.TextUtils;
+import com.google.android.libraries.mobiledatadownload.file.common.MalformedUriException;
+import java.io.File;
+
+/**
+ * Adapter for converting "file:" URIs into java.io.File. This is considered dangerous since it
+ * ignores parts of the Uri at the caller's peril, and thus is only available to allowlisted clients
+ * (mostly internal).
+ */
+public class FileUriAdapter implements UriAdapter {
+
+  private static final FileUriAdapter INSTANCE = new FileUriAdapter();
+
+  private FileUriAdapter() {}
+
+  public static FileUriAdapter instance() {
+    return INSTANCE;
+  }
+
+  @Override
+  public File toFile(Uri uri) throws MalformedUriException {
+    if (!uri.getScheme().equals("file")) {
+      throw new MalformedUriException("Scheme must be 'file'");
+    }
+    if (!TextUtils.isEmpty(uri.getQuery())) {
+      throw new MalformedUriException("Did not expect uri to have query");
+    }
+    if (!TextUtils.isEmpty(uri.getAuthority())) {
+      throw new MalformedUriException("Did not expect uri to have authority");
+    }
+    return new File(uri.getPath());
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/backends/GenericUriAdapter.java b/java/com/google/android/libraries/mobiledatadownload/file/backends/GenericUriAdapter.java
new file mode 100644
index 0000000..5e244f2
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/backends/GenericUriAdapter.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.backends;
+
+import android.content.Context;
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.common.MalformedUriException;
+import java.io.File;
+
+/**
+ * Adapter for converting "android:" URIs into java.io.File. This is considered dangerous since it
+ * ignores parts of the Uri at the caller's peril, and thus is only available to allowlisted clients
+ * (mostly internal).
+ */
+public final class GenericUriAdapter implements UriAdapter {
+
+  private final AndroidUriAdapter androidUriAdapter;
+  private final FileUriAdapter fileUriAdapter;
+
+  private GenericUriAdapter(Context context) {
+    androidUriAdapter = AndroidUriAdapter.forContext(context);
+    fileUriAdapter = FileUriAdapter.instance();
+  }
+
+  public static GenericUriAdapter forContext(Context context) {
+    return new GenericUriAdapter(context);
+  }
+
+  @Override
+  public File toFile(Uri uri) throws MalformedUriException {
+    switch (uri.getScheme()) {
+      case AndroidUri.SCHEME_NAME:
+        return androidUriAdapter.toFile(uri);
+      case FileUri.SCHEME_NAME:
+        return fileUriAdapter.toFile(uri);
+      default:
+        throw new MalformedUriException("Couldn't convert URI to path: " + uri);
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/backends/JavaFileBackend.java b/java/com/google/android/libraries/mobiledatadownload/file/backends/JavaFileBackend.java
new file mode 100644
index 0000000..dcd43a2
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/backends/JavaFileBackend.java
@@ -0,0 +1,181 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.backends;
+
+import android.net.Uri;
+import android.os.ParcelFileDescriptor;
+import android.util.Pair;
+import com.google.android.libraries.mobiledatadownload.file.common.LockScope;
+import com.google.android.libraries.mobiledatadownload.file.common.internal.BackendInputStream;
+import com.google.android.libraries.mobiledatadownload.file.common.internal.BackendOutputStream;
+import com.google.android.libraries.mobiledatadownload.file.spi.Backend;
+import com.google.common.io.Files;
+import java.io.Closeable;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.List;
+
+/** A backend that implements "file:" scheme using java.io.FileInput/OutputStream. */
+public final class JavaFileBackend implements Backend {
+
+  private final LockScope lockScope;
+
+  public JavaFileBackend() {
+    this(new LockScope());
+  }
+
+  /**
+   * Overrides the default backend-scoped {@link LockScope} with the given {@code lockScope}. This
+   * injection is only necessary if there are multiple backend instances in the same process and
+   * there's a risk of them acquiring a lock on the same underlying file.
+   */
+  public JavaFileBackend(LockScope lockScope) {
+    this.lockScope = lockScope;
+  }
+
+  @Override
+  public String name() {
+    return "file";
+  }
+
+  @Override
+  public InputStream openForRead(Uri uri) throws IOException {
+    File file = FileUriAdapter.instance().toFile(uri);
+    return BackendInputStream.create(file);
+  }
+
+  @Override
+  public Pair<Uri, Closeable> openForNativeRead(Uri uri) throws IOException {
+    File file = FileUriAdapter.instance().toFile(uri);
+    ParcelFileDescriptor pfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
+    return FileDescriptorUri.fromParcelFileDescriptor(pfd);
+  }
+
+  @Override
+  public OutputStream openForWrite(Uri uri) throws IOException {
+    File file = FileUriAdapter.instance().toFile(uri);
+    Files.createParentDirs(file);
+    return BackendOutputStream.createForWrite(file);
+  }
+
+  @Override
+  public OutputStream openForAppend(Uri uri) throws IOException {
+    File file = FileUriAdapter.instance().toFile(uri);
+    Files.createParentDirs(file);
+    return BackendOutputStream.createForAppend(file);
+  }
+
+  @Override
+  public void deleteFile(Uri uri) throws IOException {
+    File file = FileUriAdapter.instance().toFile(uri);
+    if (file.isDirectory()) {
+      throw new FileNotFoundException(String.format("%s is a directory", uri));
+    }
+    if (!file.delete()) {
+      if (!file.exists()) {
+        throw new FileNotFoundException(String.format("%s does not exist", uri));
+      } else {
+        throw new IOException(String.format("%s could not be deleted", uri));
+      }
+    }
+  }
+
+  @Override
+  public void deleteDirectory(Uri uri) throws IOException {
+    File dir = FileUriAdapter.instance().toFile(uri);
+    if (!dir.isDirectory()) {
+      throw new FileNotFoundException(String.format("%s is not a directory", uri));
+    }
+    if (!dir.delete()) {
+      throw new IOException(String.format("%s could not be deleted", uri));
+    }
+  }
+
+  @Override
+  public void rename(Uri from, Uri to) throws IOException {
+    File fromFile = FileUriAdapter.instance().toFile(from);
+    File toFile = FileUriAdapter.instance().toFile(to);
+    Files.createParentDirs(toFile);
+    if (!fromFile.renameTo(toFile)) {
+      throw new IOException(String.format("%s could not be renamed to %s", from, to));
+    }
+  }
+
+  @Override
+  public boolean exists(Uri uri) throws IOException {
+    File file = FileUriAdapter.instance().toFile(uri);
+    return file.exists();
+  }
+
+  @Override
+  public boolean isDirectory(Uri uri) throws IOException {
+    File file = FileUriAdapter.instance().toFile(uri);
+    return file.isDirectory();
+  }
+
+  @Override
+  public void createDirectory(Uri uri) throws IOException {
+    File file = FileUriAdapter.instance().toFile(uri);
+    if (!file.mkdirs()) {
+      throw new IOException(String.format("%s could not be created", uri));
+    }
+  }
+
+  @Override
+  public long fileSize(Uri uri) throws IOException {
+    File file = FileUriAdapter.instance().toFile(uri);
+    if (file.isDirectory()) {
+      return 0;
+    }
+    return file.length();
+  }
+
+  @Override
+  public Iterable<Uri> children(Uri parentUri) throws IOException {
+    File parent = FileUriAdapter.instance().toFile(parentUri);
+    if (!parent.isDirectory()) {
+      throw new FileNotFoundException(String.format("%s is not a directory", parentUri));
+    }
+    File[] children = parent.listFiles();
+    if (children == null) {
+      throw new IOException(
+          String.format("Not a directory or I/O error (unexpected): %s", parentUri));
+    }
+    List<Uri> result = new ArrayList<Uri>();
+    for (File child : children) {
+      String path = child.getAbsolutePath();
+      if (child.isDirectory() && !path.endsWith("/")) {
+        path += "/";
+      }
+      result.add(FileUri.builder().setPath(path).build());
+    }
+    return result;
+  }
+
+  @Override
+  public File toFile(Uri uri) throws IOException {
+    return FileUriAdapter.instance().toFile(uri);
+  }
+
+  @Override
+  public LockScope lockScope() throws IOException {
+    return lockScope;
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/backends/MemoryBackend.java b/java/com/google/android/libraries/mobiledatadownload/file/backends/MemoryBackend.java
new file mode 100644
index 0000000..e64e26c
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/backends/MemoryBackend.java
@@ -0,0 +1,127 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.backends;
+
+import android.net.Uri;
+import android.text.TextUtils;
+import com.google.android.libraries.mobiledatadownload.file.common.MalformedUriException;
+import com.google.android.libraries.mobiledatadownload.file.spi.Backend;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.ByteBuffer;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * A {@link Backend} whose files exist only in memory. Files are never cached to disk and exist only
+ * for the duration of the backend itself. It has the following limitations:
+ *
+ * <ul>
+ *   <li>The backend is flat; directory operations are unsupported
+ *   <li>{@link Backend#openForAppend} is unsupported
+ *   <li>Native filesytem features such as FD's and syncing are unsupported
+ * </ul>
+ *
+ * <p>See <internal> for further documentation.
+ */
+// TODO(b/111694348): consider adding directory support
+public final class MemoryBackend implements Backend {
+
+  // Uri scheme the backend is registered under (package-private for use in {@link MemoryUri})
+  static final String URI_SCHEME = "memory";
+
+  private final Map<Uri, ByteBuffer> files = new HashMap<>();
+
+  @Override
+  public String name() {
+    return URI_SCHEME;
+  }
+
+  @Override
+  public InputStream openForRead(Uri uri) throws IOException {
+    validate(uri);
+    ByteBuffer buf = files.get(uri);
+    if (buf == null) {
+      throw new FileNotFoundException(String.format("%s (No such file)", uri));
+    }
+    ByteBuffer slice = buf.slice(); // Points to the same data but has an independent read position
+    return new ByteArrayInputStream(slice.array(), slice.arrayOffset(), slice.limit());
+  }
+
+  @Override
+  public OutputStream openForWrite(Uri uri) throws IOException {
+    validate(uri);
+    // Note that this is atomic in the sense that the data isn't flushed until closing the stream
+    return new ByteArrayOutputStream() {
+      @Override
+      public void close() throws IOException {
+        files.put(uri, ByteBuffer.wrap(this.buf, 0, this.count));
+      }
+    };
+  }
+
+  @Override
+  public void deleteFile(Uri uri) throws IOException {
+    validate(uri);
+    if (files.remove(uri) == null) {
+      throw new FileNotFoundException(String.format("%s does not exist", uri));
+    }
+  }
+
+  @Override
+  public void rename(Uri from, Uri to) throws IOException {
+    validate(from);
+    validate(to);
+    ByteBuffer buf = files.remove(from);
+    if (buf == null) {
+      throw new IOException(String.format("%s could not be renamed to %s", from, to));
+    }
+    files.put(to, buf);
+  }
+
+  @Override
+  public boolean exists(Uri uri) throws IOException {
+    validate(uri);
+    return files.containsKey(uri);
+  }
+
+  @Override
+  public long fileSize(Uri uri) throws IOException {
+    validate(uri);
+    ByteBuffer buf = files.get(uri);
+    return buf != null ? buf.limit() : 0;
+  }
+
+  /** Validates that {@code uri} is a non-empty memory: Uri with no authority or query. */
+  private static void validate(Uri uri) throws MalformedUriException {
+    if (!URI_SCHEME.equals(uri.getScheme())) {
+      throw new MalformedUriException("Expected scheme to be " + URI_SCHEME);
+    }
+    if (TextUtils.isEmpty(uri.getSchemeSpecificPart())) {
+      throw new MalformedUriException("Expected a non-empty file identifier");
+    }
+    if (!TextUtils.isEmpty(uri.getAuthority())) {
+      throw new MalformedUriException("Did not expect an authority");
+    }
+    if (!TextUtils.isEmpty(uri.getQuery())) {
+      throw new MalformedUriException("Did not expect a query");
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/backends/MemoryUri.java b/java/com/google/android/libraries/mobiledatadownload/file/backends/MemoryUri.java
new file mode 100644
index 0000000..2d69d72
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/backends/MemoryUri.java
@@ -0,0 +1,73 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.backends;
+
+import android.net.Uri;
+import android.text.TextUtils;
+import com.google.android.libraries.mobiledatadownload.file.common.MalformedUriException;
+import com.google.android.libraries.mobiledatadownload.file.common.internal.LiteTransformFragments;
+import com.google.android.libraries.mobiledatadownload.file.transforms.TransformProtos;
+import com.google.common.collect.ImmutableList;
+import com.google.mobiledatadownload.TransformProto;
+
+/**
+ * Helper class for "memory" scheme Uris. The scheme is opaque, with the opaque part simply used as
+ * a key to identify the file; it is non-hierarchical.
+ */
+public final class MemoryUri {
+
+  /** Returns an empty "memory" scheme Uri builder. */
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  private MemoryUri() {}
+
+  /** A builder for "memory" scheme Uris. */
+  public static final class Builder {
+
+    private String key = "";
+    private final ImmutableList.Builder<String> encodedSpecs = ImmutableList.builder();
+
+    private Builder() {}
+
+    /** Sets the non-empty key to be used as a file identifier. */
+    public Builder setKey(String key) {
+      this.key = key;
+      return this;
+    }
+
+    /**
+     * Appends a transform to the Uri. Calling twice with the same transform replaces the original.
+     */
+    public Builder withTransform(TransformProto.Transform spec) {
+      encodedSpecs.add(TransformProtos.toEncodedSpec(spec));
+      return this;
+    }
+
+    public Uri build() throws MalformedUriException {
+      if (TextUtils.isEmpty(key)) {
+        throw new MalformedUriException("Key must be non-empty");
+      }
+      String fragment = LiteTransformFragments.joinTransformSpecs(encodedSpecs.build());
+      return new Uri.Builder()
+          .scheme(MemoryBackend.URI_SCHEME)
+          .opaquePart(key)
+          .encodedFragment(fragment)
+          .build();
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/backends/UriAdapter.java b/java/com/google/android/libraries/mobiledatadownload/file/backends/UriAdapter.java
new file mode 100644
index 0000000..e9a73aa
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/backends/UriAdapter.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.backends;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.common.MalformedUriException;
+import java.io.File;
+
+/**
+ * Interface for converting certain URI schemes to raw java.io.Files. Implementations of this are
+ * considered dangerous since they ignore parts of the URI incluging the fragment at the caller's
+ * peril, and thus is only available to allowlisted clients (mostly internal).
+ */
+interface UriAdapter {
+  /**
+   * Adapts this uri into a File referring to the same underlying system object. Some components of
+   * the URI including the fragment are ignored.
+   */
+  File toFile(Uri uri) throws MalformedUriException;
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/backends/UriNormalizer.java b/java/com/google/android/libraries/mobiledatadownload/file/backends/UriNormalizer.java
new file mode 100644
index 0000000..e792a36
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/backends/UriNormalizer.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.backends;
+
+import static com.google.common.io.Files.simplifyPath;
+
+import android.annotation.TargetApi;
+import android.net.Uri;
+import android.os.Build;
+
+/** Utility class to normalize URIs */
+public final class UriNormalizer {
+
+  private UriNormalizer() {}
+
+  /** Normalizes the URI to prevent path traversal attacks. Requires SDK 16+. */
+  @TargetApi(Build.VERSION_CODES.JELLY_BEAN) // for Uri.normalizeScheme()
+  public static Uri normalizeUri(Uri uri) {
+    return uri.normalizeScheme().buildUpon().path(simplifyPath(uri.getPath())).build();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/behaviors/BUILD b/java/com/google/android/libraries/mobiledatadownload/file/behaviors/BUILD
new file mode 100644
index 0000000..5d2195a
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/behaviors/BUILD
@@ -0,0 +1,46 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_library")
+
+package(
+    default_visibility = [
+        "//visibility:public",
+    ],
+    licenses = ["notice"],
+)
+
+android_library(
+    name = "compute_uri",
+    srcs = [
+        "ComputedUriFuture.java",
+        "UriComputingBehavior.java",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common:fragment",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "syncing",
+    srcs = [
+        "SyncingBehavior.java",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "@com_google_guava_guava",
+    ],
+)
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/behaviors/ComputedUriFuture.java b/java/com/google/android/libraries/mobiledatadownload/file/behaviors/ComputedUriFuture.java
new file mode 100644
index 0000000..c340195
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/behaviors/ComputedUriFuture.java
@@ -0,0 +1,94 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.behaviors;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.common.Fragment;
+import com.google.android.libraries.mobiledatadownload.file.common.ParamComputer;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+/**
+ * Computed Uris are available only after the stream has been fully consumed or closed. This class
+ * enforces that behavior, and prevents any race conditions that could otherwise happen if a client
+ * tried to fetch the computed uri while another thread was still processing the stream.
+ */
+// TODO: ShouldNotSubclass Future
+@SuppressWarnings("ShouldNotSubclass")
+final class ComputedUriFuture implements Future<Uri>, ParamComputer.Callback {
+  private static final String TRANSFORM_PARAM = "transform";
+
+  private final Uri uri;
+  private final Fragment fragment;
+  private final CountDownLatch countDownLatch;
+  private final Fragment.Param.Builder transformsParamBuilder;
+
+  /** Construct a new instance with the original uri, and param computers. */
+  ComputedUriFuture(Uri uri, List<ParamComputer> paramComputers) {
+    this.uri = uri;
+    this.fragment = Fragment.parse(uri);
+    countDownLatch = new CountDownLatch(paramComputers.size());
+    for (ParamComputer paramComputer : paramComputers) {
+      paramComputer.setCallback(this);
+    }
+    Fragment.Param transformParam = fragment.findParam(TRANSFORM_PARAM);
+    this.transformsParamBuilder =
+        (transformParam != null)
+            ? transformParam.toBuilder()
+            : Fragment.Param.builder(TRANSFORM_PARAM);
+  }
+
+  @Override
+  public void onParamValueComputed(Fragment.ParamValue value) {
+    transformsParamBuilder.addValue(value);
+    countDownLatch.countDown();
+  }
+
+  @Override
+  public boolean cancel(boolean mayInterruptIfRunning) {
+    return false;
+  }
+
+  @Override
+  public boolean isCancelled() {
+    return false;
+  }
+
+  @Override
+  public boolean isDone() {
+    return (countDownLatch.getCount() == 0);
+  }
+
+  @Override
+  public Uri get() throws InterruptedException, ExecutionException {
+    countDownLatch.await();
+    Fragment computedFragment = fragment.toBuilder().addParam(transformsParamBuilder).build();
+    return uri.buildUpon().encodedFragment(computedFragment.toString()).build();
+  }
+
+  @Override
+  public Uri get(long timeout, TimeUnit unit)
+      throws InterruptedException, ExecutionException, TimeoutException {
+    if (!countDownLatch.await(timeout, unit)) {
+      throw new TimeoutException();
+    }
+    return get();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/behaviors/SyncingBehavior.java b/java/com/google/android/libraries/mobiledatadownload/file/behaviors/SyncingBehavior.java
new file mode 100644
index 0000000..b63c70f
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/behaviors/SyncingBehavior.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.behaviors;
+
+import com.google.android.libraries.mobiledatadownload.file.Behavior;
+import com.google.android.libraries.mobiledatadownload.file.common.Syncable;
+import com.google.android.libraries.mobiledatadownload.file.common.UnsupportedFileStorageOperation;
+import com.google.common.collect.Iterables;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.List;
+
+/**
+ * This Behavior can be used to ensure that all application buffers and OS buffers are flushed to
+ * persistent storage.
+ *
+ * <p>NOTE: not final for testing purposes only.
+ */
+public class SyncingBehavior implements Behavior {
+
+  private OutputStream headStream;
+  private Syncable syncable;
+
+  @Override
+  public void forOutputChain(List<OutputStream> chain) throws IOException {
+    OutputStream backendStream = Iterables.getLast(chain);
+    if (backendStream instanceof Syncable) {
+      syncable = ((Syncable) backendStream);
+      headStream = chain.get(0);
+    }
+  }
+
+  public void sync() throws IOException {
+    if (syncable == null) {
+      throw new UnsupportedFileStorageOperation("Cannot sync underlying stream");
+    }
+    headStream.flush();
+    syncable.sync();
+  }
+
+  @Override
+  public void commit() throws IOException {
+    sync();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/behaviors/UriComputingBehavior.java b/java/com/google/android/libraries/mobiledatadownload/file/behaviors/UriComputingBehavior.java
new file mode 100644
index 0000000..f561373
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/behaviors/UriComputingBehavior.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.behaviors;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.Behavior;
+import com.google.android.libraries.mobiledatadownload.file.common.ParamComputer;
+import com.google.common.collect.FluentIterable;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.List;
+import java.util.concurrent.Future;
+
+/**
+ * Behavior that supports computing a URI based on the contents of the stream. This is useful, eg,
+ * for generating a secure hash.
+ *
+ * <p>See <internal> for documentation.
+ */
+public class UriComputingBehavior implements Behavior {
+
+  private final Uri uri;
+  private ComputedUriFuture future;
+
+  /**
+   * Construct a new URI computing behavior for the provided URI. The URI will form as the base that
+   * is modified with the new computed fragment values, and make available via <code>uriFuture()
+   * </code> method. It is expected to be the same URI as is passed to the FileStorage <code>open()
+   * </code> method.
+   */
+  public UriComputingBehavior(Uri uri) {
+    this.uri = uri;
+  }
+
+  @Override
+  public void forInputChain(List<InputStream> chain) {
+    future =
+        new ComputedUriFuture(
+            uri,
+            FluentIterable.from(chain)
+                .filter((InputStream in) -> (in instanceof ParamComputer))
+                .transform((InputStream in) -> (ParamComputer) in)
+                .toList());
+  }
+
+  @Override
+  public void forOutputChain(List<OutputStream> chain) {
+    future =
+        new ComputedUriFuture(
+            uri,
+            FluentIterable.from(chain)
+                .filter((OutputStream out) -> (out instanceof ParamComputer))
+                .transform((OutputStream out) -> (ParamComputer) out)
+                .toList());
+  }
+
+  /**
+   * Gets a future to the URI with the fragment updated to include the computed metadata. This
+   * future will block until the stream has been fully consumed, or, when writing, closed.
+   */
+  public Future<Uri> uriFuture() {
+    return future;
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/BUILD b/java/com/google/android/libraries/mobiledatadownload/file/common/BUILD
new file mode 100644
index 0000000..c183243
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/BUILD
@@ -0,0 +1,59 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_library")
+
+package(
+    default_visibility = [
+        "//visibility:public",
+    ],
+    licenses = ["notice"],
+)
+
+android_library(
+    name = "common",
+    srcs = [
+        "FileChannelConvertible.java",
+        "FileConvertible.java",
+        "FileStorageUnavailableException.java",
+        "GcParam.java",
+        "InvalidAccountException.java",
+        "LazyInitializable.java",
+        "LimitExceededException.java",
+        "Lock.java",
+        "LockScope.java",
+        "MalformedUriException.java",
+        "ReleasableResource.java",
+        "Sizable.java",
+        "Syncable.java",
+        "UnsupportedFileStorageOperation.java",
+    ],
+    deps = [
+        "@com_google_code_findbugs_jsr305",
+        # NOTE: dependency of gmscore client lib <internal>
+    ],
+)
+
+android_library(
+    name = "fragment",
+    srcs = [
+        "Fragment.java",
+        "ParamComputer.java",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/internal:charsets",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/internal:preconditions",
+        "@com_google_code_findbugs_jsr305",
+        # NOTE: dependency of gmscore client lib <internal>
+    ],
+)
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/FileChannelConvertible.java b/java/com/google/android/libraries/mobiledatadownload/file/common/FileChannelConvertible.java
new file mode 100644
index 0000000..5944fb6
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/FileChannelConvertible.java
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common;
+
+import java.nio.channels.FileChannel;
+
+/** Something that can be converted to a FileChannel. */
+public interface FileChannelConvertible {
+  FileChannel toFileChannel();
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/FileConvertible.java b/java/com/google/android/libraries/mobiledatadownload/file/common/FileConvertible.java
new file mode 100644
index 0000000..656cbc6
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/FileConvertible.java
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common;
+
+import java.io.File;
+
+/** Something that can be converted to a File. */
+public interface FileConvertible {
+  File toFile();
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/FileStorageUnavailableException.java b/java/com/google/android/libraries/mobiledatadownload/file/common/FileStorageUnavailableException.java
new file mode 100644
index 0000000..7b49201
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/FileStorageUnavailableException.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common;
+
+import java.io.IOException;
+
+/** The operation cannot be completed because backend IO is in an unreachable state. */
+// TODO(b/63902177): mitigate expensive throwable constructor
+public final class FileStorageUnavailableException extends IOException {
+  public FileStorageUnavailableException(String message) {
+    super(message);
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/Fragment.java b/java/com/google/android/libraries/mobiledatadownload/file/common/Fragment.java
new file mode 100644
index 0000000..5d02885
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/Fragment.java
@@ -0,0 +1,612 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common;
+
+import static com.google.android.libraries.mobiledatadownload.file.common.internal.Charsets.UTF_8;
+
+import android.net.Uri;
+import android.text.TextUtils;
+import com.google.android.libraries.mobiledatadownload.file.common.internal.Preconditions;
+import java.io.UnsupportedEncodingException;
+import java.net.URLDecoder;
+import java.net.URLEncoder;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import javax.annotation.Nullable;
+
+/**
+ * A URI fragment parser and builder. Parses fragment params is similar to parsing of query params,
+ * and are delimited by "&". For example, <code>#foo=bar&us=them</code>
+ *
+ * <p>produces two params: one "foo" with value "bar", and another "us" with value "them".
+ *
+ * <p>Each fragment param must have at least one value; multiple values are delimited by "+". For
+ * example, <code>#foo=bar+baz</code>
+ *
+ * <p>produces one param, "foo", with two values "bar" and "baz".
+ *
+ * <p>Furthermore, fragment values can have subparams, which are additional information scoped to
+ * the value. Subparams have keys and optional values, and are delimited by ",". For example, <code>
+ * #foo=bar(x=1)+baz(this=that,when)</code>
+ *
+ * <p>produces one param, "foo", with two values "bar" and "baz" where "bar" has subparam "x" set at
+ * 1, baz has subparam "this" set at "that", and "when" is unset.
+ *
+ * <p>While the <internal> spec requires that keys and values are [0-9a-zA-Z-_]+, this class
+ * encodes/decodes keys/values, including subparams using java.net.URLEncoder/decoder. In
+ * particular, this class is responsible for producing strings suitable for being appended verbatim
+ * to the fragment part of an RFC3986 URI.
+ */
+public final class Fragment {
+  public static final Fragment EMPTY_FRAGMENT = new Fragment(null);
+  public static final Fragment.ParamValue EMPTY_FRAGMENT_PARAM_VALUE = new ParamValue(null, null);
+
+  private final List<Param> params = new ArrayList<Param>();
+
+  private Fragment(@Nullable List<Param> params) {
+    if (params != null) {
+      this.params.addAll(params);
+    }
+  }
+
+  /** Create a new, empty builder. */
+  public static Fragment.Builder builder() {
+    return new Fragment.Builder(null);
+  }
+
+  /** Return a builder based on this Fragment. */
+  public Fragment.Builder toBuilder() {
+    Fragment.Builder builder = builder();
+    for (Param param : params) {
+      builder.addParam(param.toBuilder());
+    }
+    return builder;
+  }
+
+  /** Iterate over the params. */
+  public List<Param> params() {
+    return Collections.unmodifiableList(params);
+  }
+
+  /** Finds the param with the given key. Returns null if not found. */
+  @Nullable
+  public Param findParam(String key) {
+    for (Param param : params) {
+      if (param.key.equals(key)) {
+        return param;
+      }
+    }
+    return null;
+  }
+
+  @Override
+  public String toString() {
+    return TextUtils.join("&", params);
+  }
+
+  /** Builder for the whole URI fragment. */
+  public static final class Builder {
+    private final List<Param.Builder> params = new ArrayList<Param.Builder>();
+
+    private Builder(@Nullable List<Param.Builder> params) {
+      if (params == null) {
+        return;
+      }
+      for (Param.Builder param : params) {
+        addParam(param);
+      }
+    }
+
+    /** Get all of the params as a list. */
+    public List<Param.Builder> params() {
+      return Collections.unmodifiableList(params);
+    }
+
+    /** Finds the param with the given key. Returns null if not found. */
+    @Nullable
+    public Param.Builder findParam(String key) {
+      for (Param.Builder param : params) {
+        if (param.key.equals(key)) {
+          return param;
+        }
+      }
+      return null;
+    }
+
+    /** Adds a param. If a param with same key already exists, this replaces it. */
+    public Builder addParam(Param param) {
+      addParam(param.toBuilder());
+      return this;
+    }
+
+    /** Adds a param. If a param with the same key already exist, this replaces it. */
+    public Builder addParam(Param.Builder param) {
+      for (int i = 0; i < params.size(); i++) {
+        if (params.get(i).key.equals(param.key)) {
+          params.set(i, param);
+          return this;
+        }
+      }
+      params.add(param);
+      return this;
+    }
+
+    /** Adds a simple param with no value. */
+    public Builder addParam(String key) {
+      return addParam(Param.builder(key));
+    }
+
+    /** Return an immutable Fragment. Unset params are ignored. */
+    public Fragment build() {
+      List<Param> params = new ArrayList<Param>();
+      for (Param.Builder builder : this.params) {
+        Param param = builder.build();
+        if (param != null) {
+          params.add(param);
+        }
+      }
+      return new Fragment(params);
+    }
+  }
+
+  /** A fragment param. */
+  public static final class Param {
+    private final String key;
+    private final List<ParamValue> values = new ArrayList<ParamValue>();
+
+    /**
+     * @throws IllegalArgumentException if {@code values} is empty.
+     */
+    private Param(String key, List<ParamValue> values) {
+      Preconditions.checkArgument(!values.isEmpty(), "Missing param values");
+      this.key = key;
+      this.values.addAll(values);
+    }
+
+    /** Gets the key for the param. */
+    public String key() {
+      return key;
+    }
+
+    /** Iterate over the values. */
+    public List<ParamValue> values() {
+      return Collections.unmodifiableList(values);
+    }
+
+    /** Find a value by name. Returns null if not found. */
+    @Nullable
+    public ParamValue findValue(String name) {
+      for (ParamValue value : values) {
+        if (value.name.equals(name)) {
+          return value;
+        }
+      }
+      return null;
+    }
+
+    /**
+     * Create a new param identified with a key.
+     *
+     * @param key The unique key.
+     * @return The param.
+     */
+    public static Param.Builder builder(String key) {
+      return new Param.Builder(key, null);
+    }
+
+    /** Return a builder based on this Param. */
+    public Param.Builder toBuilder() {
+      Param.Builder builder = builder(key);
+      for (ParamValue value : values) {
+        builder.addValue(value.toBuilder());
+      }
+      return builder;
+    }
+
+    @Override
+    public String toString() {
+      StringBuilder builder = new StringBuilder();
+      builder.append(urlEncode(key));
+      builder.append("=");
+      builder.append(TextUtils.join("+", values));
+      return builder.toString();
+    }
+
+    /** Builder for a fragment param. */
+    public static final class Builder {
+      private final String key;
+      private final List<ParamValue.Builder> values = new ArrayList<ParamValue.Builder>();
+
+      private Builder(String key, @Nullable List<ParamValue.Builder> values) {
+        this.key = key;
+        if (values == null) {
+          return;
+        }
+        for (ParamValue.Builder value : values) {
+          addValue(value);
+        }
+      }
+
+      /** Gets the key for the param. */
+      public String key() {
+        return key;
+      }
+
+      /** Get all of the param values as a list. */
+      public List<ParamValue.Builder> values() {
+        return Collections.unmodifiableList(values);
+      }
+
+      /** Find a value by name. Returns null if not found. */
+      @Nullable
+      public ParamValue.Builder findValue(String name) {
+        for (ParamValue.Builder value : values) {
+          if (value.name.equals(name)) {
+            return value;
+          }
+        }
+        return null;
+      }
+
+      /**
+       * Adds a value to this param. If a value already exists with the same name, this will replace
+       * it.
+       */
+      public Builder addValue(ParamValue value) {
+        addValue(value.toBuilder());
+        return this;
+      }
+
+      /**
+       * Adds a value to this param. If a value already exists with the same name, this will replace
+       * it.
+       */
+      public Builder addValue(ParamValue.Builder value) {
+        for (int i = 0; i < values.size(); i++) {
+          if (values.get(i).name.equals(value.name)) {
+            values.set(i, value);
+            return this;
+          }
+        }
+        values.add(value);
+        return this;
+      }
+
+      /** Adds a value that has no subparams. Also replaces existing value if present. */
+      public Builder addValue(String name) {
+        return addValue(new ParamValue.Builder(name, null));
+      }
+
+      /** Return a new immutable Param from this builder, or null if the param is unset. */
+      @Nullable
+      public Param build() {
+        if (this.values.isEmpty()) {
+          return null;
+        }
+        List<ParamValue> values = new ArrayList<ParamValue>();
+        for (ParamValue.Builder value : this.values) {
+          values.add(value.build());
+        }
+        return new Param(key, values);
+      }
+    }
+  }
+
+  /** A value of a fragment param. Each fragment param can have multiple values. */
+  public static final class ParamValue {
+
+    private final String name;
+    private final List<SubParam> subparams = new ArrayList<SubParam>();
+
+    private ParamValue(String name, @Nullable List<SubParam> subparams) {
+      this.name = name;
+      if (subparams != null) {
+        this.subparams.addAll(subparams);
+      }
+    }
+
+    /** Creates a new param value with the given name. */
+    public static ParamValue.Builder builder(String name) {
+      return new ParamValue.Builder(name, null);
+    }
+
+    /** Return a builder based on this ParamValue. */
+    public ParamValue.Builder toBuilder() {
+      ParamValue.Builder builder = builder(name);
+      for (SubParam subparam : subparams) {
+        builder.addSubParam(subparam);
+      }
+      return builder;
+    }
+
+    /** The name of the param value. */
+    public String name() {
+      return name;
+    }
+
+    /** Iterate over the subparams. */
+    public List<SubParam> subParams() {
+      return Collections.unmodifiableList(subparams);
+    }
+
+    /** Finds a subparam with the given key. If not found, returns null. */
+    @Nullable
+    public SubParam findSubParam(String key) {
+      for (SubParam subparam : subparams) {
+        if (subparam.key.equals(key)) {
+          return subparam;
+        }
+      }
+      return null;
+    }
+
+    /**
+     * Finds the subparam value with the given key. If the subparam or value is null, returns null.
+     *
+     * @param key
+     * @return The value of the subparam or null.
+     */
+    @Nullable
+    public String findSubParamValue(String key) {
+      SubParam subparam = findSubParam(key);
+      return (subparam == null) ? null : subparam.value;
+    }
+
+    @Override
+    public String toString() {
+      StringBuilder builder = new StringBuilder();
+      builder.append(urlEncode(name));
+      if (subparams.isEmpty()) {
+        return builder.toString();
+      }
+      builder.append("(");
+      builder.append(TextUtils.join(",", subparams));
+      builder.append(")");
+      return builder.toString();
+    }
+
+    /** Builder for a fragment param value. */
+    public static final class Builder {
+      private final String name;
+      private final List<SubParam> subparams = new ArrayList<SubParam>();
+
+      private Builder(String name, @Nullable List<SubParam> subparams) {
+        this.name = name;
+        if (subparams == null) {
+          return;
+        }
+        for (SubParam subparam : subparams) {
+          addSubParam(subparam);
+        }
+      }
+
+      /** The name of the param value. */
+      public String name() {
+        return name;
+      }
+
+      /** Get all of the subparams as a list. */
+      public List<SubParam> subparams() {
+        return Collections.unmodifiableList(subparams);
+      }
+
+      /** Finds a subparam with the given key. If not found, returns null. */
+      @Nullable
+      public SubParam findSubParam(String key) {
+        for (SubParam subparam : subparams) {
+          if (subparam.key.equals(key)) {
+            return subparam;
+          }
+        }
+        return null;
+      }
+
+      /**
+       * Finds the subparam value with the given key. If the subparam or value is null, returns
+       * null.
+       *
+       * @param key
+       * @return The value of the subparam or null.
+       */
+      @Nullable
+      public String findSubParamValue(String key) {
+        SubParam subparam = findSubParam(key);
+        return (subparam == null) ? null : subparam.value;
+      }
+
+      /**
+       * Adds a subparam. If an existing subparam exists with the same key, this will replace it.
+       *
+       * @param subparam
+       * @return The subparam or null if not found.
+       */
+      public Builder addSubParam(SubParam subparam) {
+        for (int i = 0; i < subparams.size(); i++) {
+          if (subparams.get(i).key.equals(subparam.key)) {
+            subparams.set(i, subparam);
+            return this;
+          }
+        }
+        subparams.add(subparam);
+        return this;
+      }
+
+      /**
+       * Shortcut to add a subparam with this key and value. Replaces existing subparam with same
+       * key if present.
+       *
+       * @param key The subparam key.
+       * @param value The subparam value.
+       */
+      public Builder addSubParam(String key, String value) {
+        return addSubParam(new SubParam(key, value));
+      }
+
+      /** Build an immutable ParamValue from this builder. */
+      public ParamValue build() {
+        return new ParamValue(name, subparams);
+      }
+    }
+  }
+
+  /** A fragment param value subparam. */
+  public static final class SubParam {
+
+    private final String key;
+    @Nullable private final String value;
+
+    /** Creates a new subparam with the given key and value. */
+    public static SubParam build(String key, String value) {
+      return new SubParam(key, value);
+    }
+
+    /** Creates a new subparam with the given key and no value. */
+    public static SubParam build(String key) {
+      return new SubParam(key, null);
+    }
+
+    private SubParam(String key, @Nullable String value) {
+      this.key = key;
+      this.value = value;
+    }
+
+    /** Returns the subparam key. */
+    public String key() {
+      return key;
+    }
+
+    /** Returns the subparam value, or null if not set. */
+    @Nullable
+    public String value() {
+      return value;
+    }
+
+    /** Returns true if the subparam has a value set. */
+    public boolean hasValue() {
+      return value != null;
+    }
+
+    @Override
+    public String toString() {
+      if (hasValue()) {
+        return urlEncode(key) + "=" + urlEncode(value);
+      } else {
+        return urlEncode(key);
+      }
+    }
+  }
+
+  /** Parses a fragment from the uri as described in {@link Fragment}. */
+  public static Fragment parse(Uri uri) {
+    return parse(uri.getEncodedFragment());
+  }
+
+  /** Parses a fragment from an encoded string as described in {@link Fragment}. */
+  public static Fragment parse(@Nullable String encodedFragment) {
+    if (TextUtils.isEmpty(encodedFragment)) {
+      return EMPTY_FRAGMENT;
+    }
+    List<Param.Builder> params = new ArrayList<Param.Builder>();
+    for (String kvPair : encodedFragment.split("&")) {
+      String[] kv = kvPair.split("=", 2);
+      List<ParamValue.Builder> values = new ArrayList<ParamValue.Builder>();
+      String key = kv[0];
+      Preconditions.checkArgument(!TextUtils.isEmpty(key), "malformed key: %s", encodedFragment);
+      Preconditions.checkArgument(
+          kv.length == 2 && !TextUtils.isEmpty(kv[1]), "missing param value: %s", encodedFragment);
+      String rawValues = kv[1];
+      String[] splitValues = rawValues.split("\\+");
+      for (int i = 0; i < splitValues.length; i++) {
+        String value = splitValues[i];
+        if (value.isEmpty()) {
+          continue;
+        }
+        List<SubParam> subparams = null;
+        int lparen = value.indexOf("(");
+        if (lparen != -1) {
+          String rawSubparams = value.substring(lparen);
+          Preconditions.checkArgument(
+              rawSubparams.charAt(0) == '('
+                  && rawSubparams.charAt(rawSubparams.length() - 1) == ')',
+              "malformed fragment subparams: %s",
+              encodedFragment);
+          subparams = parseSubParams(rawSubparams.substring(1, rawSubparams.length() - 1));
+          value = value.substring(0, lparen);
+        } else {
+          Preconditions.checkArgument(
+              !value.contains(")"), "malformed fragment subparams: %s", encodedFragment);
+        }
+        values.add(new ParamValue.Builder(urlDecode(value), subparams));
+      }
+      params.add(new Param.Builder(urlDecode(key), values));
+    }
+    return new Fragment.Builder(params).build();
+  }
+
+  // TODO: This method probably should be elsewhere, perhaps in a lite fragment helper class.
+  @Nullable
+  public static String getTransformSubParam(Uri uri, String transformName, String subParamKey) {
+    Fragment.ParamValue value = getTransformParamValue(uri, transformName);
+    if (value == null) {
+      return null;
+    }
+    String result = value.findSubParamValue(subParamKey);
+    return !TextUtils.isEmpty(result) ? result : null;
+  }
+
+  @Nullable
+  public static Fragment.ParamValue getTransformParamValue(Uri uri, String transformName) {
+    Fragment.Param param = Fragment.parse(uri).findParam("transform");
+    if (param == null) {
+      return null;
+    }
+    return param.findValue(transformName);
+  }
+
+  private static List<SubParam> parseSubParams(String rawSubparams) {
+    List<SubParam> subparams = new ArrayList<SubParam>();
+    String[] pairs = rawSubparams.split(",");
+    for (int i = 0; i < pairs.length; i++) {
+      String[] kv = pairs[i].split("=", 2);
+      String key = kv[0];
+      Preconditions.checkArgument(
+          !TextUtils.isEmpty(key), "missing fragment subparam key: %s", rawSubparams);
+      if (kv.length == 2 && !TextUtils.isEmpty(kv[1])) {
+        subparams.add(new SubParam(urlDecode(key), urlDecode(kv[1])));
+      } else {
+        subparams.add(new SubParam(urlDecode(key), null));
+      }
+    }
+    return subparams;
+  }
+
+  private static final String urlEncode(String str) {
+    try {
+      return URLEncoder.encode(str, UTF_8.displayName());
+    } catch (UnsupportedEncodingException e) {
+      throw new IllegalStateException(); // Not really
+    }
+  }
+
+  private static final String urlDecode(String str) {
+    try {
+      return URLDecoder.decode(str, UTF_8.displayName());
+    } catch (UnsupportedEncodingException e) {
+      throw new IllegalStateException(); // Not really
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/GcParam.java b/java/com/google/android/libraries/mobiledatadownload/file/common/GcParam.java
new file mode 100644
index 0000000..2e01e6d
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/GcParam.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common;
+
+// TODO: investigate using org.joda.time.instant instead of Date
+import java.util.Date;
+
+/** Specification of GC behavior for a given file. */
+public final class GcParam {
+
+  private static enum Code {
+    NONE,
+    EXPIRES_AT
+  }
+
+  private static final GcParam NONE_PARAM = new GcParam(Code.NONE, null);
+
+  private final Code code;
+  private final Date date;
+
+  private GcParam(Code code, Date date) {
+    this.code = code;
+    this.date = date;
+  }
+
+  /**
+   * @return A GcParam for expiration at the given date.
+   */
+  public static GcParam expiresAt(Date date) {
+    return new GcParam(Code.EXPIRES_AT, date);
+  }
+
+  /**
+   * @return the "none" GcParam.
+   */
+  public static GcParam none() {
+    return NONE_PARAM;
+  }
+
+  /**
+   * @return the expiration associated with {@code this}.
+   * @throws IllegalStateException if the GcParam is not an expiration
+   */
+  public Date getExpiration() {
+    if (!isExpiresAt()) {
+      throw new IllegalStateException("GcParam is not an expiration");
+    }
+    return date;
+  }
+
+  /**
+   * @return whether {@code this} is an expiration GcParam
+   */
+  public boolean isExpiresAt() {
+    return code == Code.EXPIRES_AT;
+  }
+
+  /**
+   * @return whether {@code this} is the none GcParam
+   */
+  public boolean isNone() {
+    return code == Code.NONE;
+  }
+
+  @Override
+  public boolean equals(Object obj) {
+    if (!(obj instanceof GcParam)) {
+      return false;
+    }
+    GcParam that = (GcParam) obj;
+    return (this.code == that.code)
+        && (this.isNone() || this.date.getTime() == that.date.getTime());
+  }
+
+  @Override
+  public int hashCode() {
+    return isNone() ? 0 : date.hashCode();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/InvalidAccountException.java b/java/com/google/android/libraries/mobiledatadownload/file/common/InvalidAccountException.java
new file mode 100644
index 0000000..b5cf321
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/InvalidAccountException.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common;
+
+import java.io.IOException;
+
+/** Exception thrown when an account does not exist. */
+public final class InvalidAccountException extends IOException {
+  public InvalidAccountException(String message) {
+    super(message);
+  }
+
+  public InvalidAccountException(String message, Throwable cause) {
+    super(message, cause);
+  }
+
+  public InvalidAccountException(Throwable cause) {
+    super(cause);
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/LazyInitializable.java b/java/com/google/android/libraries/mobiledatadownload/file/common/LazyInitializable.java
new file mode 100644
index 0000000..f24cea1
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/LazyInitializable.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common;
+
+import java.io.IOException;
+
+/**
+ * A class whose initialization requires disk IO, which is deferred to the time of first use. The
+ * client can optionally call {@link #init} on the class in order to initialize it (and handle any
+ * exceptions) immediately.
+ */
+public interface LazyInitializable {
+  void init() throws IOException;
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/LimitExceededException.java b/java/com/google/android/libraries/mobiledatadownload/file/common/LimitExceededException.java
new file mode 100644
index 0000000..ebf74d0
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/LimitExceededException.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common;
+
+/** Exception thrown to indicate that a limit set by the system has been exceed. */
+public final class LimitExceededException extends RuntimeException {
+  public LimitExceededException() {
+    super();
+  }
+
+  public LimitExceededException(String message) {
+    super(message);
+  }
+
+  public LimitExceededException(String message, Throwable cause) {
+    super(message, cause);
+  }
+
+  public LimitExceededException(Throwable cause) {
+    super(cause);
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/Lock.java b/java/com/google/android/libraries/mobiledatadownload/file/common/Lock.java
new file mode 100644
index 0000000..6f1585b
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/Lock.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common;
+
+import java.io.Closeable;
+import java.io.IOException;
+
+/** Ensures mutual exclusive access to a resource. */
+public interface Lock extends Closeable {
+
+  /** Releases the lock that is held. */
+  void release() throws IOException;
+
+  /** Returns true if lock is still held. */
+  boolean isValid();
+
+  /** Returns true if lock is shared and false if it is exclusive. */
+  boolean isShared();
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/LockScope.java b/java/com/google/android/libraries/mobiledatadownload/file/common/LockScope.java
new file mode 100644
index 0000000..00e68e0
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/LockScope.java
@@ -0,0 +1,276 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common;
+
+import android.net.Uri;
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.InterruptedIOException;
+import java.nio.channels.FileChannel;
+import java.nio.channels.FileLock;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.Semaphore;
+import javax.annotation.Nullable;
+
+/**
+ * An implementation of {@link Lock} based on a Java channel {@link FileLock} and Semaphores. It
+ * handles multi-thread and multi-process exclusion.
+ *
+ * <p>NOTE: Multi-thread exclusion is not supported natively by Java (See {@link
+ * https://docs.oracle.com/javase/7/docs/api/java/nio/channels/FileChannel.html}), but it is
+ * provided here. For it to work properly, a map from file name to Semaphore is maintained. If the
+ * scope of that map is not big enough (eg, if the map is maintained in a Backend, but there are
+ * multiple Backend instances accessing the same file), it is still possible to get a
+ * OverlappingFileLockException.
+ *
+ * <p>NOTE: The keys in the semaphore map are generated from the File or Uri string. No attempt is
+ * made to canonicalize those strings, or deal with other corner cases like hard links.
+ *
+ * <p>TODO: Implemented shared thread locks if needed.
+ */
+public final class LockScope {
+
+  @Nullable private final ConcurrentMap<String, Semaphore> lockMap;
+
+  /**
+   * @deprecated Prefer the static {@link create()} factory method.
+   */
+  @Deprecated
+  public LockScope() {
+    this(new ConcurrentHashMap<>());
+  }
+
+  private LockScope(ConcurrentMap<String, Semaphore> lockMap) {
+    this.lockMap = lockMap;
+  }
+
+  /** Returns a new instance. */
+  public static LockScope create() {
+    return new LockScope(new ConcurrentHashMap<>());
+  }
+
+  /**
+   * Returns a new instance that will use the given map for lock leasing. This is only necessary if
+   * {@code LockScope} can't be a managed as a singleton but {@code lockMap} can be.
+   */
+  public static LockScope createWithExistingThreadLocks(ConcurrentMap<String, Semaphore> lockMap) {
+    return new LockScope(lockMap);
+  }
+
+  /** Returns a new instance that will always fail to acquire thread locks. */
+  public static LockScope createWithFailingThreadLocks() {
+    return new LockScope(null);
+  }
+
+  /** Acquires a cross-thread lock on {@code uri}. This blocks until the lock is obtained. */
+  public Lock threadLock(Uri uri) throws IOException {
+    if (!threadLocksAreAvailable()) {
+      throw new UnsupportedFileStorageOperation("Couldn't acquire lock");
+    }
+
+    Semaphore semaphore = getOrCreateSemaphore(uri.toString());
+    try (SemaphoreResource semaphoreResource = SemaphoreResource.acquire(semaphore)) {
+      return new SemaphoreLockImpl(semaphoreResource.releaseFromTryBlock());
+    }
+  }
+
+  /**
+   * Attempts to acquire a cross-thread lock on {@code uri}. This does not block, and returns null
+   * if the lock cannot be obtained immediately.
+   */
+  @Nullable
+  public Lock tryThreadLock(Uri uri) throws IOException {
+    if (!threadLocksAreAvailable()) {
+      return null;
+    }
+
+    Semaphore semaphore = getOrCreateSemaphore(uri.toString());
+    try (SemaphoreResource semaphoreResource = SemaphoreResource.tryAcquire(semaphore)) {
+      if (!semaphoreResource.acquired()) {
+        return null;
+      }
+      return new SemaphoreLockImpl(semaphoreResource.releaseFromTryBlock());
+    }
+  }
+
+  /** Acquires a cross-process lock on {@code channel}. This blocks until the lock is obtained. */
+  public Lock fileLock(FileChannel channel, boolean shared) throws IOException {
+    FileLock lock = channel.lock(0L /* position */, Long.MAX_VALUE /* size */, shared);
+    return new FileLockImpl(lock);
+  }
+
+  /**
+   * Attempts to acquire a cross-process lock on {@code channel}. This does not block, and returns
+   * null if the lock cannot be obtained immediately.
+   */
+  @Nullable
+  public Lock tryFileLock(FileChannel channel, boolean shared) throws IOException {
+    try {
+      FileLock lock = channel.tryLock(0L /* position */, Long.MAX_VALUE /* size */, shared);
+      if (lock == null) {
+        return null;
+      }
+      return new FileLockImpl(lock);
+    } catch (IOException ex) {
+      // Android throws IOException with message "fcntl failed: EAGAIN (Try again)" instead
+      // of returning null as expected.
+      return null;
+    }
+  }
+
+  private boolean threadLocksAreAvailable() {
+    return lockMap != null;
+  }
+
+  private static class FileLockImpl implements Lock {
+
+    private FileLock fileLock;
+    private Semaphore semaphore;
+
+    public FileLockImpl(FileLock fileLock) {
+      this.fileLock = fileLock;
+      this.semaphore = null;
+    }
+
+    /**
+     * @deprecated Prefer the single-argument {@code FileLockImpl(FileLock)}.
+     */
+    @Deprecated
+    public FileLockImpl(FileLock fileLock, Semaphore semaphore) {
+      this.fileLock = fileLock;
+      this.semaphore = semaphore;
+    }
+
+    @Override
+    public void release() throws IOException {
+      // The semaphore guards access to the fileLock, so fileLock *must* be released first.
+      try {
+        if (fileLock != null) {
+          fileLock.release();
+          fileLock = null;
+        }
+      } finally {
+        if (semaphore != null) {
+          semaphore.release();
+          semaphore = null;
+        }
+      }
+    }
+
+    @Override
+    public boolean isValid() {
+      return fileLock.isValid();
+    }
+
+    @Override
+    public boolean isShared() {
+      return fileLock.isShared();
+    }
+
+    @Override
+    public void close() throws IOException {
+      release();
+    }
+  }
+
+  private static class SemaphoreLockImpl implements Lock {
+
+    private Semaphore semaphore;
+
+    SemaphoreLockImpl(Semaphore semaphore) {
+      this.semaphore = semaphore;
+    }
+
+    @Override
+    public void release() throws IOException {
+      if (semaphore != null) {
+        semaphore.release();
+        semaphore = null;
+      }
+    }
+
+    @Override
+    public boolean isValid() {
+      return semaphore != null;
+    }
+
+    /** Semaphore locks are always exclusive. */
+    @Override
+    public boolean isShared() {
+      return false;
+    }
+
+    @Override
+    public void close() throws IOException {
+      release();
+    }
+  }
+
+  // SemaphoreResource similar to ReleaseableResource that handles both releasing and implementing
+  // closeable.
+  private static class SemaphoreResource implements Closeable {
+    @Nullable private Semaphore semaphore;
+
+    static SemaphoreResource tryAcquire(Semaphore semaphore) {
+      boolean acquired = semaphore.tryAcquire();
+      return new SemaphoreResource(acquired ? semaphore : null);
+    }
+
+    static SemaphoreResource acquire(Semaphore semaphore) throws InterruptedIOException {
+      try {
+        semaphore.acquire();
+      } catch (InterruptedException ex) {
+        throw new InterruptedIOException("semaphore not acquired: " + ex);
+      }
+      return new SemaphoreResource(semaphore);
+    }
+
+    SemaphoreResource(@Nullable Semaphore semaphore) {
+      this.semaphore = semaphore;
+    }
+
+    boolean acquired() {
+      return (semaphore != null);
+    }
+
+    Semaphore releaseFromTryBlock() {
+      Semaphore result = semaphore;
+      semaphore = null;
+      return result;
+    }
+
+    @Override
+    public void close() {
+      if (semaphore != null) {
+        semaphore.release();
+        semaphore = null;
+      }
+    }
+  }
+
+  private Semaphore getOrCreateSemaphore(String key) {
+    // NOTE: Entries added to this lockMap are never removed. If a large, varying number of
+    // files are locked, adding a mechanism delete obsolete entries in the table would be desirable.
+    // That is not the case now.
+    Semaphore semaphore = lockMap.get(key);
+    if (semaphore == null) {
+      lockMap.putIfAbsent(key, new Semaphore(1));
+      semaphore = lockMap.get(key); // Re-get() in case another thread putIfAbsent() before us.
+    }
+    return semaphore;
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/MalformedUriException.java b/java/com/google/android/libraries/mobiledatadownload/file/common/MalformedUriException.java
new file mode 100644
index 0000000..17c289a
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/MalformedUriException.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common;
+
+import java.io.IOException;
+
+/**
+ * Exception thrown to indicate that a malformed Uri was passed as an argument.
+ *
+ * <p>This class of exception is used only during static Uri parsing, such as a Uri that does not
+ * conform to its scheme or a fragment that couldn't be parsed. It is never used to indicate a
+ * transient filesystem failure, such as calling a file operation on a directory or trying to access
+ * an unavailable storage device.
+ */
+public final class MalformedUriException extends IOException {
+  public MalformedUriException(String message) {
+    super(message);
+  }
+
+  public MalformedUriException(String message, Throwable cause) {
+    super(message, cause);
+  }
+
+  public MalformedUriException(Throwable cause) {
+    super(cause);
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/ParamComputer.java b/java/com/google/android/libraries/mobiledatadownload/file/common/ParamComputer.java
new file mode 100644
index 0000000..ccad7d0
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/ParamComputer.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common;
+
+/**
+ * A ParamComputer updates the fragment param value in order to encode state in the URI fragment
+ * based on the content of the stream. The primary use case for this is checksum.
+ */
+public interface ParamComputer {
+
+  /** ParamComputer callback, to be invoked exactly once when the computed param value is ready. */
+  interface Callback {
+    void onParamValueComputed(Fragment.ParamValue value);
+  }
+
+  /**
+   * Sets a callback which is invoked when the computed URI is available (eg, end of stream).
+   *
+   * <p>NOTE: Must invoke callback exactly once.
+   */
+  void setCallback(Callback callback);
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/ReleasableResource.java b/java/com/google/android/libraries/mobiledatadownload/file/common/ReleasableResource.java
new file mode 100644
index 0000000..b5d2657
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/ReleasableResource.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common;
+
+import java.io.Closeable;
+import java.io.IOException;
+import javax.annotation.Nullable;
+
+/**
+ * A wrapper for a Closeable resource that allows caller to free that resource from a
+ * try-with-resources block.
+ */
+public final class ReleasableResource<T extends Closeable> implements Closeable {
+  @Nullable private T resource;
+
+  private ReleasableResource(T resource) {
+    this.resource = resource;
+  }
+
+  /**
+   * Creates a ReleasableResource wrapped around a resource.
+   *
+   * @param resource the Closeable resource.
+   */
+  public static <T extends Closeable> ReleasableResource<T> create(T resource) {
+    return new ReleasableResource<T>(resource);
+  }
+
+  /**
+   * Returns the wrapped resource and releases ownership. The caller is responsible for ensuring the
+   * resource is closed.
+   *
+   * @return the wrapped resource.
+   */
+  @Nullable
+  public T release() {
+    T freed = resource;
+    resource = null;
+    return freed;
+  }
+
+  /**
+   * Returns the wrapped resource but does not release ownership.
+   *
+   * @return the wrapped resource.
+   */
+  @Nullable
+  public T get() {
+    return resource;
+  }
+
+  @Override
+  public void close() throws IOException {
+    if (resource != null) {
+      resource.close();
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/Sizable.java b/java/com/google/android/libraries/mobiledatadownload/file/common/Sizable.java
new file mode 100644
index 0000000..0093edc
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/Sizable.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common;
+
+import java.io.IOException;
+import javax.annotation.Nullable;
+
+/** Interface for accessing the size of a data behind a stream. */
+public interface Sizable {
+
+  /**
+   * Return the logical size of the stream or null if it is unknown. The logical size is defined as
+   * the total number of bytes required to store all of the data in the stream after applying the
+   * transform. This is expected to be an efficient operation. In particular, if it is O(filesize)
+   * to compute the logical size, implementor should return null rather than perform that
+   * calculation.
+   */
+  @Nullable
+  Long size() throws IOException;
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/Syncable.java b/java/com/google/android/libraries/mobiledatadownload/file/common/Syncable.java
new file mode 100644
index 0000000..2df601f
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/Syncable.java
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common;
+
+import java.io.IOException;
+
+/** Interface flushing OS buffers in order to ensure data is written to persistent storage. */
+public interface Syncable {
+  void sync() throws IOException;
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/UnsupportedFileStorageOperation.java b/java/com/google/android/libraries/mobiledatadownload/file/common/UnsupportedFileStorageOperation.java
new file mode 100644
index 0000000..9f592a2
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/UnsupportedFileStorageOperation.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common;
+
+import java.io.IOException;
+
+/** The operation is unsupported by the backend or at least one transform specified in the URI. */
+public final class UnsupportedFileStorageOperation extends IOException {
+  public UnsupportedFileStorageOperation(String message) {
+    super(message);
+  }
+
+  public UnsupportedFileStorageOperation(String message, Throwable cause) {
+    super(message, cause);
+  }
+
+  public UnsupportedFileStorageOperation(Throwable cause) {
+    super(cause);
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/internal/BUILD b/java/com/google/android/libraries/mobiledatadownload/file/common/internal/BUILD
new file mode 100644
index 0000000..0ffd400
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/internal/BUILD
@@ -0,0 +1,79 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_library")
+
+package(
+    default_visibility = ["//:__subpackages__"],
+    licenses = ["notice"],
+)
+
+android_library(
+    name = "charsets",
+    srcs = [
+        "Charsets.java",
+    ],
+)
+
+android_library(
+    name = "exceptions",
+    srcs = [
+        "Exceptions.java",
+    ],
+)
+
+android_library(
+    name = "preconditions",
+    srcs = [
+        "Preconditions.java",
+    ],
+    deps = [
+        "@com_google_code_findbugs_jsr305",
+    ],
+)
+
+android_library(
+    name = "forwarding_stream",
+    srcs = [
+        "ForwardingInputStream.java",
+        "ForwardingOutputStream.java",
+    ],
+)
+
+android_library(
+    name = "backend_stream",
+    srcs = [
+        "BackendInputStream.java",
+        "BackendOutputStream.java",
+    ],
+    deps = [
+        ":forwarding_stream",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+    ],
+)
+
+android_library(
+    name = "lazy_stream",
+    srcs = [
+        "LazyByteArrayInputStream.java",
+    ],
+)
+
+android_library(
+    name = "lite_transform_fragments",
+    srcs = ["LiteTransformFragments.java"],
+    deps = [
+        "@com_google_code_findbugs_jsr305",
+        "@com_google_guava_guava",
+    ],
+)
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/internal/BackendInputStream.java b/java/com/google/android/libraries/mobiledatadownload/file/common/internal/BackendInputStream.java
new file mode 100644
index 0000000..a6a48ae
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/internal/BackendInputStream.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common.internal;
+
+import com.google.android.libraries.mobiledatadownload.file.common.FileChannelConvertible;
+import com.google.android.libraries.mobiledatadownload.file.common.FileConvertible;
+import com.google.android.libraries.mobiledatadownload.file.common.Sizable;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.nio.channels.FileChannel;
+
+/** A File-based InputStream that implements all optional Backend behaviors. */
+public final class BackendInputStream extends ForwardingInputStream
+    implements FileConvertible, FileChannelConvertible, Sizable {
+  private final FileInputStream fileInputStream;
+  private final File file;
+
+  public static BackendInputStream create(File file) throws FileNotFoundException {
+    return new BackendInputStream(new FileInputStream(file), file);
+  }
+
+  private BackendInputStream(FileInputStream fileInputStream, File file) {
+    super(fileInputStream);
+    this.fileInputStream = fileInputStream;
+    this.file = file;
+  }
+
+  @Override
+  public File toFile() {
+    return file;
+  }
+
+  @Override
+  public FileChannel toFileChannel() {
+    return fileInputStream.getChannel();
+  }
+
+  @Override
+  public Long size() throws IOException {
+    return fileInputStream.getChannel().size();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/internal/BackendOutputStream.java b/java/com/google/android/libraries/mobiledatadownload/file/common/internal/BackendOutputStream.java
new file mode 100644
index 0000000..884a7bc
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/internal/BackendOutputStream.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common.internal;
+
+import com.google.android.libraries.mobiledatadownload.file.common.FileChannelConvertible;
+import com.google.android.libraries.mobiledatadownload.file.common.FileConvertible;
+import com.google.android.libraries.mobiledatadownload.file.common.Syncable;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.channels.FileChannel;
+
+/** A File-based OutputStream that implements all optional Backend behaviors. */
+public class BackendOutputStream extends ForwardingOutputStream
+    implements FileConvertible, FileChannelConvertible, Syncable {
+  private final FileOutputStream fileOutputStream;
+  private final File file;
+
+  public static BackendOutputStream createForWrite(File file) throws IOException {
+    return new BackendOutputStream(new FileOutputStream(file), file);
+  }
+
+  public static BackendOutputStream createForAppend(File file) throws IOException {
+    return new BackendOutputStream(new FileOutputStream(file, true /* append */), file);
+  }
+
+  /** NOTE: this constructor is public only in order to allow subclassing. */
+  public BackendOutputStream(FileOutputStream fileOutputStream, File file) {
+    super(fileOutputStream);
+    this.fileOutputStream = fileOutputStream;
+    this.file = file;
+  }
+
+  @Override
+  public File toFile() {
+    return file;
+  }
+
+  @Override
+  public FileChannel toFileChannel() {
+    return fileOutputStream.getChannel();
+  }
+
+  @Override
+  public void sync() throws IOException {
+    fileOutputStream.getFD().sync();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/internal/Charsets.java b/java/com/google/android/libraries/mobiledatadownload/file/common/internal/Charsets.java
new file mode 100644
index 0000000..64bf66c
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/internal/Charsets.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common.internal;
+
+import java.nio.charset.Charset;
+
+/**
+ * Contains constant definitions for the standard {@link Charset} instances, which are guaranteed to
+ * be supported by all Java platform implementations.
+ *
+ * <p>This is intended to be used in lieu of {@link java.nio.charset.StandardCharsets}, which was
+ * added in API level 19 and thus does not support Jellybean devices. It is a trimmed-down fork of
+ * the Charsets class under Guava, replicated here to avoid the heavy dependency.
+ */
+public final class Charsets {
+
+  /** US-ASCII: seven-bit ASCII, the Basic Latin block of the Unicode character set (ISO646-US). */
+  public static final Charset US_ASCII = Charset.forName("US-ASCII");
+
+  /** ISO Latin Alphabet No. */
+  public static final Charset ISO_8859_1 = Charset.forName("ISO-8859-1");
+
+  /** UTF-8: eight-bit UCS Transformation Format. */
+  public static final Charset UTF_8 = Charset.forName("UTF-8");
+
+  private Charsets() {}
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/internal/Exceptions.java b/java/com/google/android/libraries/mobiledatadownload/file/common/internal/Exceptions.java
new file mode 100644
index 0000000..10c2954
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/internal/Exceptions.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common.internal;
+
+import android.os.Build;
+import java.io.IOException;
+import java.util.List;
+import java.util.Locale;
+
+/** Static helpers for commonly-used operations on {@link Exception} classes. */
+public final class Exceptions {
+
+  /**
+   * Combines multiple {@code causes} into a single new {@link IOException}. Causes are attached to
+   * the new exception as {@code suppressed}, or if unsupported (SDK <19), the new exception message
+   * simply includes the number of causes.
+   */
+  public static IOException combinedIOException(String message, List<IOException> causes) {
+    // addSuppressed is available on SDK 19+.
+    if (Build.VERSION.SDK_INT < 19) {
+      String suppressedCount =
+          String.format(Locale.US, " (%d suppressed exceptions)", causes.size());
+      return new IOException(message + suppressedCount);
+    }
+
+    IOException result = new IOException(message);
+    for (IOException cause : causes) {
+      result.addSuppressed(cause);
+    }
+    return result;
+  }
+
+  private Exceptions() {}
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/internal/ForwardingInputStream.java b/java/com/google/android/libraries/mobiledatadownload/file/common/internal/ForwardingInputStream.java
new file mode 100644
index 0000000..a95bdb0
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/internal/ForwardingInputStream.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common.internal;
+
+import java.io.FilterInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * Removes idiosyncracy of FilterInputStream, which does not always forward to delegate directly.
+ *
+ * <p>This class, and not FilterInputStream, must be used for all MobStore code.
+ */
+public class ForwardingInputStream extends FilterInputStream {
+
+  public ForwardingInputStream(InputStream in) {
+    super(in);
+  }
+
+  @Override
+  public int read(byte[] b) throws IOException {
+    return in.read(b);
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/internal/ForwardingOutputStream.java b/java/com/google/android/libraries/mobiledatadownload/file/common/internal/ForwardingOutputStream.java
new file mode 100644
index 0000000..fec33a1
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/internal/ForwardingOutputStream.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common.internal;
+
+import java.io.FilterOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * Removes some idiosyncracies of FilterOutputStream. It always delegates directly, and avoids
+ * byte-by-byte implementation of write.
+ *
+ * <p>This class, and not FilterOutputStream, must be used for all MobStore code.
+ */
+public class ForwardingOutputStream extends FilterOutputStream {
+
+  public ForwardingOutputStream(OutputStream out) {
+    super(out);
+  }
+
+  @Override
+  public void write(byte[] b) throws IOException {
+    out.write(b);
+  }
+
+  @Override
+  public void write(byte[] b, int off, int len) throws IOException {
+    out.write(b, off, len);
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/internal/LazyByteArrayInputStream.java b/java/com/google/android/libraries/mobiledatadownload/file/common/internal/LazyByteArrayInputStream.java
new file mode 100644
index 0000000..1e9235d
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/internal/LazyByteArrayInputStream.java
@@ -0,0 +1,130 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common.internal;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.concurrent.Callable;
+
+/**
+ * An {@code InputStream} that wraps a byte array provided lazily. This can be helpful for adapting
+ * an all-at-once byte-processing API to a streaming paradigm. Sample usage:
+ *
+ * <pre>{@code
+ * InputStream source = ...;
+ * InputStream lazy =
+ *     new LazyByteArrayInputStream(
+ *         () -> {
+ *           try {
+ *             byte[] bytes = ByteStreams.toByteArray(source);
+ *             return myByteFunction(bytes);
+ *           } finally {
+ *             source.close();
+ *           }
+ *         });
+ * // source stream is neither read nor processed until lazy.read()
+ * }</pre>
+ */
+public final class LazyByteArrayInputStream extends InputStream {
+
+  private final Callable<byte[]> byteProvider;
+
+  // Lazily initialized by init()
+  private volatile boolean isInitialized = false;
+  private InputStream delegate;
+
+  /**
+   * Constructs a new instance that will lazily call upon {@code byteProvider} once bytes are
+   * requested. The provider will be called at most once; if it throws exception, the {@code
+   * LazyByteArrayInputStream} will be left in an inoperable state.
+   *
+   * <p>The caller MUST read or close the stream in order to avoid a possible resource leak in the
+   * byte provider.
+   */
+  public LazyByteArrayInputStream(Callable<byte[]> byteProvider) {
+    this.byteProvider = byteProvider;
+  }
+
+  @Override
+  public int available() throws IOException {
+    if (!isInitialized) {
+      return 0; // available() returns the number of bytes that can be read without blocking
+    }
+    return delegate.available();
+  }
+
+  @Override
+  public void close() throws IOException {
+    init(); // give the underlying byte source an opportunity to close any open resources
+  }
+
+  @Override
+  public int read() throws IOException {
+    init();
+    return delegate.read();
+  }
+
+  @Override
+  public int read(byte[] b) throws IOException {
+    init();
+    return delegate.read(b);
+  }
+
+  @Override
+  public int read(byte[] b, int off, int len) throws IOException {
+    init();
+    return delegate.read(b, off, len);
+  }
+
+  @Override
+  public long skip(long n) throws IOException {
+    init();
+    return delegate.skip(n);
+  }
+
+  @Override
+  public void mark(int readlimit) {
+    if (!isInitialized) {
+      return; // stream hasn't been read, thus its position is 0, thus nothing to mark
+    }
+    delegate.mark(readlimit);
+  }
+
+  @Override
+  public boolean markSupported() {
+    return true; // the markSupported method of ByteArrayInputStream always returns true
+  }
+
+  @Override
+  public void reset() throws IOException {
+    init();
+    delegate.reset();
+  }
+
+  private void init() throws IOException {
+    if (!isInitialized) {
+      isInitialized = true; // don't try again if provider fails
+      try {
+        delegate = new ByteArrayInputStream(byteProvider.call());
+      } catch (IOException e) {
+        throw e;
+      } catch (Exception e) {
+        throw new IOException(e);
+      }
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/internal/LiteTransformFragments.java b/java/com/google/android/libraries/mobiledatadownload/file/common/internal/LiteTransformFragments.java
new file mode 100644
index 0000000..5ba1f72
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/internal/LiteTransformFragments.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common.internal;
+
+import android.net.Uri;
+import android.text.TextUtils;
+import com.google.common.base.Joiner;
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import javax.annotation.Nullable;
+
+/**
+ * The fragment parser, unfortunately, is rather large in code size. This class provides some
+ * lighter utilities for accessing and manipulating the transform fragment param. It provides a
+ * strict subset of the functionality in {@link Fragment}.
+ *
+ * <p>In particular, it does not support
+ *
+ * <ul>
+ *   <li>Encoded transform names.
+ *   <li>Parsing transform params.
+ * </ul>
+ */
+public final class LiteTransformFragments {
+
+  private static final Pattern XFORM_NAME_PATTERN = Pattern.compile("(\\w+).*");
+  private static final String XFORM_FRAGMENT_PREFIX = "transform=";
+
+  /**
+   * Parses URI fragment, returning the names of the transforms that are enabled. Has the following
+   * limitations:
+   *
+   * <ul>
+   *   <li>Ignores subparams. To access those, use {@link Fragment} from your transform.
+   *   <li>Requires the fragment start with "transform=".
+   *   <li>Requires encoded transform name to contain only word chars (ie, \w).
+   * </ul>
+   */
+  public static ImmutableList<String> parseTransformNames(Uri uri) {
+    ImmutableList.Builder<String> builder = ImmutableList.builder();
+    for (String spec : parseTransformSpecs(uri)) {
+      builder.add(parseSpecName(spec));
+    }
+    return builder.build();
+  }
+
+  /**
+   * Parses URI fragment, returning the transforms specs found. Has the following limitations:
+   *
+   * <ul>
+   *   <li>Requires the fragment start with "transform=".
+   * </ul>
+   *
+   * @return List of encoded transform specs - eg "integrity(sha256=abc123)"
+   */
+  public static ImmutableList<String> parseTransformSpecs(Uri uri) {
+    String fragment = uri.getEncodedFragment();
+    if (TextUtils.isEmpty(fragment) || !fragment.startsWith(XFORM_FRAGMENT_PREFIX)) {
+      return ImmutableList.of();
+    }
+    String specs = fragment.substring(XFORM_FRAGMENT_PREFIX.length());
+    return ImmutableList.copyOf(Splitter.on("+").omitEmptyStrings().split(specs));
+  }
+
+  /**
+   * Parse the name from an encoded transform spec. For example, "integrity(sha256=abc123)" would
+   * return "integrity". Has the following limitations:
+   *
+   * <ul>
+   *   <li>Ignores subparams. To access those, use {@link Fragment} from your transform.
+   *   <li>Requires encoded transform name to contain only word chars (ie, \w).
+   * </ul>
+   */
+  public static String parseSpecName(String encodedSpec) {
+    Matcher matcher = XFORM_NAME_PATTERN.matcher(encodedSpec);
+    if (!matcher.matches()) {
+      throw new IllegalArgumentException("Invalid fragment spec: " + encodedSpec);
+    }
+    return matcher.group(1);
+  }
+
+  /**
+   * Joins the encoded transform specs to produce an encoded transform fragment suitable for adding
+   * to a Uri with {@link Uri.Builder#encodedFragment}.
+   */
+  @Nullable
+  public static String joinTransformSpecs(List<String> encodedSpecs) {
+    if (encodedSpecs.isEmpty()) {
+      return null;
+    }
+    return XFORM_FRAGMENT_PREFIX + Joiner.on("+").join(encodedSpecs);
+  }
+
+  private LiteTransformFragments() {}
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/internal/Preconditions.java b/java/com/google/android/libraries/mobiledatadownload/file/common/internal/Preconditions.java
new file mode 100644
index 0000000..7ea65eb
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/internal/Preconditions.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common.internal;
+
+import javax.annotation.Nullable;
+
+/**
+ * Static convenience methods that help a method check whether it was invoked correctly.
+ *
+ * <p>This is a trimmed down version of the Preconditions class under Guava, replicated here to
+ * avoid the heavy dependency.
+ */
+public final class Preconditions {
+
+  /**
+   * Ensures the truth of an expression involving one or more parameters to the calling method.
+   *
+   * @param expression a boolean expression
+   * @param errorMessageTemplate a template for the exception message should the check fail. The
+   *     message is formed by replacing each {@code %s} placeholder in the template with an
+   *     argument. These are matched by position - the first {@code %s} gets {@code
+   *     errorMessageArgs[0]}, etc.
+   * @param errorMessageArgs the arguments to be substituted into the message template. Arguments
+   *     are converted to strings using {@link String#valueOf(Object)}.
+   * @throws IllegalArgumentException if {@code expression} is false
+   */
+  public static void checkArgument(
+      boolean expression,
+      @Nullable String errorMessageTemplate,
+      @Nullable Object... errorMessageArgs) {
+    if (!expression) {
+      throw new IllegalArgumentException(format(errorMessageTemplate, errorMessageArgs));
+    }
+  }
+
+  /**
+   * Ensures the truth of an expression involving the state of the calling instance, but not
+   * involving any parameters to the calling method.
+   *
+   * @param expression a boolean expression
+   * @param errorMessageTemplate a template for the exception message should the check fail. The
+   *     message is formed by replacing each {@code %s} placeholder in the template with an
+   *     argument. These are matched by position - the first {@code %s} gets {@code
+   *     errorMessageArgs[0]}, etc.
+   * @param errorMessageArgs the arguments to be substituted into the message template. Arguments
+   *     are converted to strings using {@link String#valueOf(Object)}.
+   * @throws IllegalStateException if {@code expression} is false
+   */
+  public static void checkState(
+      boolean expression,
+      @Nullable String errorMessageTemplate,
+      @Nullable Object... errorMessageArgs) {
+    if (!expression) {
+      throw new IllegalStateException(format(errorMessageTemplate, errorMessageArgs));
+    }
+  }
+
+  private static String format(String template, Object... args) {
+    // We do not handle null parameters gracefully like Guava. If we find that we are passing in
+    // invalid parameters we can do more checks here.
+    return String.format(template, args);
+  }
+
+  private Preconditions() {}
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/testing/AlwaysThrowsTransform.java b/java/com/google/android/libraries/mobiledatadownload/file/common/testing/AlwaysThrowsTransform.java
new file mode 100644
index 0000000..f9f1a30
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/testing/AlwaysThrowsTransform.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common.testing;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.common.internal.ForwardingInputStream;
+import com.google.android.libraries.mobiledatadownload.file.common.internal.ForwardingOutputStream;
+import com.google.android.libraries.mobiledatadownload.file.spi.Transform;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+/** A Transform that throws IOExceptions for any read or write operation. */
+public class AlwaysThrowsTransform implements Transform {
+
+  private static final String NAME = "alwaysthrows";
+
+  @Override
+  public String name() {
+    return NAME;
+  }
+
+  @Override
+  public InputStream wrapForRead(Uri uri, InputStream wrapped) throws IOException {
+    return new ForwardingInputStream(wrapped) {
+      @Override
+      public int read(byte[] b, int off, int len) throws IOException {
+        throw new IOException("throwing");
+      }
+
+      @Override
+      public int read(byte[] b) throws IOException {
+        throw new IOException("throwing");
+      }
+
+      @Override
+      public int read() throws IOException {
+        throw new IOException("throwing");
+      }
+    };
+  }
+
+  @Override
+  public OutputStream wrapForWrite(Uri uri, OutputStream wrapped) throws IOException {
+    return new ForwardingOutputStream(wrapped) {
+      @Override
+      public void write(byte[] b) throws IOException {
+        throw new IOException("throwing");
+      }
+
+      @Override
+      public void write(byte[] b, int off, int len) throws IOException {
+        throw new IOException("throwing");
+      }
+
+      @Override
+      public void write(int b) throws IOException {
+        throw new IOException("throwing");
+      }
+    };
+  }
+
+  @Override
+  public OutputStream wrapForAppend(Uri uri, OutputStream wrapped) throws IOException {
+    return wrapForWrite(uri, wrapped);
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/testing/BUILD b/java/com/google/android/libraries/mobiledatadownload/file/common/testing/BUILD
new file mode 100644
index 0000000..410729f
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/testing/BUILD
@@ -0,0 +1,142 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("//tools/build_defs/testing:bzl_library.bzl", "bzl_library")
+load("@build_bazel_rules_android//android:rules.bzl", "android_library")
+
+package(
+    default_testonly = 1,
+    default_visibility = ["//:__subpackages__"],
+    licenses = ["notice"],
+)
+
+# This is a java_library rather than android_library because it includes test
+# infrastructure for robolectric tests. It is a separate build target from the
+# main "testing" target below in order to allow android_tests to depend on the
+# latter. See link for more information:
+# <internal>
+java_library(
+    name = "robolectric",
+    srcs = [
+        "ShadowUtils.java",
+    ],
+    deps = [
+        "@android_sdk_linux",
+        "@robolectric",
+    ],
+)
+
+android_library(
+    name = "filestorage",
+    testonly = 1,
+    srcs = [
+        "FileStorageTestBase.java",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/spi",
+        "@junit",
+        "@mockito",
+    ],
+)
+
+android_library(
+    name = "matchers",
+    testonly = 1,
+    srcs = ["FragmentParamMatchers.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/internal:lite_transform_fragments",
+        "@com_google_guava_guava",
+        "@mockito",
+    ],
+)
+
+android_library(
+    name = "testing",
+    testonly = 1,
+    srcs = [
+        "BackendTestBase.java",
+        "ExceptionTesting.java",
+        "StreamUtils.java",
+        "TemporaryAndroidUri.java",
+        "TemporaryUri.java",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:android_adapter",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/internal:charsets",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:stream",
+        "//java/com/google/android/libraries/mobiledatadownload/file/spi",
+        "@com_google_guava_guava",
+        "@junit",
+        "@truth",
+    ],
+)
+
+android_library(
+    name = "extras",
+    testonly = 1,
+    srcs = [
+        "AlwaysThrowsTransform.java",
+        "BufferingMonitor.java",
+        "DummyTransforms.java",
+        "FileDescriptorLeakChecker.java",
+        "NoOpMonitor.java",
+        "WritesThrowTransform.java",
+    ],
+    exports = [
+        ":fake_file_backend",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/common:fragment",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/internal:forwarding_stream",
+        "//java/com/google/android/libraries/mobiledatadownload/file/spi",
+        "@com_google_guava_guava",
+        "@junit",
+        "@truth",
+    ],
+)
+
+android_library(
+    name = "fake_file_backend",
+    testonly = 1,
+    srcs = [
+        "FakeFileBackend.java",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/internal:forwarding_stream",
+        "//java/com/google/android/libraries/mobiledatadownload/file/spi",
+        "@com_google_code_findbugs_jsr305",
+        "@org_checkerframework_qual",
+    ],
+)
+
+java_lite_proto_library(
+    name = "test_message_java_proto_lite",
+    deps = [":test_message_proto"],
+)
+
+proto_library(
+    name = "test_message_proto",
+    srcs = ["test_message.proto"],
+)
+
+bzl_library(
+    name = "build_defs_bzl",
+    srcs = ["build_defs.bzl"],
+    parse_tests = False,
+    deps = ["//tools/build_defs/android:rules_bzl"],
+)
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/testing/BackendTestBase.java b/java/com/google/android/libraries/mobiledatadownload/file/common/testing/BackendTestBase.java
new file mode 100644
index 0000000..ade1277
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/testing/BackendTestBase.java
@@ -0,0 +1,547 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common.testing;
+
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.appendFile;
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.createFile;
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.makeArrayOfBytesContent;
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.readFileInBytes;
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.readFileInBytesFromSource;
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.writeFileToSink;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assume.assumeTrue;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.common.FileConvertible;
+import com.google.android.libraries.mobiledatadownload.file.common.MalformedUriException;
+import com.google.android.libraries.mobiledatadownload.file.openers.AppendStreamOpener;
+import com.google.android.libraries.mobiledatadownload.file.openers.ReadStreamOpener;
+import com.google.android.libraries.mobiledatadownload.file.openers.WriteStreamOpener;
+import com.google.android.libraries.mobiledatadownload.file.spi.Backend;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+import com.google.common.primitives.Bytes;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.Arrays;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TestName;
+
+/**
+ * Base class for {@code Backend} tests that exercises common behavior expected of all
+ * implementations. Concrete test cases must specify a test runner, extend from this class, and
+ * implement the abstract setup methods. Subclasses are free to add additional test methods in order
+ * to exercise backend-specific behavior using the provided {@link #storage}.
+ *
+ * <p>If the backend under test does not support a specific feature, the test subclass should
+ * override the appropriate {@code supportsX()} and return false in order to skip the associated
+ * unit tests. NOTE: this is adopted from Guava and seems like the least-bad strategy.
+ *
+ * <p>Abstract setup methods may be called before the {@code @Before} methods of the subclass, and
+ * so should not depend on them. {@code @BeforeClass}, lazy initialization, and static
+ * initialization are viable alternatives.
+ */
+public abstract class BackendTestBase {
+
+  private SynchronousFileStorage storage;
+  private static final byte[] TEST_CONTENT = makeArrayOfBytesContent();
+  private static final byte[] OTHER_CONTENT = makeArrayOfBytesContent(6);
+
+  @Rule public TestName testName = new TestName();
+
+  /** Returns the concrete {@code Backend} instance to be tested. */
+  protected abstract Backend backend();
+
+  /** Enables unit tests verifying {@link Backend#openForAppend}. */
+  protected boolean supportsAppend() {
+    return true;
+  }
+
+  /** Enables unit tests verifying {@link Backend#rename}. */
+  protected boolean supportsRename() {
+    return true;
+  }
+
+  /**
+   * Enables unit tests verifying {@link Backend#createDirectory}, {@link Backend#isDirectory},
+   * {@link Backend#deleteDirectory}, {@link Backend#children}, and writing to a subdirectory uri.
+   */
+  protected boolean supportsDirectories() {
+    return true;
+  }
+
+  /** Enables unit tests verifying that {@link FileConvertible} can be returned directly. */
+  protected boolean supportsFileConvertible() {
+    return true;
+  }
+
+  /** Enable unit tests verifying {@link Backend#toFile}. */
+  protected boolean supportsToFile() {
+    return true;
+  }
+
+  /**
+   * Returns a URI to a temporary directory for writing test data to. The {@code Backend} should be
+   * able to {@code openForWrite} a file to this directory without any additional setup code.
+   */
+  protected abstract Uri legalUriBase() throws IOException;
+
+  /**
+   * Returns a list of URIs for which {@code Backend.openForRead(uri)} is expected to throw {@code
+   * MalformedUriException} without any additional setup code.
+   */
+  protected List<Uri> illegalUrisToRead() {
+    return ImmutableList.of();
+  }
+
+  /**
+   * Returns a list of URIs for which {@code Backend.openForWrite(uri)} is expected to throw {@code
+   * MalformedUriException} without any additional setup code.
+   */
+  protected List<Uri> illegalUrisToWrite() {
+    return ImmutableList.of();
+  }
+
+  /**
+   * Returns a list of URIs for which {@code Backend.openForAppend(uri)} is expected to throw {@code
+   * MalformedUriException} without any additional setup code.
+   */
+  protected List<Uri> illegalUrisToAppend() {
+    return ImmutableList.of();
+  }
+
+  @Before
+  public final void setUpStorage() {
+    assertThat(backend()).isNotNull();
+    storage = new SynchronousFileStorage(ImmutableList.of(backend()), ImmutableList.of());
+  }
+
+  /** Returns the storage instance used in testing. */
+  protected SynchronousFileStorage storage() {
+    return storage;
+  }
+
+  @Test
+  public void name_returnsNonEmptyString() {
+    assertThat(backend().name()).isNotEmpty();
+  }
+
+  @Test
+  public void openForRead_withMissingFile_throwsFileNotFound() throws Exception {
+    Uri uri = uriForTestMethod();
+    assertThrows(FileNotFoundException.class, () -> storage.open(uri, ReadStreamOpener.create()));
+  }
+
+  @Test
+  public void openForRead_withIllegalUri_throwsIllegalArgumentException() throws Exception {
+    for (Uri uri : illegalUrisToRead()) {
+      assertThrows(MalformedUriException.class, () -> storage.open(uri, ReadStreamOpener.create()));
+    }
+  }
+
+  @Test
+  public void openForRead_readsWrittenContent() throws Exception {
+    Uri uri = uriForTestMethod();
+    createFile(storage, uri, TEST_CONTENT);
+    assertThat(readFileInBytes(storage, uri)).isEqualTo(TEST_CONTENT);
+  }
+
+  @Test
+  public void openForRead_returnsFileConvertible() throws Exception {
+    assumeTrue(supportsFileConvertible());
+
+    Uri uri = uriForTestMethod();
+    createFile(storage(), uri, TEST_CONTENT);
+
+    InputStream stream = backend().openForRead(uri);
+    assertThat(stream).isInstanceOf(FileConvertible.class);
+    File file = ((FileConvertible) stream).toFile();
+    assertThat(readFileInBytesFromSource(new FileInputStream(file))).isEqualTo(TEST_CONTENT);
+  }
+
+  @Test
+  public void openForWrite_withIllegalUri_throwsIllegalArgumentException() throws Exception {
+    for (Uri uri : illegalUrisToWrite()) {
+      assertThrows(
+          MalformedUriException.class, () -> storage.open(uri, WriteStreamOpener.create()));
+    }
+  }
+
+  @Test
+  public void openForWrite_withFailedDirectoryCreation_throwsException() throws Exception {
+    assumeTrue(supportsDirectories());
+
+    Uri parent = uriForTestMethod();
+    createFile(storage, parent, TEST_CONTENT);
+
+    Uri child = parent.buildUpon().appendPath("child").build();
+    assertThrows(IOException.class, () -> storage.open(child, WriteStreamOpener.create()));
+  }
+
+  @Test
+  public void openForWrite_overwritesExistingContent() throws Exception {
+    Uri uri = uriForTestMethod();
+    createFile(storage, uri, OTHER_CONTENT);
+
+    createFile(storage, uri, TEST_CONTENT);
+    assertThat(readFileInBytes(storage, uri)).isEqualTo(TEST_CONTENT);
+  }
+
+  @Test
+  public void openForWrite_createsParentDirectory() throws Exception {
+    assumeTrue(supportsDirectories());
+
+    Uri parent = uriForTestMethod();
+    Uri child = parent.buildUpon().appendPath("child").build();
+
+    createFile(storage, child, TEST_CONTENT);
+    assertThat(storage.isDirectory(parent)).isTrue();
+    assertThat(storage.exists(child)).isTrue();
+  }
+
+  @Test
+  public void openForWrite_returnsFileConvertible() throws Exception {
+    assumeTrue(supportsFileConvertible());
+
+    Uri uri = uriForTestMethod();
+    try (OutputStream stream = backend().openForWrite(uri)) {
+      assertThat(stream).isInstanceOf(FileConvertible.class);
+      File file = ((FileConvertible) stream).toFile();
+      writeFileToSink(new FileOutputStream(file), TEST_CONTENT);
+    }
+    assertThat(readFileInBytes(storage(), uri)).isEqualTo(TEST_CONTENT);
+  }
+
+  @Test
+  public void openForAppend_withIllegalUri_throwsIllegalArgumentException() throws Exception {
+    assumeTrue(supportsAppend());
+
+    for (Uri uri : illegalUrisToAppend()) {
+      assertThrows(
+          MalformedUriException.class, () -> storage.open(uri, AppendStreamOpener.create()));
+    }
+  }
+
+  @Test
+  public void openForAppend_appendsContent() throws Exception {
+    assumeTrue(supportsAppend());
+
+    Uri uri = uriForTestMethod();
+    createFile(storage, uri, OTHER_CONTENT);
+
+    appendFile(storage, uri, TEST_CONTENT);
+    assertThat(readFileInBytes(storage, uri)).isEqualTo(Bytes.concat(OTHER_CONTENT, TEST_CONTENT));
+  }
+
+  @Test
+  public void openForAppend_returnsFileConvertible() throws Exception {
+    assumeTrue(supportsAppend());
+    assumeTrue(supportsFileConvertible());
+
+    Uri uri = uriForTestMethod();
+    createFile(storage, uri, OTHER_CONTENT);
+    try (OutputStream stream = backend().openForAppend(uri)) {
+      assertThat(stream).isInstanceOf(FileConvertible.class);
+      File file = ((FileConvertible) stream).toFile();
+      writeFileToSink(new FileOutputStream(file, /* append = */ true), TEST_CONTENT);
+    }
+    assertThat(readFileInBytes(storage(), uri))
+        .isEqualTo(Bytes.concat(OTHER_CONTENT, TEST_CONTENT));
+  }
+
+  @Test
+  public void deleteFile_deletesFile() throws Exception {
+    Uri uri = uriForTestMethod();
+    createFile(storage, uri, TEST_CONTENT);
+
+    storage.deleteFile(uri);
+    assertThat(storage.exists(uri)).isFalse();
+  }
+
+  @Test
+  public void deleteFile_onDirectory_throwsFileNotFound() throws Exception {
+    assumeTrue(supportsDirectories());
+
+    Uri uri = uriForTestMethod();
+    storage.createDirectory(uri);
+
+    assertThrows(FileNotFoundException.class, () -> storage.deleteFile(uri));
+  }
+
+  @Test
+  public void deleteFile_onMissingFile_throwsFileNotFound() throws Exception {
+    Uri uri = uriForTestMethod();
+    assertThrows(FileNotFoundException.class, () -> storage.deleteFile(uri));
+  }
+
+  @Test
+  public void rename_renamesFile() throws Exception {
+    assumeTrue(supportsRename());
+
+    Uri uri1 = uriForTestMethodWithSuffix("1");
+    Uri uri2 = uriForTestMethodWithSuffix("2");
+    Uri uri3 = uriForTestMethodWithSuffix("3");
+    createFile(storage, uri1, OTHER_CONTENT);
+    createFile(storage, uri2, TEST_CONTENT);
+
+    storage.rename(uri2, uri3);
+    storage.rename(uri1, uri2);
+    assertThat(storage.exists(uri1)).isFalse();
+    assertThat(readFileInBytes(storage, uri2)).isEqualTo(OTHER_CONTENT);
+    assertThat(readFileInBytes(storage, uri3)).isEqualTo(TEST_CONTENT);
+  }
+
+  @Test
+  public void rename_renamesDirectory() throws Exception {
+    assumeTrue(supportsRename());
+    assumeTrue(supportsDirectories());
+
+    Uri uriA = uriForTestMethodWithSuffix("a");
+    Uri uriB = uriForTestMethodWithSuffix("b");
+
+    storage.createDirectory(uriA);
+    storage.rename(uriA, uriB);
+    assertThat(storage.isDirectory(uriA)).isFalse();
+    assertThat(storage.isDirectory(uriB)).isTrue();
+  }
+
+  @Test
+  public void exists_returnsTrueIfFileExists() throws Exception {
+    Uri uri = uriForTestMethod();
+    assertThat(storage.exists(uri)).isFalse();
+
+    createFile(storage, uri, TEST_CONTENT);
+    assertThat(storage.exists(uri)).isTrue();
+  }
+
+  @Test
+  public void exists_returnsTrueIfDirectoryExists() throws Exception {
+    assumeTrue(supportsDirectories());
+
+    Uri uri = uriForTestMethod();
+    assertThat(storage.exists(uri)).isFalse();
+
+    storage.createDirectory(uri);
+    assertThat(storage.exists(uri)).isTrue();
+  }
+
+  @Test
+  public void isDirectory_returnsTrueIfDirectoryExists() throws Exception {
+    assumeTrue(supportsDirectories());
+
+    Uri uri = uriForTestMethod();
+    assertThat(storage.isDirectory(uri)).isFalse();
+
+    storage.createDirectory(uri);
+    assertThat(storage.isDirectory(uri)).isTrue();
+  }
+
+  @Test
+  public void isDirectory_returnsFalseIfDoesntExist() throws Exception {
+    assumeTrue(supportsDirectories());
+
+    Uri uri = uriForTestMethod();
+    assertThat(storage.isDirectory(uri)).isFalse();
+  }
+
+  @Test
+  public void isDirectory_returnsFalseIfIsFile() throws Exception {
+    assumeTrue(supportsDirectories());
+
+    Uri uri = uriForTestMethod();
+    createFile(storage, uri, TEST_CONTENT);
+    assertThat(storage.isDirectory(uri)).isFalse();
+  }
+
+  @Test
+  public void createDirectory_createsDirectory() throws Exception {
+    assumeTrue(supportsDirectories());
+
+    Uri uri = uriForTestMethod();
+    storage.createDirectory(uri);
+    assertThat(storage.isDirectory(uri)).isTrue();
+  }
+
+  @Test
+  public void createDirectory_createsParentDirectory() throws Exception {
+    assumeTrue(supportsDirectories());
+
+    Uri parent = uriForTestMethod();
+    Uri child = parent.buildUpon().appendPath("child").build();
+
+    storage.createDirectory(child);
+    assertThat(storage.isDirectory(child)).isTrue();
+    assertThat(storage.isDirectory(parent)).isTrue();
+  }
+
+  @Test
+  public void fileSize_withMissingFile_returnsZero() throws Exception {
+    Uri uri = uriForTestMethod();
+    assertThat(storage.fileSize(uri)).isEqualTo(0);
+  }
+
+  @Test
+  public void fileSize_returnsSizeOfFile() throws Exception {
+    Uri uri = uriForTestMethod();
+
+    backend().openForWrite(uri).close();
+    assertThat(storage.fileSize(uri)).isEqualTo(0);
+
+    createFile(storage, uri, TEST_CONTENT);
+    assertThat(storage.fileSize(uri)).isEqualTo(TEST_CONTENT.length);
+  }
+
+  @Test
+  public void fileSize_withDirReturns0() throws Exception {
+    assumeTrue(supportsDirectories());
+
+    Uri uri = uriForTestMethod();
+    storage.createDirectory(uri);
+    assertThat(storage.fileSize(uri)).isEqualTo(0);
+  }
+
+  @Test
+  public void deleteDirectory_shouldDeleteEmptyDirectory() throws Exception {
+    assumeTrue(supportsDirectories());
+
+    Uri uri = uriForTestMethod();
+    assertThat(storage.isDirectory(uri)).isFalse();
+    storage.createDirectory(uri);
+    assertThat(storage.isDirectory(uri)).isTrue();
+    storage.deleteDirectory(uri);
+    assertThat(storage.isDirectory(uri)).isFalse();
+  }
+
+  @Test
+  public void deleteDirectory_shouldNOTDeleteNonEmptyDirectory() throws Exception {
+    assumeTrue(supportsDirectories());
+
+    Uri uri = uriForTestMethod();
+    storage.createDirectory(uri);
+    Uri fileUri = uri.buildUpon().appendPath("file").build();
+    createFile(storage, fileUri, TEST_CONTENT);
+
+    assertThat(storage.isDirectory(uri)).isTrue();
+    assertThrows(IOException.class, () -> storage.deleteDirectory(uri));
+    assertThat(storage.isDirectory(uri)).isTrue();
+
+    storage.deleteFile(fileUri);
+    storage.deleteDirectory(uri);
+    assertThat(storage.isDirectory(uri)).isFalse();
+  }
+
+  @Test
+  public void deleteDirectory_onFileShouldThrow() throws Exception {
+    assumeTrue(supportsDirectories());
+
+    Uri uri = uriForTestMethod();
+    createFile(storage, uri, TEST_CONTENT);
+
+    assertThrows(IOException.class, () -> storage.deleteDirectory(uri));
+  }
+
+  @Test
+  public void children_withEmptyDirectoryShouldReturnEmpty() throws Exception {
+    assumeTrue(supportsDirectories());
+
+    Uri uri = uriForTestMethod();
+    storage.createDirectory(uri);
+
+    assertThat(storage.children(uri)).isEmpty();
+  }
+
+  @Test
+  public void children_onNotFoundShouldThrow() throws Exception {
+    assumeTrue(supportsDirectories());
+
+    Uri uri = uriForTestMethod();
+
+    assertThrows(IOException.class, () -> storage.children(uri));
+  }
+
+  @Test
+  public void children_onFileShouldThrow() throws Exception {
+    assumeTrue(supportsDirectories());
+
+    Uri uri = uriForTestMethod();
+    createFile(storage, uri, TEST_CONTENT);
+
+    assertThrows(IOException.class, () -> storage.children(uri));
+  }
+
+  @Test
+  public void children_shouldReturnFilesAndSubDirectories() throws Exception {
+    assumeTrue(supportsDirectories());
+
+    Uri uri = uriForTestMethod();
+    storage.createDirectory(uri);
+
+    List<Uri> fileUris =
+        Arrays.asList(
+            uri.buildUpon().appendPath("file1").build(),
+            uri.buildUpon().appendPath("file2").build(),
+            uri.buildUpon().appendPath("file3").build());
+    for (Uri file : fileUris) {
+      createFile(storage, file, TEST_CONTENT);
+    }
+
+    List<Uri> subdirUris =
+        Arrays.asList(
+            uri.buildUpon().appendPath("dir1").build(),
+            uri.buildUpon().appendPath("dir2").build(),
+            uri.buildUpon().appendPath("dir3").build());
+    for (Uri subdir : subdirUris) {
+      storage.createDirectory(subdir);
+    }
+    List<Uri> subdirUrisWithTrailingSlashes =
+        Lists.transform(subdirUris, u -> Uri.parse(u.toString() + "/"));
+
+    List<Uri> expected = Lists.newArrayList();
+    expected.addAll(fileUris);
+    expected.addAll(subdirUrisWithTrailingSlashes);
+    assertThat(storage.children(uri)).containsExactlyElementsIn(expected);
+  }
+
+  @Test
+  public void toFile_converts() throws Exception {
+    assumeTrue(supportsToFile());
+
+    Uri uri = uriForTestMethod();
+    createFile(storage, uri, TEST_CONTENT);
+    File file = backend().toFile(uri);
+    assertThat(readFileInBytesFromSource(new FileInputStream(file))).isEqualTo(TEST_CONTENT);
+  }
+
+  /** Returns a URI in the test directory unique to the current test method. */
+  protected Uri uriForTestMethod() throws IOException {
+    return legalUriBase().buildUpon().appendPath(testName.getMethodName()).build();
+  }
+
+  /** Returns a URI in the test directory unique to the current test method and {@code suffix}. */
+  private Uri uriForTestMethodWithSuffix(String suffix) throws IOException {
+    return legalUriBase().buildUpon().appendPath(testName.getMethodName() + "-" + suffix).build();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/testing/BufferingMonitor.java b/java/com/google/android/libraries/mobiledatadownload/file/common/testing/BufferingMonitor.java
new file mode 100644
index 0000000..baaadeb
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/testing/BufferingMonitor.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common.testing;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.spi.Monitor;
+import java.io.ByteArrayOutputStream;
+
+/** A testing monitor buffers bytes read and written. */
+public class BufferingMonitor implements Monitor {
+  private final ByteArrayOutputStream readBuffer = new ByteArrayOutputStream();
+  private final ByteArrayOutputStream writeBuffer = new ByteArrayOutputStream();
+  private final ByteArrayOutputStream appendBuffer = new ByteArrayOutputStream();
+
+  public byte[] bytesRead() {
+    return readBuffer.toByteArray();
+  }
+
+  public byte[] bytesWritten() {
+    return writeBuffer.toByteArray();
+  }
+
+  public byte[] bytesAppended() {
+    return appendBuffer.toByteArray();
+  }
+
+  @Override
+  public Monitor.InputMonitor monitorRead(Uri uri) {
+    return new ReadMonitor();
+  }
+
+  @Override
+  public Monitor.OutputMonitor monitorWrite(Uri uri) {
+    return new WriteMonitor();
+  }
+
+  @Override
+  public Monitor.OutputMonitor monitorAppend(Uri uri) {
+    return new AppendMonitor();
+  }
+
+  class ReadMonitor implements Monitor.InputMonitor {
+    @Override
+    public void bytesRead(byte[] b, int off, int len) {
+      readBuffer.write(b, off, len);
+    }
+  }
+
+  class WriteMonitor implements Monitor.OutputMonitor {
+    @Override
+    public void bytesWritten(byte[] b, int off, int len) {
+      writeBuffer.write(b, off, len);
+    }
+  }
+
+  class AppendMonitor implements Monitor.OutputMonitor {
+    @Override
+    public void bytesWritten(byte[] b, int off, int len) {
+      appendBuffer.write(b, off, len);
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/testing/DummyTransforms.java b/java/com/google/android/libraries/mobiledatadownload/file/common/testing/DummyTransforms.java
new file mode 100644
index 0000000..7de3d01
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/testing/DummyTransforms.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common.testing;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.spi.Transform;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+/** Dummy transform specs for testing. */
+public class DummyTransforms {
+
+  public static final Transform CAP_FILENAME_TRANSFORM =
+      new Transform() {
+        @Override
+        public String name() {
+          return "cap";
+        }
+
+        @Override
+        public InputStream wrapForRead(Uri uri, InputStream wrapped) throws IOException {
+          return wrapped;
+        }
+
+        @Override
+        public OutputStream wrapForWrite(Uri uri, OutputStream wrapped) throws IOException {
+          return wrapped;
+        }
+
+        @Override
+        public OutputStream wrapForAppend(Uri uri, OutputStream wrapped) throws IOException {
+          return wrapped;
+        }
+
+        @Override
+        public String encode(Uri uri, String filename) {
+          return filename.toUpperCase();
+        }
+      };
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/testing/ExceptionTesting.java b/java/com/google/android/libraries/mobiledatadownload/file/common/testing/ExceptionTesting.java
new file mode 100644
index 0000000..77557bb
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/testing/ExceptionTesting.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common.testing;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+
+/** Common helper utilities for testing exceptions. */
+public final class ExceptionTesting {
+  public static <T extends Throwable> T assertThrowsAsync(
+      Class<T> throwableType, Future<?> future) {
+    ExecutionException executionException = assertThrows(ExecutionException.class, future::get);
+    assertThat(executionException).hasCauseThat().isInstanceOf(throwableType);
+    @SuppressWarnings("unchecked")
+    T exceptionCause = (T) executionException.getCause();
+    return exceptionCause;
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/testing/FakeFileBackend.java b/java/com/google/android/libraries/mobiledatadownload/file/common/testing/FakeFileBackend.java
new file mode 100644
index 0000000..2581c9a
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/testing/FakeFileBackend.java
@@ -0,0 +1,284 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common.testing;
+
+import android.net.Uri;
+import android.util.Pair;
+import com.google.android.libraries.mobiledatadownload.file.backends.JavaFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.common.GcParam;
+import com.google.android.libraries.mobiledatadownload.file.common.LockScope;
+import com.google.android.libraries.mobiledatadownload.file.common.internal.ForwardingOutputStream;
+import com.google.android.libraries.mobiledatadownload.file.spi.Backend;
+import java.io.Closeable;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.EnumMap;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import javax.annotation.concurrent.GuardedBy;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/** A Fake Backend for testing. It allows overriding certain behavior. */
+public class FakeFileBackend implements Backend {
+  private final Backend delegate;
+
+  @GuardedBy("failureMap")
+  private final @Nullable Map<OperationType, IOException> failureMap =
+      new EnumMap<>(OperationType.class);
+
+  @GuardedBy("suspensionMap")
+  private final @Nullable Map<OperationType, CountDownLatch> suspensionMap =
+      new EnumMap<>(OperationType.class);
+
+  /** Available operation types. */
+  public enum OperationType {
+    ALL,
+    READ, // openForRead, openForNativeRead
+    WRITE, // openForWrite, openForAppend
+    QUERY, // exists, isDirectory, fileSize, children, getGcParam, toFile
+    MANAGE, // delete, rename, createDirectory, setGcParam
+    WRITE_STREAM, // openForWrite/Append return streams that fail
+  }
+
+  /**
+   * Creates a {@link FakeFileBackend} that delegates to {@link JavaFileBackend} (file:// URIs). Use
+   * with {@link TemporaryUri} to avoid file path collisions.
+   */
+  public FakeFileBackend() {
+    this(new JavaFileBackend());
+  }
+
+  /** Creates a {@link FakeFileBackend} that delegates to {@code delegate}. */
+  public FakeFileBackend(Backend delegate) {
+    this.delegate = delegate;
+  }
+
+  public void setFailure(OperationType type, IOException ex) {
+    synchronized (failureMap) {
+      if (type == OperationType.ALL) {
+        for (OperationType t : OperationType.values()) {
+          failureMap.put(t, ex);
+        }
+      } else {
+        failureMap.put(type, ex);
+      }
+    }
+  }
+
+  public void clearFailure(OperationType type) {
+    synchronized (failureMap) {
+      if (type == OperationType.ALL) {
+        failureMap.clear();
+      } else {
+        failureMap.remove(type);
+      }
+    }
+  }
+
+  public void setSuspension(OperationType type) {
+    synchronized (suspensionMap) {
+      if (type == OperationType.ALL) {
+        for (OperationType t : OperationType.values()) {
+          suspensionMap.put(t, new CountDownLatch(1));
+        }
+      } else {
+        suspensionMap.put(type, new CountDownLatch(1));
+      }
+    }
+  }
+
+  public void clearSuspension(OperationType type) {
+    synchronized (suspensionMap) {
+      if (type == OperationType.ALL) {
+        for (CountDownLatch latch : suspensionMap.values()) {
+          latch.countDown();
+        }
+        suspensionMap.clear();
+      } else if (suspensionMap.containsKey(type)) {
+        suspensionMap.get(type).countDown();
+        suspensionMap.remove(type);
+      }
+    }
+  }
+
+  private void throwIf(OperationType type) throws IOException {
+    IOException ioException;
+    synchronized (failureMap) {
+      ioException = failureMap.get(type);
+    }
+    if (ioException != null) {
+      throw ioException;
+    }
+  }
+
+  private void suspendIf(OperationType type) throws IOException {
+    CountDownLatch latch;
+    synchronized (suspensionMap) {
+      latch = suspensionMap.get(type);
+    }
+    if (latch != null) {
+      try {
+        latch.await();
+      } catch (InterruptedException ex) {
+        Thread.currentThread().interrupt();
+        throw new IOException(
+            "Thread interrupted while CountDownLatch for suspended operation is waiting ", ex);
+      }
+    }
+  }
+
+  private void throwOrSuspendIf(OperationType type) throws IOException {
+    throwIf(type);
+    suspendIf(type);
+  }
+
+  @Override
+  public String name() {
+    return delegate.name();
+  }
+
+  @Override
+  public InputStream openForRead(Uri uri) throws IOException {
+    throwOrSuspendIf(OperationType.READ);
+    return delegate.openForRead(uri);
+  }
+
+  @Override
+  public Pair<Uri, Closeable> openForNativeRead(Uri uri) throws IOException {
+    throwOrSuspendIf(OperationType.READ);
+    return delegate.openForNativeRead(uri);
+  }
+
+  @Override
+  public OutputStream openForWrite(Uri uri) throws IOException {
+    throwOrSuspendIf(OperationType.WRITE);
+    OutputStream out = delegate.openForWrite(uri);
+    IOException ioException;
+    synchronized (failureMap) {
+      ioException = failureMap.get(OperationType.WRITE_STREAM);
+    }
+    if (ioException != null) {
+      return new FailingOutputStream(out, ioException);
+    }
+    return out;
+  }
+
+  @Override
+  public OutputStream openForAppend(Uri uri) throws IOException {
+    throwOrSuspendIf(OperationType.WRITE);
+    OutputStream out = delegate.openForAppend(uri);
+    IOException ioException;
+    synchronized (failureMap) {
+      ioException = failureMap.get(OperationType.WRITE_STREAM);
+    }
+    if (ioException != null) {
+      return new FailingOutputStream(out, ioException);
+    }
+    return out;
+  }
+
+  @Override
+  public void deleteFile(Uri uri) throws IOException {
+    throwOrSuspendIf(OperationType.MANAGE);
+    delegate.deleteFile(uri);
+  }
+
+  @Override
+  public void deleteDirectory(Uri uri) throws IOException {
+    throwOrSuspendIf(OperationType.MANAGE);
+    delegate.deleteDirectory(uri);
+  }
+
+  @Override
+  public void rename(Uri from, Uri to) throws IOException {
+    throwOrSuspendIf(OperationType.MANAGE);
+    delegate.rename(from, to);
+  }
+
+  @Override
+  public boolean exists(Uri uri) throws IOException {
+    throwOrSuspendIf(OperationType.QUERY);
+    return delegate.exists(uri);
+  }
+
+  @Override
+  public boolean isDirectory(Uri uri) throws IOException {
+    throwOrSuspendIf(OperationType.QUERY);
+    return delegate.isDirectory(uri);
+  }
+
+  @Override
+  public void createDirectory(Uri uri) throws IOException {
+    throwOrSuspendIf(OperationType.MANAGE);
+    delegate.createDirectory(uri);
+  }
+
+  @Override
+  public long fileSize(Uri uri) throws IOException {
+    throwOrSuspendIf(OperationType.QUERY);
+    return delegate.fileSize(uri);
+  }
+
+  @Override
+  public Iterable<Uri> children(Uri parentUri) throws IOException {
+    throwOrSuspendIf(OperationType.QUERY);
+    return delegate.children(parentUri);
+  }
+
+  @Override
+  public GcParam getGcParam(Uri uri) throws IOException {
+    throwOrSuspendIf(OperationType.QUERY);
+    return delegate.getGcParam(uri);
+  }
+
+  @Override
+  public void setGcParam(Uri uri, GcParam param) throws IOException {
+    throwOrSuspendIf(OperationType.MANAGE);
+    delegate.setGcParam(uri, param);
+  }
+
+  @Override
+  public File toFile(Uri uri) throws IOException {
+    throwOrSuspendIf(OperationType.QUERY);
+    return delegate.toFile(uri);
+  }
+
+  @Override
+  public LockScope lockScope() throws IOException {
+    return delegate.lockScope();
+  }
+
+  static class FailingOutputStream extends ForwardingOutputStream {
+    private final IOException exception;
+
+    FailingOutputStream(OutputStream delegate, IOException exception) {
+      super(delegate);
+      this.exception = exception;
+    }
+
+    @Override
+    public void write(byte[] b) throws IOException {
+      throw exception;
+    }
+
+    @Override
+    public void write(byte[] b, int off, int len) throws IOException {
+      throw exception;
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/testing/FileDescriptorLeakChecker.java b/java/com/google/android/libraries/mobiledatadownload/file/common/testing/FileDescriptorLeakChecker.java
new file mode 100644
index 0000000..981de70
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/testing/FileDescriptorLeakChecker.java
@@ -0,0 +1,185 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common.testing;
+
+import static com.google.common.truth.Truth.assertWithMessage;
+
+import android.os.Build;
+import android.os.Process;
+import android.os.SystemClock;
+import android.system.Os;
+import android.util.Log;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Maps;
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.junit.rules.MethodRule;
+import org.junit.runners.model.FrameworkMethod;
+import org.junit.runners.model.Statement;
+
+/**
+ * Rule to ensure that tests do not leak file descriptors. This does not currently work with
+ * robolectric tests (b/121325017).
+ *
+ * <p>Usage: <code>@Rule FileDescriptorLeakChecker leakChecker = new FileDescriptorLeakChecker();
+ * </code>
+ */
+public class FileDescriptorLeakChecker implements MethodRule {
+  private static final String TAG = "FileDescriptorLeakChecker";
+  private static final int MAX_FDS = 1024;
+
+  private List<String> processesToMonitor = null;
+  private List<String> filesToMonitor = null;
+  private long msToWait = 0;
+
+  /**
+   * Processes whose FDs the FileDescriptorLeakChecker needs to monitor.
+   *
+   * @param processesToMonitor The names of the processes to monitor.
+   */
+  public FileDescriptorLeakChecker withProcessesToMonitor(List<String> processesToMonitor) {
+    this.processesToMonitor = processesToMonitor;
+    return this;
+  }
+
+  public FileDescriptorLeakChecker withFilesToMonitor(List<String> filesToMonitor) {
+    this.filesToMonitor = filesToMonitor;
+    return this;
+  }
+
+  /**
+   * If the files are closed asynchronously, the evaluation could fail. This option allows to
+   * perform the evaluation one more time.
+   *
+   * @param msToWait Milliseconds the FileDescriptorLeakChecker needs to wait before retrying.
+   */
+  public FileDescriptorLeakChecker withWaitIfFails(long msToWait) {
+    this.msToWait = msToWait;
+    return this;
+  }
+
+  private ImmutableMap<String, Integer> generateMap(List<Integer> pids) {
+    ImmutableMap.Builder<String, Integer> names = ImmutableMap.builder();
+    for (int pid : pids) {
+      String fdDir = "/proc/" + pid + "/fd/";
+      for (int i = 0; i < MAX_FDS; i++) {
+        try {
+          File fdFile = new File(fdDir + i);
+          if (!fdFile.exists()) {
+            continue;
+          }
+          String filePath;
+          if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+            // Directly reading the symlink allows us to see special files (e.g., pipes),
+            // which are sanitized by getCanonicalPath()
+            filePath = Os.readlink(fdFile.getAbsolutePath());
+          } else {
+            filePath = fdFile.getCanonicalPath();
+          }
+          String key = fdFile + "=" + filePath;
+          names.put(key, pid);
+        } catch (Exception e) {
+          Log.w(TAG, i + " -> " + e);
+        }
+      }
+    }
+    return names.buildOrThrow();
+  }
+
+  private ImmutableList<Integer> getProcessIds() {
+    if (processesToMonitor == null) {
+      int myPid = Process.myPid();
+      assertWithMessage("My process ID unavailable - are you using robolectric? b/121325017")
+          .that(myPid)
+          .isGreaterThan(0);
+      return ImmutableList.of(myPid);
+    }
+    ImmutableList.Builder<Integer> pids = ImmutableList.builder();
+    try {
+      String line;
+      java.lang.Process p = Runtime.getRuntime().exec("ps");
+      BufferedReader input = new BufferedReader(new InputStreamReader(p.getInputStream()));
+      while ((line = input.readLine()) != null) {
+        for (String name : processesToMonitor) {
+          if (line.endsWith(name)) {
+            pids.add(Integer.parseInt(line.split("[ \t]+", -1)[1]));
+            break;
+          }
+        }
+      }
+      input.close();
+    } catch (IOException e) {
+      Log.e(TAG, e.getMessage());
+    }
+    return pids.build();
+  }
+
+  private int filesToMonitorButOpen(Set<String> openFilesAfter) {
+    int res = 0;
+    for (String file : filesToMonitor) {
+      res = openFilesAfter.contains(file) ? res + 1 : res;
+    }
+    return res;
+  }
+
+  private int numberOfInterestingOpenFiles(Map<String, Integer> diffMap) {
+    Set<String> newOpenFiles = diffMap.keySet();
+    return filesToMonitor == null ? newOpenFiles.size() : filesToMonitorButOpen(newOpenFiles);
+  }
+
+  private Map<String, Integer> difference(
+      ImmutableMap<String, Integer> before, ImmutableMap<String, Integer> after) {
+    return Maps.difference(before, after).entriesOnlyOnRight();
+  }
+
+  private String buildMessage(Map<String, Integer> openFiles) {
+    StringBuilder builder = new StringBuilder();
+    builder.append("Your test is leaking file descriptors!\n");
+    for (String key : openFiles.keySet()) {
+      builder.append(String.format("%s is still open in process %d\n", key, openFiles.get(key)));
+    }
+    builder.append('\n');
+    return builder.toString();
+  }
+
+  @Override
+  public Statement apply(Statement base, FrameworkMethod method, Object target) {
+    return new Statement() {
+      @Override
+      public void evaluate() throws Throwable {
+        ImmutableList<Integer> pids = getProcessIds();
+        ImmutableMap<String, Integer> beforeMap = generateMap(pids);
+
+        base.evaluate();
+
+        Map<String, Integer> diffMap = difference(beforeMap, generateMap(pids));
+        int diff = numberOfInterestingOpenFiles(diffMap);
+        if (diff > 0 && msToWait > 0) {
+          SystemClock.sleep(msToWait);
+          diffMap = difference(beforeMap, generateMap(pids));
+          diff = numberOfInterestingOpenFiles(diffMap);
+        }
+        assertWithMessage(buildMessage(diffMap)).that(diff).isEqualTo(0);
+      }
+    };
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/testing/FileStorageTestBase.java b/java/com/google/android/libraries/mobiledatadownload/file/common/testing/FileStorageTestBase.java
new file mode 100644
index 0000000..68aa251
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/testing/FileStorageTestBase.java
@@ -0,0 +1,143 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common.testing;
+
+import static org.mockito.AdditionalAnswers.returnsSecondArg;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+import android.net.Uri;
+import android.util.Pair;
+import com.google.android.libraries.mobiledatadownload.file.spi.Backend;
+import com.google.android.libraries.mobiledatadownload.file.spi.Monitor;
+import com.google.android.libraries.mobiledatadownload.file.spi.Transform;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import org.junit.Before;
+import org.junit.Rule;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+/**
+ * Base class with common code for file storage tests.
+ *
+ * <p>Mocks use "file", "cns", "compress" and "encrypt" to make the tests easier to follow, but they
+ * do not implement any of said behaviors.
+ */
+public abstract class FileStorageTestBase {
+
+  @Rule public final MockitoRule mockito = MockitoJUnit.rule();
+
+  protected final Uri file1Uri;
+  protected final Uri file2Uri;
+  protected final Uri file3Uri;
+  protected final Uri dir1Uri;
+  protected final String file1Filename;
+  protected final String file2Filename;
+  protected final Uri cnsUri;
+  protected final Uri file1CompressUri;
+  protected final Uri file1CompressUriWithEncoded;
+  protected final Uri file2CompressEncryptUri;
+  protected final Uri file3IdentityUri;
+  protected final Uri dir2CompressUri;
+  protected final Uri uriWithCompressParam;
+  protected final Uri uriWithCompressParamWithEncoded;
+  protected final Uri uriWithEncryptParam;
+  protected final Uri uriWithIdentityParam;
+
+  private static final InputStream EMPTY_INPUT = new ByteArrayInputStream(new byte[] {});
+  private static final OutputStream NULL_OUTPUT =
+      new OutputStream() {
+        @Override
+        public void write(int i) throws IOException {
+          // ignore
+        }
+      };
+
+  @Mock protected Backend fileBackend;
+  @Mock protected Backend cnsBackend;
+  @Mock protected Transform compressTransform;
+  @Mock protected Transform encryptTransform;
+  @Mock protected Monitor countingMonitor;
+
+  protected InputStream compressInputStream = new ByteArrayInputStream(new byte[] {});
+  protected InputStream encryptInputStream = new ByteArrayInputStream(new byte[] {});
+  protected OutputStream compressOutputStream = new ByteArrayOutputStream();
+  protected OutputStream encryptOutputStream = new ByteArrayOutputStream();
+  protected Closeable closeable = () -> {};
+
+  protected final Transform identityTransform =
+      new Transform() {
+        @Override
+        public String name() {
+          return "identity";
+        }
+      };
+
+  protected FileStorageTestBase() {
+    // Robolectric doesn't seem to work with static initializers.
+    file1Uri = Uri.parse("file:///file1");
+    file2Uri = Uri.parse("file:///file2");
+    file3Uri = Uri.parse("file:///file3");
+    dir1Uri = Uri.parse("file:///dir1/");
+    file1Filename = "file1";
+    file2Filename = "file2";
+    cnsUri = Uri.parse("cns:///1");
+    file1CompressUri = Uri.parse("file:///file1#transform=compress");
+    // Percent encoding of fragment params is not in our spec (<internal>), but
+    // implemented anyway.
+    file1CompressUriWithEncoded = Uri.parse("file:///file1#transform=compress(k%3D=%28v%29)");
+    file2CompressEncryptUri = Uri.parse("file:///file2#transform=compress+encrypt");
+    file3IdentityUri = Uri.parse("file:///file3#transform=identity");
+    dir2CompressUri = Uri.parse("file:///dir2/#transform=compress");
+    uriWithCompressParam = Uri.parse("#transform=compress");
+    uriWithCompressParamWithEncoded = Uri.parse("#transform=compress(k%3D=%28v%29)");
+    uriWithEncryptParam = Uri.parse("#transform=encrypt");
+    uriWithIdentityParam = Uri.parse("#transform=identity");
+  }
+
+  @Before
+  public void initMocksAndCreateFileStorage() throws Exception {
+    when(fileBackend.name()).thenReturn("file");
+    when(fileBackend.openForRead(any())).thenReturn(EMPTY_INPUT);
+    when(fileBackend.openForWrite(any())).thenReturn(NULL_OUTPUT);
+    when(fileBackend.openForAppend(any())).thenReturn(NULL_OUTPUT);
+    when(cnsBackend.openForRead(any())).thenReturn(EMPTY_INPUT);
+    when(cnsBackend.openForWrite(any())).thenReturn(NULL_OUTPUT);
+    when(cnsBackend.openForAppend(any())).thenReturn(NULL_OUTPUT);
+    when(cnsBackend.name()).thenReturn("cns");
+    when(compressTransform.name()).thenReturn("compress");
+    when(compressTransform.encode(any(), any())).then(returnsSecondArg());
+    when(compressTransform.decode(any(), any())).then(returnsSecondArg());
+    when(encryptTransform.name()).thenReturn("encrypt");
+    when(encryptTransform.encode(any(), any())).then(returnsSecondArg());
+    when(encryptTransform.decode(any(), any())).then(returnsSecondArg());
+    when(fileBackend.openForNativeRead(any()))
+        .thenReturn(Pair.create(Uri.parse("fd:123"), closeable));
+    when(cnsBackend.openForNativeRead(any()))
+        .thenReturn(Pair.create(Uri.parse("fd:456"), closeable));
+    initStorage();
+  }
+
+  // Called after mocks are initialized to create the FileStorage instance.
+  // Required b/c order of @Befores is non-deterministic.
+  protected abstract void initStorage();
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/testing/FragmentParamMatchers.java b/java/com/google/android/libraries/mobiledatadownload/file/common/testing/FragmentParamMatchers.java
new file mode 100644
index 0000000..3808063
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/testing/FragmentParamMatchers.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common.testing;
+
+import static org.mockito.ArgumentMatchers.argThat;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.common.internal.LiteTransformFragments;
+import com.google.common.collect.ImmutableList;
+import org.mockito.ArgumentMatcher;
+
+/** Matchers for Fragment Params. */
+public final class FragmentParamMatchers {
+
+  /** Matcher for {@link Fragment.ParamValue}s. */
+  private static class ParamValueMatcher implements ArgumentMatcher<Uri> {
+    private final String expectedSpec;
+
+    ParamValueMatcher(Uri expected) {
+      ImmutableList<String> specs = LiteTransformFragments.parseTransformSpecs(expected);
+      if (specs.size() != 1) {
+        throw new IllegalArgumentException("Should have only one spec: " + expected);
+      }
+      this.expectedSpec = specs.get(0);
+    }
+
+    @Override
+    public boolean matches(Uri other) {
+      Uri otherUri = (Uri) other;
+      ImmutableList<String> otherSpecs = LiteTransformFragments.parseTransformSpecs(otherUri);
+      for (String otherSpec : otherSpecs) {
+        if (expectedSpec.equals(otherSpec)) {
+          return true;
+        }
+      }
+      return false;
+    }
+  }
+
+  public static Uri eqParam(Uri expected) {
+    return argThat(new ParamValueMatcher(expected));
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/testing/NoOpMonitor.java b/java/com/google/android/libraries/mobiledatadownload/file/common/testing/NoOpMonitor.java
new file mode 100644
index 0000000..b84e41a
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/testing/NoOpMonitor.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common.testing;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.spi.Monitor;
+
+/** A monitor that doesn't monitor any IO (i.e. always returns null). */
+public final class NoOpMonitor implements Monitor {
+
+  @Override
+  public Monitor.InputMonitor monitorRead(Uri unusedUri) {
+    return null;
+  }
+
+  @Override
+  public Monitor.OutputMonitor monitorWrite(Uri unusedUri) {
+    return null;
+  }
+
+  @Override
+  public Monitor.OutputMonitor monitorAppend(Uri unusedUri) {
+    return null;
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/testing/ShadowUtils.java b/java/com/google/android/libraries/mobiledatadownload/file/common/testing/ShadowUtils.java
new file mode 100644
index 0000000..91f1655
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/testing/ShadowUtils.java
@@ -0,0 +1,170 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common.testing;
+
+import static org.robolectric.shadow.api.Shadow.directlyOn;
+
+import android.app.Application;
+import android.content.Context;
+import android.os.Build;
+import android.os.Environment;
+import androidx.test.core.app.ApplicationProvider;
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.RealObject;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.ShadowStatFs;
+
+/** Common helper utilities that extend the Robolectric Shadow API. */
+public final class ShadowUtils {
+
+  /**
+   * Adds an external dir to the Robolectric {@code Environment} and {@code Context}. In order to
+   * use this method, the test class must be configured to use the custom {@link ShadowContextImpl}
+   * and {@link ShadowEnvironment}.
+   */
+  public static File addExternalDir(String path, boolean isEmulated, boolean isMounted) {
+    File dir = ShadowEnvironment.addExternalDir(path);
+    String storageState = isMounted ? Environment.MEDIA_MOUNTED : Environment.MEDIA_REMOVED;
+
+    ShadowEnvironment.setExternalStorageEmulated(dir, isEmulated);
+    ShadowEnvironment.setExternalStorageState(dir, storageState);
+
+    // The shadow implementation of LOLLIPOP storage APIs doesn't fully handle subdirectories, so
+    // the best we can do is to set the same storage properties on each directory we're adding.
+    ShadowContextImpl shadow =
+        Shadow.extract(
+            ((Application) ApplicationProvider.getApplicationContext()).getBaseContext());
+    List<File> packageDirs = shadow.addExternalPackageDirs(dir);
+    for (File packageDir : packageDirs) {
+      ShadowEnvironment.setExternalStorageEmulated(packageDir, isEmulated);
+      ShadowEnvironment.setExternalStorageState(packageDir, storageState);
+    }
+
+    // Configure primary storage APIs if this is the first external dir
+    if (shadow.getExternalFilesDirs(null).length == 1) {
+      ShadowEnvironment.setExternalStorageDirectory(dir);
+      ShadowEnvironment.setIsExternalStorageEmulated(isEmulated);
+      ShadowEnvironment.setExternalStorageState(storageState);
+    }
+
+    return dir;
+  }
+
+  /**
+   * Configures the information returned by the Robolectric {@code StatsFs}.
+   *
+   * @param dir The file under which {@code StatFs} should return the specified stats
+   * @param totalBytes Total number of bytes on the filesystem
+   * @param freeBytes Number of unused bytes on the filesystem
+   */
+  public static void setStatFs(File dir, int totalBytes, int freeBytes) {
+    int blockCount = totalBytes / ShadowStatFs.BLOCK_SIZE;
+    int freeBlocks = freeBytes / ShadowStatFs.BLOCK_SIZE;
+    int availableBlocks = freeBlocks;
+    ShadowStatFs.registerStats(dir, blockCount, freeBlocks, availableBlocks);
+  }
+
+  /** Extends the stock Robolectric {@code Context} shadow to support multiple externalFilesDirs. */
+  @Implements(className = org.robolectric.shadows.ShadowContextImpl.CLASS_NAME)
+  public static class ShadowContextImpl extends org.robolectric.shadows.ShadowContextImpl {
+    @RealObject private Context realObject;
+
+    private final List<File> externalFilesDirs = new ArrayList<>();
+    private final List<File> externalCacheDirs = new ArrayList<>();
+
+    // Used to simulate a race condition failure on pre-N devices. See getFilesDir.
+    private boolean getFilesDirRunAlready = false;
+
+    /**
+     * Adds package-private /files and /cache subdirectories to the Robolectric {@code Context}
+     * under the named external storage {@code partition}, then returns those new subdirectories.
+     */
+    List<File> addExternalPackageDirs(File partition) {
+      File filesDir = new File(partition, "Android/data/com.google.android.storage.test/files");
+      File cacheDir = new File(partition, "Android/data/com.google.android.storage.test/cache");
+      externalFilesDirs.add(filesDir);
+      externalCacheDirs.add(cacheDir);
+      return Arrays.asList(filesDir, cacheDir);
+    }
+
+    @Override
+    @Implementation
+    public File getExternalFilesDir(String type) {
+      return !externalFilesDirs.isEmpty() ? externalFilesDirs.get(0) : null;
+    }
+
+    @Override
+    @Implementation
+    public File[] getExternalFilesDirs(String type) {
+      return externalFilesDirs.toArray(new File[externalFilesDirs.size()]);
+    }
+
+    @Implementation
+    public File getExternalCacheDir() {
+      return !externalCacheDirs.isEmpty() ? externalCacheDirs.get(0) : null;
+    }
+
+    @Implementation
+    public File[] getExternalCacheDirs() {
+      return externalCacheDirs.toArray(new File[externalCacheDirs.size()]);
+    }
+
+    /**
+     * See b/70255835. The first call (or first few calls) of getFilesDir may return null on pre-N
+     * devices. We simulate this by returning null only on the first call here.
+     */
+    @Implementation
+    public File getFilesDir() {
+      if (getFilesDirRunAlready || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+        return directlyOn(realObject, ShadowContextImpl.CLASS_NAME, "getFilesDir");
+      }
+      getFilesDirRunAlready = true;
+      return null;
+    }
+  }
+
+  /**
+   * Extends the stock Robolectric {@code Environment} shadow to better emulate external storage
+   * APIs on lower sdk levels.
+   */
+  @Implements(Environment.class)
+  public static class ShadowEnvironment extends org.robolectric.shadows.ShadowEnvironment {
+
+    private static File externalStorageDirectory;
+
+    /**
+     * Sets the value returned by {@code getExternalStorageDirectory}, which should be the same path
+     * as the first directory added via {@link ShadowEnvironment#addExternalDir}. This is necessary
+     * because by default, Robolectric returns a fixed value for {@code getExternalStorageDirectory}
+     * that doesn't reflect calls to the other shadow APIs.
+     */
+    static void setExternalStorageDirectory(File dir) {
+      externalStorageDirectory = dir;
+    }
+
+    @Implementation
+    public static File getExternalStorageDirectory() {
+      return externalStorageDirectory;
+    }
+  }
+
+  private ShadowUtils() {}
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/testing/StreamUtils.java b/java/com/google/android/libraries/mobiledatadownload/file/common/testing/StreamUtils.java
new file mode 100644
index 0000000..32fe068
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/testing/StreamUtils.java
@@ -0,0 +1,147 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common.testing;
+
+import static com.google.android.libraries.mobiledatadownload.file.common.internal.Charsets.ISO_8859_1;
+import static com.google.android.libraries.mobiledatadownload.file.common.internal.Charsets.UTF_8;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.common.ReleasableResource;
+import com.google.android.libraries.mobiledatadownload.file.openers.AppendStreamOpener;
+import com.google.android.libraries.mobiledatadownload.file.openers.ReadStreamOpener;
+import com.google.android.libraries.mobiledatadownload.file.openers.WriteStreamOpener;
+import com.google.common.io.ByteStreams;
+import com.google.common.io.CharStreams;
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.Reader;
+import java.io.Writer;
+
+/** Helpers for using Streams in tests. */
+public final class StreamUtils {
+
+  private StreamUtils() {}
+
+  @Deprecated
+  public static void createFile(SynchronousFileStorage storage, Uri uri, String contents)
+      throws IOException {
+    writeFileToSink(storage.open(uri, WriteStreamOpener.create()), contents);
+  }
+
+  public static void createFile(SynchronousFileStorage storage, Uri uri, byte[] contents)
+      throws IOException {
+    try (OutputStream out = storage.open(uri, WriteStreamOpener.create())) {
+      out.write(contents);
+    }
+  }
+
+  /**
+   * Write contents to sink stream and then close it.
+   *
+   * @deprecated Use the equivalent byte-based method.
+   */
+  @Deprecated
+  public static void writeFileToSink(OutputStream sink, String contents) throws IOException {
+    try (Writer writer = new OutputStreamWriter(sink, ISO_8859_1)) {
+      writer.write(contents);
+    }
+  }
+
+  public static void writeFileToSink(OutputStream sink, byte[] contents) throws IOException {
+    try (ReleasableResource<Closeable> out = ReleasableResource.create(sink)) {
+      sink.write(contents);
+    }
+  }
+
+  /** Appends or Creates a file at {@code uri} containing the byte stream {@code contents}. */
+  public static void appendFile(SynchronousFileStorage storage, Uri uri, byte[] contents)
+      throws IOException {
+    try (OutputStream out = storage.open(uri, AppendStreamOpener.create())) {
+      out.write(contents);
+    }
+  }
+
+  @Deprecated
+  public static String readFile(SynchronousFileStorage storage, Uri uri) throws IOException {
+    return readFileFromSource(storage.open(uri, ReadStreamOpener.create()));
+  }
+
+  public static byte[] readFileInBytes(SynchronousFileStorage storage, Uri uri) throws IOException {
+    try (InputStream in = storage.open(uri, ReadStreamOpener.create())) {
+      return ByteStreams.toByteArray(in);
+    }
+  }
+  /**
+   * Read all bytes from source stream and then close it.
+   *
+   * @deprecated Use the equivalent byte-based method.
+   */
+  @Deprecated
+  public static String readFileFromSource(InputStream source) throws IOException {
+    try (Reader reader = new InputStreamReader(source, ISO_8859_1)) {
+      return CharStreams.toString(reader);
+    }
+  }
+
+  public static byte[] readFileInBytesFromSource(InputStream source) throws IOException {
+    byte[] read = null;
+    try (ReleasableResource<Closeable> in = ReleasableResource.create(source)) {
+      read = ByteStreams.toByteArray(source);
+    }
+    return read;
+  }
+  /**
+   * Create an amount of content that exceeds what the OS is expected to buffer. This is sufficient
+   * for convincing ourselves that streams behave as expected.
+   *
+   * <p>TODO: This could also be testdata - should it be?
+   *
+   * @deprecated Use the equivalent byte-based method.
+   */
+  @Deprecated
+  public static String makeContentThatExceedsOsBufferSize() {
+    StringBuilder buf = new StringBuilder();
+    for (int i = 0; i < 2000; i++) {
+      buf.append("all work and no play makes jack a dull boy\n");
+    }
+    assertThat(buf.length()).isGreaterThan(65536 /* linux pipe capacity*/);
+    return buf.toString();
+  }
+
+  public static byte[] makeByteContentThatExceedsOsBufferSize() {
+    return makeArrayOfBytesContent(65540); // linux pipe capacity is 65536
+  }
+
+  /** Create an arbitrary array of bytes */
+  public static byte[] makeArrayOfBytesContent() {
+    return "all work and no play makes jack a dull boy\n".getBytes(UTF_8);
+  }
+
+  /** Create an arbitrary array of bytes of a given length */
+  public static byte[] makeArrayOfBytesContent(int length) {
+    byte[] array = new byte[length];
+    for (int i = 0; i < length; i++) {
+      array[i] = (byte) i;
+    }
+    return array;
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/testing/TemporaryAndroidUri.java b/java/com/google/android/libraries/mobiledatadownload/file/common/testing/TemporaryAndroidUri.java
new file mode 100644
index 0000000..e562b2c
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/testing/TemporaryAndroidUri.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common.testing;
+
+import android.content.Context;
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.backends.AndroidUri;
+import com.google.android.libraries.mobiledatadownload.file.backends.AndroidUriAdapter;
+import java.io.File;
+import java.io.IOException;
+import org.junit.rules.ExternalResource;
+import org.junit.rules.TemporaryFolder;
+
+/**
+ * The TemporaryAndroidUri Rule allows creation of android: scheme Uris that should be deleted when
+ * the test method finishes (whether it passes or fails). This is intended to be the Uri equivalent
+ * of the built-in TemporaryFolder rule. Example of usage:
+ *
+ * <pre>{@code
+ * public final class TestClass {
+ *   @Rule public TemporaryAndroidUri tmpUri = new TemporaryAndroidUri();
+ *
+ *   @Test
+ *   public void test() throws IOException {
+ *     Uri uri = tmpUri.newUri();
+ *   }
+ * }
+ * }</pre>
+ */
+public final class TemporaryAndroidUri extends ExternalResource {
+
+  private final Context context;
+
+  // Lazily initialized in before()
+  private TemporaryFolder tmpFolder;
+
+  public TemporaryAndroidUri(Context context) {
+    this.context = context;
+  }
+
+  @Override
+  protected void before() throws Throwable {
+    // Initializes TemporaryFolder under android://files/testing/shared/
+    Uri rootUri = AndroidUri.builder(context).setInternalLocation().setModule("testing").build();
+    File rootFile = AndroidUriAdapter.forContext(context).toFile(rootUri);
+    rootFile.mkdirs();
+    if (!rootFile.isDirectory()) {
+      throw new IOException("Could not create root directory: " + rootFile.getPath());
+    }
+
+    tmpFolder = new TemporaryFolder(rootFile);
+    tmpFolder.create();
+  }
+
+  @Override
+  protected void after() {
+    tmpFolder.delete();
+  }
+
+  /**
+   * Returns an android: Uri to a new temporary file.
+   *
+   * <p>Note that, similar to {@link TemporaryUri}, this method will create an empty file at the
+   * returned location (b/142570676).
+   */
+  public Uri newUri() throws IOException {
+    return AndroidUri.builder(context).fromFile(tmpFolder.newFile()).build();
+  }
+
+  /** Returns an android: Uri to a new temporary folder. */
+  public Uri newDirectoryUri() throws IOException {
+    return AndroidUri.builder(context).fromFile(tmpFolder.newFolder()).build();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/testing/TemporaryUri.java b/java/com/google/android/libraries/mobiledatadownload/file/common/testing/TemporaryUri.java
new file mode 100644
index 0000000..b3a8284
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/testing/TemporaryUri.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common.testing;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.backends.FileUri;
+import java.io.IOException;
+import org.junit.rules.ExternalResource;
+import org.junit.rules.TemporaryFolder;
+
+/**
+ * The TemporaryUri Rule allows creation of file: scheme Uris that should be deleted when the test
+ * method finishes (whether it passes or fails). This is intended to be the Uri equivalent of the
+ * built-in TemporaryFolder rule. Example of usage:
+ *
+ * <pre>{@code
+ * public final class TestClass {
+ *   @Rule public TemporaryUri tmpUri = new TemporaryUri();
+ *
+ *   @Test
+ *   public void test() throws IOException {
+ *     Uri uri = tmpUri.newUri();
+ *   }
+ * }
+ * }</pre>
+ */
+public final class TemporaryUri extends ExternalResource {
+
+  private final TemporaryFolder tmpFolder = new TemporaryFolder();
+
+  @Override
+  protected void before() throws Throwable {
+    tmpFolder.create();
+  }
+
+  @Override
+  protected void after() {
+    tmpFolder.delete();
+  }
+
+  /** Returns a file: Uri to a new temporary file. */
+  public Uri newUri() throws IOException {
+    return FileUri.fromFile(tmpFolder.newFile());
+  }
+
+  /** Returns a file: Uri builder to a new temporary file. */
+  public FileUri.Builder newUriBuilder() throws IOException {
+    return FileUri.builder().fromFile(tmpFolder.newFile());
+  }
+
+  /** Returns a file: Uri to a new temporary folder. */
+  public Uri newDirectoryUri() throws IOException {
+    return FileUri.fromFile(tmpFolder.newFolder());
+  }
+
+  /** Returns the file: Uri of the directory under which other Uris are created. */
+  public Uri getRootUri() {
+    return FileUri.fromFile(tmpFolder.getRoot());
+  }
+
+  /** Returns a file: Uri builder of the directory under which other Uris are created. */
+  public FileUri.Builder getRootUriBuilder() {
+    return FileUri.builder().fromFile(tmpFolder.getRoot());
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/testing/WritesThrowTransform.java b/java/com/google/android/libraries/mobiledatadownload/file/common/testing/WritesThrowTransform.java
new file mode 100644
index 0000000..d16e916
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/testing/WritesThrowTransform.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common.testing;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.common.Fragment;
+import com.google.android.libraries.mobiledatadownload.file.common.internal.ForwardingOutputStream;
+import com.google.android.libraries.mobiledatadownload.file.spi.Transform;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+/** A Transform that throws IOException after writing N bytes of data (reads are unaffected). */
+public class WritesThrowTransform implements Transform {
+
+  private static final String NAME = "writethrows";
+  private static final String LENGTH_SUBPARAM = "write_length";
+
+  @Override
+  public String name() {
+    return NAME;
+  }
+
+  @Override
+  public InputStream wrapForRead(Uri uri, InputStream wrapped) throws IOException {
+    return wrapped;
+  }
+
+  @Override
+  public OutputStream wrapForWrite(Uri uri, OutputStream wrapped) throws IOException {
+    return new ForwardingOutputStream(wrapped) {
+      long byteCount = getLengthSubparam(uri);
+
+      @Override
+      public void write(byte[] b) throws IOException {
+        write(b, 0, b.length);
+      }
+
+      @Override
+      public void write(byte[] b, int off, int len) throws IOException {
+        len = (int) Math.min(len, byteCount); // Write no more than byteCount bytes
+        out.write(b, off, len);
+        byteCount -= len;
+        if (byteCount == 0) {
+          throw new IOException("throwing");
+        }
+      }
+
+      @Override
+      public void write(int b) throws IOException {
+        write(new byte[] {(byte) b}, 0, 1);
+      }
+    };
+  }
+
+  @Override
+  public OutputStream wrapForAppend(Uri uri, OutputStream wrapped) throws IOException {
+    return wrapForWrite(uri, wrapped);
+  }
+
+  /** Returns the value of {@link #LENGTH_SUBPARAM} in {@code param}, or 0 if not present. */
+  private static long getLengthSubparam(Uri uri) {
+    String value = Fragment.getTransformSubParam(uri, NAME, LENGTH_SUBPARAM);
+    return value != null ? Long.parseLong(value) : 0;
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/testing/build_defs.bzl b/java/com/google/android/libraries/mobiledatadownload/file/common/testing/build_defs.bzl
new file mode 100644
index 0000000..eef907a
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/testing/build_defs.bzl
@@ -0,0 +1,104 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+"""Build definition helpers for MobStore library."""
+
+load("@build_bazel_rules_android//android:rules.bzl", "android_application_test")
+
+# NOTE: API level 16 is the lowest supported by GmsCore (<internal>)
+_DEFAULT_TARGET_APIS = [
+    "16",
+    "17",
+    "18",
+    "19",
+    # generic_phone:google_20_x86_gms_stable does not exist (20=KitKat Wear)
+    "21",
+    "22",
+    "23",
+    "24",
+    "25",
+    "26",
+    "27",
+    "28",
+    "29",
+]
+
+_GENERIC_DEVICE_FMT = "generic_phone:google_%s_x86_gms_stable"
+_EMULATOR_DIRECTORY = "//tools/android/emulated_devices/%s"
+
+def android_test_multi_api(
+        name,
+        target_apis = _DEFAULT_TARGET_APIS,
+        **kwargs):
+    """Simple definition for running an android_application_test against multiple API levels.
+
+    For each of the specified API levels we generate an android_application_test for the
+    given test file run at that API level.  All of these tests will be run on
+    TAP. We also generate a duplicate target at the highest API level for ease
+    of local testing; it's marked notap so TAP doesn't run the same test twice.
+
+    For example, with FooTest.java and API levels 18 and 19, we generate:
+    ...:FooTest_generic_phone_google_18_x86_gms_stable (API level 18)
+    ...:FooTest_generic_phone_google_19_x86_gms_stable (API level 19)
+    ...:FooTest (API level 19)
+
+    Args:
+      name: Name of the "default" test target and used to derive subtargets
+      target_apis: List of Android API levels as strings for which a test should
+                   be generated. If unspecified, 16-28 excluding 20 are used.
+      **kwargs: Parameters that are passed to the generated android_application_test rule.
+    """
+
+    # Generate a verbosely-named test target at each API level for TAP testing
+    target_devices = []
+    for target_api in target_apis:
+        target_device = _GENERIC_DEVICE_FMT % (target_api)
+        target_devices.append(target_device)
+    android_test_multi_device(
+        name = name,
+        target_devices = target_devices,
+        **kwargs
+    )
+
+    # Duplicate the highest API target with a simple name for local testing
+    top_api = target_apis[-1]
+    test_device = _EMULATOR_DIRECTORY % (_GENERIC_DEVICE_FMT % top_api)
+    android_application_test(
+        name = name,
+        target_devices = [test_device],
+        tags = ["notap"],
+        **kwargs
+    )
+
+# TODO: consider combining with android_test_multi_api
+def android_test_multi_device(
+        name,
+        target_devices,
+        **kwargs):
+    """Simple definition for running an android_application_test against multiple devices.
+
+    Args:
+      name: Name of the test rule; we generate several sub-targets based on API.
+      target_devices: List of emulators as strings for which a test should be
+                      generated.
+      **kwargs: Parameters that are passed to the generated android_application_test rule.
+    """
+    for target_device in target_devices:
+        sanitized_device = target_device.replace(":", "_")  # ":" is invalid
+        test_name = "%s_%s" % (name, sanitized_device)
+        test_device = _EMULATOR_DIRECTORY % (target_device)
+        android_application_test(
+            name = test_name,
+            target_devices = [test_device],
+            **kwargs
+        )
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/common/testing/test_message.proto b/java/com/google/android/libraries/mobiledatadownload/file/common/testing/test_message.proto
new file mode 100644
index 0000000..4611f9c
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/common/testing/test_message.proto
@@ -0,0 +1,46 @@
+// Copyright 2022 Google LLC
+//
+// 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.
+// Dummy protos for use in tests
+syntax = "proto2";
+
+package google.android.storage.common;
+
+option java_package = "com.google.mobiledatadownload.testing";
+option java_outer_classname = "TestMessageProto";
+
+message FooProto {
+  optional string text = 1;
+  optional bool boolean = 2;
+  optional int32 integer = 3;
+  optional bytes bytes = 4;
+}
+
+message BarProto {
+  optional int32 integer = 1;
+}
+
+message ExtendableProto {
+  extensions 1000 to max;
+}
+
+message ExtensionProto {
+  extend ExtendableProto {
+    optional ExtensionProto extension = 226219688;
+  }
+  optional FooProto foo = 1;
+}
+
+message MapProto {
+  map<string, BarProto> bar = 2;
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/integration/downloader/BUILD b/java/com/google/android/libraries/mobiledatadownload/file/integration/downloader/BUILD
new file mode 100644
index 0000000..42e34fa
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/integration/downloader/BUILD
@@ -0,0 +1,48 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_library")
+
+package(
+    default_visibility = [
+        "//visibility:public",
+    ],
+    licenses = ["notice"],
+)
+
+android_library(
+    name = "downloader2",
+    srcs = [
+        "DownloadDestinationOpener.java",
+        "DownloadMetadataStore.java",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:random_access_file",
+        "@com_google_guava_guava",
+        "@downloader",
+    ],
+)
+
+android_library(
+    name = "downloader2_sp",
+    srcs = [
+        "SharedPreferencesDownloadMetadata.java",
+    ],
+    deps = [
+        ":downloader2",
+        "@com_google_guava_guava",
+        "@downloader",
+    ],
+)
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/integration/downloader/DownloadDestinationOpener.java b/java/com/google/android/libraries/mobiledatadownload/file/integration/downloader/DownloadDestinationOpener.java
new file mode 100644
index 0000000..855ec85
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/integration/downloader/DownloadDestinationOpener.java
@@ -0,0 +1,186 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.integration.downloader;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+
+import android.net.Uri;
+import com.google.android.downloader.DownloadDestination;
+import com.google.android.downloader.DownloadMetadata;
+import com.google.android.libraries.mobiledatadownload.file.OpenContext;
+import com.google.android.libraries.mobiledatadownload.file.Opener;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.common.ReleasableResource;
+import com.google.android.libraries.mobiledatadownload.file.common.UnsupportedFileStorageOperation;
+import com.google.android.libraries.mobiledatadownload.file.openers.RandomAccessFileOpener;
+import com.google.common.base.Optional;
+import com.google.common.util.concurrent.ListenableFuture;
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.nio.channels.FileChannel;
+import java.nio.channels.WritableByteChannel;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeoutException;
+
+/**
+ * A MobStore Opener for <internal>'s {@link DownloadDestination}.
+ *
+ * <p>This creates a {@link DownloadDestination} that supports writing data and connects to a shared
+ * PDS instance to handle metadata updates.
+ *
+ * <pre>{@code
+ * Downloader downloader = new Downloader(...);
+ * DownloadMetadataStore metadataStore = ...;
+ * SynchronousFileStorage storage = new SynchronousFileStorage(...);
+ *
+ * DownloadDestination destination = storage.open(
+ *   targetFileUri,
+ *   DownloadDestinationOpener.create(metadataStore));
+ *
+ * downloader.execute(
+ *   downloader
+ *     .newRequestBuilder(urlToDownload, destination)
+ *     .build());
+ * }</pre>
+ */
+public final class DownloadDestinationOpener implements Opener<DownloadDestination> {
+  private static final long TIMEOUT_MS = 1000;
+
+  /** Implementation of {@link DownloadDestination} created by the opener. */
+  private static final class DownloadDestinationImpl implements DownloadDestination {
+    // We need to touch two underlying files (metadata from DownloadMetadataStore and the downloaded
+    // file). Define a lock to keep the access of these files synchronized.
+    private final Object lock = new Object();
+
+    private final Uri onDeviceUri;
+    private final DownloadMetadataStore metadataStore;
+    private final SynchronousFileStorage fileStorage;
+
+    private DownloadDestinationImpl(
+        Uri onDeviceUri, SynchronousFileStorage fileStorage, DownloadMetadataStore metadataStore) {
+      this.onDeviceUri = onDeviceUri;
+      this.metadataStore = metadataStore;
+      this.fileStorage = fileStorage;
+    }
+
+    @Override
+    public long numExistingBytes() throws IOException {
+      return fileStorage.fileSize(onDeviceUri);
+    }
+
+    @Override
+    public DownloadMetadata readMetadata() throws IOException {
+      synchronized (lock) {
+        Optional<DownloadMetadata> existingMetadata =
+            blockingGet(metadataStore.read(onDeviceUri), "Failed to read metadata.");
+
+        // Return existing metadata, or a new instance.
+        return existingMetadata.or(DownloadMetadata::create);
+      }
+    }
+
+    @Override
+    public WritableByteChannel openByteChannel(long byteOffset, DownloadMetadata metadata)
+        throws IOException {
+      // Ensure that metadata is not null
+      checkArgument(metadata != null, "Received null metadata to store");
+      // Check that offset is in range
+      long fileSize = numExistingBytes();
+      checkArgument(
+          byteOffset >= 0 && byteOffset <= fileSize,
+          "Offset for write (%s) out of range of existing file size (%s bytes)",
+          byteOffset,
+          fileSize);
+
+      synchronized (lock) {
+        // Update metadata first.
+        blockingGet(metadataStore.upsert(onDeviceUri, metadata), "Failed to update metadata.");
+
+        // Use ReleasableResource to ensure channel is setup properly before returning it.
+        try (ReleasableResource<RandomAccessFile> file =
+            ReleasableResource.create(
+                fileStorage.open(onDeviceUri, RandomAccessFileOpener.createForReadWrite()))) {
+          // Get channel and seek to correct offset.
+          FileChannel channel = file.get().getChannel();
+          channel.position(byteOffset);
+
+          // Release ownership -- caller is responsible for closing the channel.
+          file.release();
+
+          return channel;
+        }
+      }
+    }
+
+    @Override
+    public void clear() throws IOException {
+      synchronized (lock) {
+        // clear metadata and delete file.
+        blockingGet(metadataStore.delete(onDeviceUri), "Failed to clear metadata.");
+
+        fileStorage.deleteFile(onDeviceUri);
+      }
+    }
+
+    /**
+     * Helper method for async call error handling.
+     *
+     * <p>Exceptions due to an async call failure are handled and wrapped in an IOException.
+     */
+    private static <V> V blockingGet(ListenableFuture<V> future, String errorMessage)
+        throws IOException {
+      try {
+        return future.get(TIMEOUT_MS, MILLISECONDS);
+      } catch (InterruptedException e) {
+        Thread.currentThread().interrupt();
+        throw new IOException(errorMessage, e.getCause());
+      } catch (ExecutionException e) {
+        throw new IOException(errorMessage, e.getCause());
+      } catch (TimeoutException | CancellationException e) {
+        throw new IOException(errorMessage, e);
+      }
+    }
+  }
+
+  private final DownloadMetadataStore metadataStore;
+
+  private DownloadDestinationOpener(DownloadMetadataStore metadataStore) {
+    this.metadataStore = metadataStore;
+  }
+
+  @Override
+  public DownloadDestination open(OpenContext openContext) throws IOException {
+    if (openContext.hasTransforms()) {
+      throw new UnsupportedFileStorageOperation(
+          "Transforms are not supported by this Opener: " + openContext.originalUri());
+    }
+
+    // Check whether or not the file uri is a directory.
+    if (openContext.storage().isDirectory(openContext.originalUri())) {
+      throw new IOException(
+          new IllegalArgumentException("Requested file download is already a directory."));
+    }
+
+    return new DownloadDestinationImpl(
+        openContext.originalUri(), openContext.storage(), metadataStore);
+  }
+
+  public static DownloadDestinationOpener create(DownloadMetadataStore metadataStore) {
+    return new DownloadDestinationOpener(metadataStore);
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/integration/downloader/DownloadMetadataStore.java b/java/com/google/android/libraries/mobiledatadownload/file/integration/downloader/DownloadMetadataStore.java
new file mode 100644
index 0000000..95c7853
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/integration/downloader/DownloadMetadataStore.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.integration.downloader;
+
+import android.net.Uri;
+import com.google.android.downloader.DownloadMetadata;
+import com.google.common.base.Optional;
+import com.google.common.util.concurrent.ListenableFuture;
+
+/** Storage mechanism for DownloadMetadata. */
+public interface DownloadMetadataStore {
+  /** Returns the DownloadMetadata associated with {@code uri} if it exists. */
+  ListenableFuture<Optional<DownloadMetadata>> read(Uri uri);
+
+  /** Inserts or updates the {@code metadata} associated with {@code uri}. */
+  ListenableFuture<Void> upsert(Uri uri, DownloadMetadata metadata);
+
+  /** Deletes the DownloadMetadata associated with {@code uri} if it exists. */
+  ListenableFuture<Void> delete(Uri uri);
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/integration/downloader/SharedPreferencesDownloadMetadata.java b/java/com/google/android/libraries/mobiledatadownload/file/integration/downloader/SharedPreferencesDownloadMetadata.java
new file mode 100644
index 0000000..b4586af
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/integration/downloader/SharedPreferencesDownloadMetadata.java
@@ -0,0 +1,115 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.integration.downloader;
+
+import android.content.SharedPreferences;
+import android.net.Uri;
+import com.google.android.downloader.DownloadMetadata;
+import com.google.common.base.Optional;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import java.io.IOException;
+
+/** DownloadMetadataStore based on SharedPreferences. */
+public final class SharedPreferencesDownloadMetadata implements DownloadMetadataStore {
+
+  private final Object lock = new Object();
+
+  private final SharedPreferences sharedPrefs;
+  private final ListeningExecutorService backgroundExecutor;
+
+  public SharedPreferencesDownloadMetadata(
+      SharedPreferences sharedPrefs, ListeningExecutorService backgroundExecutor) {
+    this.sharedPrefs = sharedPrefs;
+    this.backgroundExecutor = backgroundExecutor;
+  }
+
+  /** Data fields for each URI persisted in SharedPreferences. */
+  private enum Key {
+    CONTENT_TAG("ct"),
+    LAST_MODIFIED_TIME_SECS("lmts");
+
+    final String sharedPrefsSuffix;
+
+    Key(String sharedPrefsSuffix) {
+      this.sharedPrefsSuffix = sharedPrefsSuffix;
+    }
+  }
+
+  private static final String SPLIT_CHAR = "|";
+
+  /** Returns the SharedPreferences key used to store the given data field for {@code uri}. */
+  private static String getKey(Uri uri, Key key) {
+    return uri + SPLIT_CHAR + key.sharedPrefsSuffix;
+  }
+
+  @Override
+  public ListenableFuture<Optional<DownloadMetadata>> read(Uri uri) {
+    return backgroundExecutor.submit(
+        () -> {
+          synchronized (lock) {
+            // Checking for CONTENT_TAG is sufficient since we will always store both the content
+            // tag and last modified timestamp in the same commit.
+            if (!sharedPrefs.contains(getKey(uri, Key.CONTENT_TAG))) {
+              return Optional.absent();
+            }
+
+            String contentTag = sharedPrefs.getString(getKey(uri, Key.CONTENT_TAG), "");
+            long lastModifiedTimeSeconds =
+                sharedPrefs.getLong(getKey(uri, Key.LAST_MODIFIED_TIME_SECS), 0);
+            return Optional.of(DownloadMetadata.create(contentTag, lastModifiedTimeSeconds));
+          }
+        });
+  }
+
+  @Override
+  public ListenableFuture<Void> upsert(Uri uri, DownloadMetadata metadata) {
+    return backgroundExecutor.submit(
+        () -> {
+          synchronized (lock) {
+            SharedPreferences.Editor editor = sharedPrefs.edit();
+            editor.putString(getKey(uri, Key.CONTENT_TAG), metadata.getContentTag());
+            editor.putLong(
+                getKey(uri, Key.LAST_MODIFIED_TIME_SECS), metadata.getLastModifiedTimeSeconds());
+            commitOrThrow(editor);
+
+            return null;
+          }
+        });
+  }
+
+  @Override
+  public ListenableFuture<Void> delete(Uri uri) {
+    return backgroundExecutor.submit(
+        () -> {
+          synchronized (lock) {
+            SharedPreferences.Editor editor = sharedPrefs.edit();
+            editor.remove(getKey(uri, Key.CONTENT_TAG));
+            editor.remove(getKey(uri, Key.LAST_MODIFIED_TIME_SECS));
+            commitOrThrow(editor);
+
+            return null;
+          }
+        });
+  }
+
+  /** Calls {@code editor.commit()} and throws IOException if the commit failed. */
+  private static void commitOrThrow(SharedPreferences.Editor editor) throws IOException {
+    if (!editor.commit()) {
+      throw new IOException("Failed to commit");
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/monitors/BUILD b/java/com/google/android/libraries/mobiledatadownload/file/monitors/BUILD
new file mode 100644
index 0000000..11f0cbd
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/monitors/BUILD
@@ -0,0 +1,29 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_library")
+
+package(
+    default_visibility = [
+        "//visibility:public",
+    ],
+    licenses = ["notice"],
+)
+
+android_library(
+    name = "monitors",
+    srcs = ["ByteCountingOutputMonitor.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/spi",
+    ],
+)
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/monitors/ByteCountingOutputMonitor.java b/java/com/google/android/libraries/mobiledatadownload/file/monitors/ByteCountingOutputMonitor.java
new file mode 100644
index 0000000..25df6c1
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/monitors/ByteCountingOutputMonitor.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.monitors;
+
+import com.google.android.libraries.mobiledatadownload.file.spi.Monitor;
+import java.util.concurrent.TimeUnit;
+
+/** An OutputMonitor {@link Monitor.OutputMonitor} class that counts bytes written. */
+public final class ByteCountingOutputMonitor implements Monitor.OutputMonitor {
+
+  /** Interface of SystemClock. */
+  public interface Clock {
+    /** Returns the current system time in milliseconds since January 1, 1970 00:00:00 UTC. */
+    long currentTimeMillis();
+  }
+
+  private final Counter counter;
+  private final Clock clock;
+
+  // We will only flush counters to storage at most once in this time frame.
+  private final long logFrequencyInMillis;
+
+  // Last timestamp that we flushed counters to storage.
+  private long lastFlushTimestampMs;
+
+  /**
+   * Creates a ByteCountingOutputMonitor instance with the given counting behavior.
+   *
+   * @param counter The {@link Counter} implementation for buffer and flush.
+   * @param clock The {@link Clock} instance to align and track counter operations.
+   * @param logFrequency {@link long} desired interval between each successful flush.
+   * @param timeUnit {@link TimeUnit} unit of time for logFrequency
+   */
+  public ByteCountingOutputMonitor(
+      Counter counter, Clock clock, long logFrequency, TimeUnit timeUnit) {
+    this.counter = counter;
+    this.clock = clock;
+    this.logFrequencyInMillis = timeUnit.toMillis(logFrequency);
+    lastFlushTimestampMs = clock.currentTimeMillis();
+  }
+
+  @Override
+  public final void bytesWritten(byte[] b, int off, int len) {
+    counter.bufferCounter(len);
+
+    // Check if enough time has passed since the last flush.
+    if ((clock.currentTimeMillis() - lastFlushTimestampMs) >= logFrequencyInMillis) {
+      counter.flushCounter();
+
+      // Reset timestamp.
+      lastFlushTimestampMs = clock.currentTimeMillis();
+    }
+  }
+
+  @Override
+  public void close() {
+    counter.flushCounter();
+  }
+
+  public Counter getCounter() {
+    return counter;
+  }
+
+  /** A Counter interface to handle buffering and flushing for rate-limiting behavior. */
+  public interface Counter {
+
+    /** Saves byte length to an in-memory buffer to limit writing to disk. */
+    void bufferCounter(int len);
+
+    /** Log currently counted bytes to disk and reset the counter. */
+    void flushCounter();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/openers/AppendStreamOpener.java b/java/com/google/android/libraries/mobiledatadownload/file/openers/AppendStreamOpener.java
new file mode 100644
index 0000000..bff5543
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/openers/AppendStreamOpener.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import com.google.android.libraries.mobiledatadownload.file.Behavior;
+import com.google.android.libraries.mobiledatadownload.file.OpenContext;
+import com.google.android.libraries.mobiledatadownload.file.Opener;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.List;
+
+/** An opener that returns a simple OutputStream that appends to the file. */
+public final class AppendStreamOpener implements Opener<OutputStream> {
+
+  private Behavior[] behaviors;
+
+  private AppendStreamOpener() {}
+
+  public static AppendStreamOpener create() {
+    return new AppendStreamOpener();
+  }
+
+  /**
+   * Supports adding options to writes. For example, SyncBehavior will force data to be flushed and
+   * durably persisted.
+   */
+  public AppendStreamOpener withBehaviors(Behavior... behaviors) {
+    this.behaviors = behaviors;
+    return this;
+  }
+
+  @Override
+  public OutputStream open(OpenContext openContext) throws IOException {
+    OutputStream backendOutput = openContext.backend().openForAppend(openContext.encodedUri());
+    List<OutputStream> chain = openContext.chainTransformsForAppend(backendOutput);
+    if (behaviors != null) {
+      for (Behavior behavior : behaviors) {
+        behavior.forOutputChain(chain);
+      }
+    }
+    return chain.get(0);
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/openers/AssetFileDescriptorOpener.java b/java/com/google/android/libraries/mobiledatadownload/file/openers/AssetFileDescriptorOpener.java
new file mode 100644
index 0000000..8b58414
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/openers/AssetFileDescriptorOpener.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import android.content.res.AssetFileDescriptor;
+import android.os.ParcelFileDescriptor;
+import com.google.android.libraries.mobiledatadownload.file.OpenContext;
+import com.google.android.libraries.mobiledatadownload.file.Opener;
+import java.io.IOException;
+
+/**
+ * Opener that returns an AssetFileDescriptor. Caller must close the AssetFileDescriptor when done
+ * reading from it. Does not support Monitors or Transforms.
+ *
+ * <p>TODO: Support offset and length.
+ */
+public final class AssetFileDescriptorOpener implements Opener<AssetFileDescriptor> {
+
+  private AssetFileDescriptorOpener() {}
+
+  public static AssetFileDescriptorOpener create() {
+    return new AssetFileDescriptorOpener();
+  }
+
+  @Override
+  public AssetFileDescriptor open(OpenContext openContext) throws IOException {
+    ParcelFileDescriptorOpener pfdOpener = ParcelFileDescriptorOpener.create();
+    ParcelFileDescriptor pfd = pfdOpener.open(openContext);
+    // TODO(b/115933017): consider wrapping the AFD to force it to implement Closeable on all sdks
+    return new AssetFileDescriptor(pfd, 0, pfd.getStatSize());
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/openers/BUILD b/java/com/google/android/libraries/mobiledatadownload/file/openers/BUILD
new file mode 100644
index 0000000..8511d59
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/openers/BUILD
@@ -0,0 +1,219 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_library")
+
+package(
+    default_visibility = [
+        "//visibility:public",
+    ],
+    licenses = ["notice"],
+)
+
+android_library(
+    name = "asset_file_descriptor",
+    srcs = ["AssetFileDescriptorOpener.java"],
+    deps = [
+        ":parcel_file_descriptor",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+    ],
+)
+
+android_library(
+    name = "random_access_file",
+    srcs = ["RandomAccessFileOpener.java"],
+    deps = [
+        ":file",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "closeable_uri",
+    srcs = ["CloseableUri.java"],
+)
+
+# Requires API level 21+
+android_library(
+    name = "file",
+    srcs = [
+        "Pipes.java",
+        "ReadFileOpener.java",
+        "WriteFileOpener.java",
+    ],
+    deps = [
+        ":stream",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "@androidx_annotation_annotation",  # buildcleaner: keep
+        "@com_google_code_findbugs_jsr305",
+    ],
+)
+
+android_library(
+    name = "stream_mutation",
+    srcs = ["StreamMutationOpener.java"],
+    deps = [
+        ":lock_file",
+        ":scratch",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "@com_google_code_findbugs_jsr305",
+    ],
+)
+
+android_library(
+    name = "native",
+    srcs = [
+        "NativeReadOpener.java",
+    ],
+    deps = [
+        ":closeable_uri",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+    ],
+)
+
+android_library(
+    name = "parcel_file_descriptor",
+    srcs = ["ParcelFileDescriptorOpener.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file_descriptor",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+    ],
+)
+
+android_library(
+    name = "proto",
+    srcs = [
+        "ReadProtoOpener.java",
+        "WriteProtoOpener.java",
+    ],
+    deps = [
+        ":scratch",
+        ":stream",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "@com_google_protobuf//:protobuf_lite",
+    ],
+)
+
+android_library(
+    name = "lock_file",
+    srcs = [
+        "LockFileOpener.java",
+    ],
+    deps = [
+        ":random_access_file",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "@com_google_code_findbugs_jsr305",
+    ],
+)
+
+android_library(
+    name = "recursive_delete",
+    srcs = ["RecursiveDeleteOpener.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/internal:exceptions",
+    ],
+)
+
+android_library(
+    name = "recursive_size",
+    srcs = ["RecursiveSizeOpener.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "stream",
+    srcs = [
+        "AppendStreamOpener.java",
+        "ReadStreamOpener.java",
+        "WriteStreamOpener.java",
+    ],
+    deps = ["//java/com/google/android/libraries/mobiledatadownload/file"],
+)
+
+android_library(
+    name = "integrity_uri_computer",
+    srcs = ["IntegrityUriComputingOpener.java"],
+    deps = [
+        ":stream",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/behaviors:compute_uri",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:integrity",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:proto_fragments",
+        "//proto:transform_java_proto_lite",
+        "@com_google_guava_guava",
+    ],
+)
+
+# Requires API level 21+
+android_library(
+    name = "system_library",
+    srcs = ["SystemLibraryOpener.java"],
+    deps = [
+        ":file",
+        ":stream",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "@com_google_code_findbugs_jsr305",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "string",
+    srcs = [
+        "ReadStringOpener.java",
+        "WriteStringOpener.java",
+    ],
+    deps = [
+        ":bytes",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/internal:charsets",
+    ],
+)
+
+android_library(
+    name = "bytes",
+    srcs = [
+        "ReadByteArrayOpener.java",
+        "WriteByteArrayOpener.java",
+    ],
+    deps = [
+        ":stream",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "memory_mapped_bytes",
+    srcs = ["MappedByteBufferOpener.java"],
+    deps = [
+        ":stream",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+    ],
+)
+
+android_library(
+    name = "scratch",
+    srcs = ["ScratchFile.java"],
+    visibility = ["//:__subpackages__"],
+)
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/openers/CloseableUri.java b/java/com/google/android/libraries/mobiledatadownload/file/openers/CloseableUri.java
new file mode 100644
index 0000000..105ee71
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/openers/CloseableUri.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import android.net.Uri;
+import java.io.Closeable;
+
+/** A pair of <URI, Closeable>. Once closed the URI is no longer valid. */
+public interface CloseableUri extends Closeable {
+  Uri uri();
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/openers/IntegrityUriComputingOpener.java b/java/com/google/android/libraries/mobiledatadownload/file/openers/IntegrityUriComputingOpener.java
new file mode 100644
index 0000000..4643a52
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/openers/IntegrityUriComputingOpener.java
@@ -0,0 +1,73 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.OpenContext;
+import com.google.android.libraries.mobiledatadownload.file.Opener;
+import com.google.android.libraries.mobiledatadownload.file.behaviors.UriComputingBehavior;
+import com.google.android.libraries.mobiledatadownload.file.transforms.IntegrityTransform;
+import com.google.android.libraries.mobiledatadownload.file.transforms.TransformProtoFragments;
+import com.google.common.io.ByteStreams;
+import com.google.mobiledatadownload.TransformProto;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.concurrent.ExecutionException;
+
+/** An opener that produces a URI augumented with the IntegrityUriComputing of the input URI. */
+public final class IntegrityUriComputingOpener implements Opener<Uri> {
+
+  private static final TransformProto.Transform DEFAULT_SPEC =
+      TransformProto.Transform.newBuilder()
+          .setIntegrity(TransformProto.IntegrityTransform.getDefaultInstance())
+          .build();
+
+  private IntegrityUriComputingOpener() {}
+
+  public static IntegrityUriComputingOpener create() {
+    return new IntegrityUriComputingOpener();
+  }
+
+  @Override
+  public Uri open(OpenContext openContext) throws IOException {
+    Uri uri = openContext.originalUri();
+
+    uri = TransformProtoFragments.addOrReplaceTransform(uri, DEFAULT_SPEC);
+    UriComputingBehavior uriComputer = new UriComputingBehavior(uri);
+    try (InputStream stream =
+        openContext.storage().open(uri, ReadStreamOpener.create().withBehaviors(uriComputer))) {
+      ByteStreams.exhaust(stream);
+      Uri uriWithDigest;
+      try {
+        uriWithDigest = uriComputer.uriFuture().get();
+      } catch (InterruptedException e) {
+        Thread.currentThread().interrupt(); // per <internal>
+        throw new IOException(e);
+      } catch (ExecutionException e) {
+        if (e.getCause() instanceof IOException) {
+          throw (IOException) e.getCause();
+        }
+        throw new IOException(e);
+      }
+      String base64Digest = IntegrityTransform.getDigestIfPresent(uriWithDigest);
+      TransformProto.Transform newSpec =
+          TransformProto.Transform.newBuilder()
+              .setIntegrity(TransformProto.IntegrityTransform.newBuilder().setSha256(base64Digest))
+              .build();
+      return TransformProtoFragments.addOrReplaceTransform(uriWithDigest, newSpec);
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/openers/LockFileOpener.java b/java/com/google/android/libraries/mobiledatadownload/file/openers/LockFileOpener.java
new file mode 100644
index 0000000..c608ec2
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/openers/LockFileOpener.java
@@ -0,0 +1,182 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.OpenContext;
+import com.google.android.libraries.mobiledatadownload.file.Opener;
+import com.google.android.libraries.mobiledatadownload.file.common.FileChannelConvertible;
+import com.google.android.libraries.mobiledatadownload.file.common.ReleasableResource;
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.nio.channels.FileChannel;
+import javax.annotation.Nullable;
+
+/**
+ * An opener for acquiring lock files.
+ *
+ * <p>Lock files are used to separate lock acquisition from IO on the target file itself. For a
+ * target file "data.txt", an associated lock file "data.txt.lock" is created and used to control
+ * locking instead of acquiring a file lock on "data.txt" itself. This means the lock holder can
+ * perform a wider range of operations on the target file than would have been possible with a
+ * simple file lock on the target; the lock acts as an independent semaphore.
+ *
+ * <p>Note that this opener is incompatible with opaque URIs, e.g. "file:///foo.txt" is compatible
+ * whereas "memory:foo.txt" is not.
+ *
+ * <p>TODO: consider allowing client to specify lock file in order to support opaque URIs.
+ */
+public final class LockFileOpener implements Opener<Closeable> {
+
+  private static final String LOCK_SUFFIX = ".lock";
+
+  private final boolean shared;
+  private final boolean readOnly;
+  private boolean isNonBlocking;
+
+  private LockFileOpener(boolean shared, boolean readOnly) {
+    this.shared = shared;
+    this.readOnly = readOnly;
+  }
+
+  /**
+   * Creates an instance that will acquire an exclusive lock on the file. {@link #open} will create
+   * the lock file if it doesn't already exist.
+   */
+  public static LockFileOpener createExclusive() {
+    return new LockFileOpener(/* shared= */ false, /* readOnly= */ false);
+  }
+
+  /**
+   * Creates an instance that will acquire a shared lock on the file (shared across processes;
+   * multiple threads in the same process exclude one another). {@link #open} won't create the lock
+   * file if it doesn't already exist (instead throwing {@code FileNotFoundException}), meaning this
+   * opener is read-only.
+   */
+  public static LockFileOpener createReadOnlyShared() {
+    return new LockFileOpener(/* shared= */ true, /* readOnly= */ true);
+  }
+
+  /**
+   * Creates an instance that will acquire a shared lock on the file (shared across processes;
+   * multiple threads in the same process exclude one another). {@link #open} *will* create the lock
+   * file if it doesn't already exist.
+   */
+  public static LockFileOpener createShared() {
+    return new LockFileOpener(/* shared= */ true, /* readOnly= */ false);
+  }
+
+  /**
+   * If enabled and the lock cannot be acquired immediately, {@link #open} will return {@code null}
+   * instead of waiting until the lock can be acquired.
+   */
+  public LockFileOpener nonBlocking(boolean isNonBlocking) {
+    this.isNonBlocking = isNonBlocking;
+    return this;
+  }
+
+  // TODO(b/131180722): consider adding option for blocking with timeout
+
+  @Override
+  @Nullable
+  public Closeable open(OpenContext openContext) throws IOException {
+    // Clearing fragment is necessary to open a FileChannelConvertible stream.
+    Uri lockUri =
+        openContext
+            .originalUri()
+            .buildUpon()
+            .path(openContext.encodedUri().getPath() + LOCK_SUFFIX)
+            .fragment("")
+            .build();
+
+    try (ReleasableResource<Closeable> threadLockResource =
+        ReleasableResource.create(openThreadLock(openContext, lockUri))) {
+      if (threadLockResource.get() == null) {
+        return null;
+      }
+
+      try (ReleasableResource<Closeable> streamResource =
+              ReleasableResource.create(openStreamForLocking(openContext, lockUri));
+          ReleasableResource<Closeable> fileLockResource =
+              ReleasableResource.create(openFileLock(openContext, streamResource.get()))) {
+        if (fileLockResource.get() == null) {
+          return null;
+        }
+
+        // The thread lock guards access to the stream and file lock so *must* be closed last, and
+        // a file lock must be closed before its underlying file so *must* be closed first.
+        Closeable threadLock = threadLockResource.release();
+        Closeable stream = streamResource.release();
+        Closeable fileLock = fileLockResource.release();
+        return () -> {
+          try (Closeable last = threadLock;
+              Closeable middle = stream;
+              Closeable first = fileLock) {}
+        };
+      }
+    }
+  }
+
+  /**
+   * Acquires (or tries to acquire) the cross-thread lock for {@code lockUri}. This is a
+   * sub-operation of {@link #open}.
+   */
+  @Nullable
+  private Closeable openThreadLock(OpenContext openContext, Uri lockUri) throws IOException {
+    if (isNonBlocking) {
+      return openContext.backend().lockScope().tryThreadLock(lockUri);
+    } else {
+      return openContext.backend().lockScope().threadLock(lockUri);
+    }
+  }
+
+  /** Opens a stream to {@code lockUri}. This is a sub-operation of {@link #open}. */
+  private Closeable openStreamForLocking(OpenContext openContext, Uri lockUri) throws IOException {
+    if (shared && readOnly) {
+      return openContext.backend().openForRead(lockUri);
+    } else if (shared && !readOnly) {
+      return openContext.storage().open(lockUri, RandomAccessFileOpener.createForReadWrite());
+    } else {
+      return openContext.backend().openForWrite(lockUri);
+    }
+  }
+
+  /**
+   * Acquires (or tries to acquire) the cross-process lock for {@code stream}. Fails if the stream
+   * can't be converted to FileChannel. This is a sub-operation of {@link #open}.
+   */
+  @Nullable
+  private Closeable openFileLock(OpenContext openContext, Closeable closeable) throws IOException {
+    FileChannel channel = getFileChannelFromCloseable(closeable);
+    if (isNonBlocking) {
+      return openContext.backend().lockScope().tryFileLock(channel, shared);
+    } else {
+      return openContext.backend().lockScope().fileLock(channel, shared);
+    }
+  }
+
+  private static FileChannel getFileChannelFromCloseable(Closeable closeable) throws IOException {
+    // TODO(b/181119642): Update code so we are not casing on instanceof.
+    if (closeable instanceof FileChannelConvertible) {
+      return ((FileChannelConvertible) closeable).toFileChannel();
+    } else if (closeable instanceof RandomAccessFile) {
+      return ((RandomAccessFile) closeable).getChannel();
+    } else {
+      throw new IOException("Lock stream not convertible to FileChannel");
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/openers/MappedByteBufferOpener.java b/java/com/google/android/libraries/mobiledatadownload/file/openers/MappedByteBufferOpener.java
new file mode 100644
index 0000000..1390e31
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/openers/MappedByteBufferOpener.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import com.google.android.libraries.mobiledatadownload.file.OpenContext;
+import com.google.android.libraries.mobiledatadownload.file.Opener;
+import com.google.android.libraries.mobiledatadownload.file.common.FileChannelConvertible;
+import com.google.android.libraries.mobiledatadownload.file.common.UnsupportedFileStorageOperation;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.MappedByteBuffer;
+import java.nio.channels.FileChannel;
+import java.nio.channels.FileChannel.MapMode;
+
+/**
+ * Opener that maps a file directly into memory (read only).
+ *
+ * <p>Warning: MappedByteBuffer is known to suffer from poor garbage collection; see {@link
+ * <internal>}.
+ *
+ * <p>Usage: <code>
+ * MappedByteBuffer buffer = storage.open(uri, MappedByteBufferOpener.create());
+ * </code>
+ */
+public final class MappedByteBufferOpener implements Opener<MappedByteBuffer> {
+
+  private MappedByteBufferOpener() {}
+
+  public static MappedByteBufferOpener createForRead() {
+    return new MappedByteBufferOpener();
+  }
+
+  @Override
+  public MappedByteBuffer open(OpenContext openContext) throws IOException {
+    // FileChannelConvertible (vs ReadFileOpener) allows this opener to be used over IPC.
+    try (InputStream stream = ReadStreamOpener.create().open(openContext)) {
+      if (stream instanceof FileChannelConvertible) {
+        FileChannel fileChannel = ((FileChannelConvertible) stream).toFileChannel();
+        return fileChannel.map(MapMode.READ_ONLY, 0, fileChannel.size());
+      }
+      throw new UnsupportedFileStorageOperation(
+          "URI not convertible to FileChannel for mapping: " + openContext.originalUri());
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/openers/NativeReadOpener.java b/java/com/google/android/libraries/mobiledatadownload/file/openers/NativeReadOpener.java
new file mode 100644
index 0000000..aa7d38c
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/openers/NativeReadOpener.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import android.net.Uri;
+import android.util.Pair;
+import com.google.android.libraries.mobiledatadownload.file.OpenContext;
+import com.google.android.libraries.mobiledatadownload.file.Opener;
+import java.io.Closeable;
+import java.io.IOException;
+
+/**
+ * An opener that produces a file descriptor URI (like "fd:123"). This is useful for opening a file
+ * in Java and passing a handle to it down to C++.
+ *
+ * <p>Transforms are not applied in the Java code and are retained in the returned URI so they can
+ * be applied in native code.
+ *
+ * <p>The caller is responsible for closing the file descriptor. The native code is expected to dup
+ * the descriptor if it needs to hold it, so they can both call close independently.
+ *
+ * <p>Usage: <code>
+ * try (CloseableUri fdUri = storage.open(uri, NativeReadOpener.create())) {
+ *   // Use URI in native code
+ * }
+ * </code>
+ */
+public final class NativeReadOpener implements Opener<CloseableUri> {
+  private NativeReadOpener() {}
+
+  public static NativeReadOpener create() {
+    return new NativeReadOpener();
+  }
+
+  @Override
+  public CloseableUri open(OpenContext openContext) throws IOException {
+    Pair<Uri, Closeable> result = openContext.backend().openForNativeRead(openContext.encodedUri());
+    Uri uriWithFragment =
+        result
+            .first
+            .buildUpon()
+            .encodedFragment(openContext.originalUri().getEncodedFragment())
+            .build();
+    return new CloseableUri() {
+      @Override
+      public Uri uri() {
+        return uriWithFragment;
+      }
+
+      @Override
+      public void close() throws IOException {
+        result.second.close();
+      }
+    };
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/openers/ParcelFileDescriptorOpener.java b/java/com/google/android/libraries/mobiledatadownload/file/openers/ParcelFileDescriptorOpener.java
new file mode 100644
index 0000000..5125ca6
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/openers/ParcelFileDescriptorOpener.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import android.net.Uri;
+import android.os.ParcelFileDescriptor;
+import android.util.Pair;
+import com.google.android.libraries.mobiledatadownload.file.OpenContext;
+import com.google.android.libraries.mobiledatadownload.file.Opener;
+import com.google.android.libraries.mobiledatadownload.file.backends.FileDescriptorUri;
+import com.google.android.libraries.mobiledatadownload.file.common.UnsupportedFileStorageOperation;
+import java.io.Closeable;
+import java.io.IOException;
+
+/**
+ * Opener that returns an ParcelFileDescriptor. Caller must close the ParcelFileDescriptor when done
+ * reading from it. Does not support Monitors or Transforms.
+ */
+public final class ParcelFileDescriptorOpener implements Opener<ParcelFileDescriptor> {
+
+  private ParcelFileDescriptorOpener() {}
+
+  public static ParcelFileDescriptorOpener create() {
+    return new ParcelFileDescriptorOpener();
+  }
+
+  @Override
+  public ParcelFileDescriptor open(OpenContext openContext) throws IOException {
+    Pair<Uri, Closeable> result = openContext.backend().openForNativeRead(openContext.encodedUri());
+    try {
+      if (openContext.hasTransforms()) {
+        throw new UnsupportedFileStorageOperation(
+            "Accessing file descriptor directly would skip transforms for "
+                + openContext.originalUri());
+      }
+
+      int nativeFd = FileDescriptorUri.getFd(result.first);
+      // NOTE: Could also use adoptFd to avoid dup and closing original, but it's slightly
+      // cleaner this way to ensure that we cannot leak file descriptors when exception is thrown.
+      // TODO(b/115933017): consider wrapping the PFD to force it to implement Closeable on all sdks
+      return ParcelFileDescriptor.fromFd(nativeFd);
+    } finally {
+      result.second.close();
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/openers/Pipes.java b/java/com/google/android/libraries/mobiledatadownload/file/openers/Pipes.java
new file mode 100644
index 0000000..db46e84
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/openers/Pipes.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import android.annotation.TargetApi;
+import android.os.Build;
+import android.os.Process;
+import android.system.ErrnoException;
+import android.system.Os;
+import android.system.OsConstants;
+import com.google.android.libraries.mobiledatadownload.file.common.UnsupportedFileStorageOperation;
+import java.io.File;
+import java.io.IOException;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/** Static utility methods pertaining to Posix pipes and streaming data through them. */
+final class Pipes {
+
+  private static final int MY_PID = Process.myPid();
+
+  /**
+   * Creates a named pipe (FIFO) in {@code directory} that's guaranteed to not collide with any
+   * other running process. {@code tag} and {@code idGenerator} should be static and specific to the
+   * caller's class in order to avoid naming collisions with other callers within the same process.
+   *
+   * @throws IOException if the FIFO cannot be created or is not supported at this SDK level (21+)
+   */
+  @TargetApi(Build.VERSION_CODES.LOLLIPOP) // for ErrnoException, Os, and OsConstants
+  static File makeFifo(File directory, String tag, AtomicInteger idGenerator) throws IOException {
+    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
+      throw new UnsupportedFileStorageOperation(
+          String.format("FIFOs require SDK level 21+; current level is %d", Build.VERSION.SDK_INT));
+    }
+    int fifoId = idGenerator.getAndIncrement();
+    String fifoName = ".mobstore-" + tag + "-" + MY_PID + "-" + fifoId + ".fifo";
+    File fifoFile = new File(directory, fifoName);
+    fifoFile.delete(); // Delete stale FIFO if it exists (it shouldn't)
+    try {
+      Os.mkfifo(fifoFile.getAbsolutePath(), OsConstants.S_IRUSR | OsConstants.S_IWUSR);
+      return fifoFile;
+    } catch (ErrnoException e) {
+      fifoFile.delete();
+      throw new IOException(e);
+    }
+  }
+
+  /**
+   * Blocks on {@code pumpFuture.get()} and propagates any exception it may have encountered as an
+   * {@code IOException}.
+   */
+  static void getAndPropagateAsIOException(Future<Throwable> pumpFuture) throws IOException {
+    try {
+      Throwable throwable = pumpFuture.get();
+      if (throwable != null) {
+        if (throwable instanceof IOException) {
+          throw (IOException) throwable;
+        }
+        throw new IOException(throwable);
+      }
+    } catch (InterruptedException e) {
+      Thread.currentThread().interrupt(); // per <internal>
+      throw new IOException(e);
+    } catch (ExecutionException e) {
+      if (e.getCause() instanceof IOException) {
+        throw (IOException) e.getCause();
+      }
+      throw new IOException(e);
+    }
+  }
+
+  private Pipes() {}
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/openers/RandomAccessFileOpener.java b/java/com/google/android/libraries/mobiledatadownload/file/openers/RandomAccessFileOpener.java
new file mode 100644
index 0000000..25e1839
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/openers/RandomAccessFileOpener.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import com.google.android.libraries.mobiledatadownload.file.OpenContext;
+import com.google.android.libraries.mobiledatadownload.file.Opener;
+import com.google.common.io.Files;
+import java.io.File;
+import java.io.IOException;
+import java.io.RandomAccessFile;
+
+/** Opener that returns a RandomAccessFile. */
+public final class RandomAccessFileOpener implements Opener<RandomAccessFile> {
+
+  private final boolean writeSupport;
+
+  /**
+   * If writeSupport is false, the RAF is read only. If writeSupport is enabled, the RAF is read and
+   * write, and the directory is created if necessary.
+   */
+  private RandomAccessFileOpener(boolean writeSupport) {
+    this.writeSupport = writeSupport;
+  }
+
+  public static RandomAccessFileOpener createForRead() {
+    return new RandomAccessFileOpener(/*writeSupport=*/ false);
+  }
+
+  /**
+   * Create an opener that returns a RandomAccessFile opened for read and write. If the File or its
+   * parent directories do not exist, they will be created.
+   */
+  public static RandomAccessFileOpener createForReadWrite() {
+    return new RandomAccessFileOpener(/*writeSupport=*/ true);
+  }
+
+  @Override
+  public RandomAccessFile open(OpenContext openContext) throws IOException {
+    if (writeSupport) {
+      ReadFileOpener readFileOpener = ReadFileOpener.create().withShortCircuit();
+      File file = readFileOpener.open(openContext);
+      Files.createParentDirs(file);
+      return new RandomAccessFile(file, "rw");
+    } else /* if (!writeSupport) */ {
+      ReadFileOpener readFileOpener = ReadFileOpener.create();
+      File file = readFileOpener.open(openContext);
+      return new RandomAccessFile(file, "r");
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/openers/ReadByteArrayOpener.java b/java/com/google/android/libraries/mobiledatadownload/file/openers/ReadByteArrayOpener.java
new file mode 100644
index 0000000..12d03b0
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/openers/ReadByteArrayOpener.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import com.google.android.libraries.mobiledatadownload.file.OpenContext;
+import com.google.android.libraries.mobiledatadownload.file.Opener;
+import com.google.android.libraries.mobiledatadownload.file.common.Sizable;
+import com.google.android.libraries.mobiledatadownload.file.common.UnsupportedFileStorageOperation;
+import com.google.common.io.ByteStreams;
+import com.google.common.primitives.Ints;
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * An opener that returns a byte[] for a Uri. Attempts to get the file size so it can perform a
+ * single allocation, but falls back to a dynamic allocation when the size is unknown.
+ *
+ * <p>Warning: Large memory allocations can OOM. We recommend evaluating reliability and performance
+ * on target platforms when allocating > 1M.
+ *
+ * <p>Usage: <code>
+ * byte[] bytes = storage.open(uri, ReadByteArrayOpener.create());
+ * </code>
+ */
+public final class ReadByteArrayOpener implements Opener<byte[]> {
+
+  private ReadByteArrayOpener() {}
+
+  /** Creates a new opener instance to read a byte array. */
+  public static ReadByteArrayOpener create() {
+    return new ReadByteArrayOpener();
+  }
+
+  @Override
+  public byte[] open(OpenContext openContext) throws IOException {
+    try (InputStream in = ReadStreamOpener.create().open(openContext)) {
+      Long size = null;
+      // Try to get the length from the Sizable interface. This can potentially work with
+      // monitors and transforms that do not change the file size, or do so in a way that
+      // supports efficient calculation of the logical size (eg file size - header size = logical
+      // size).
+      if (in instanceof Sizable) {
+        size = ((Sizable) in).size();
+      }
+
+      // If Sizable failed and there are not transforms that could manipulate the file size,
+      // then try calling fileSize().
+      if (size == null && !openContext.hasTransforms()) {
+        try {
+          long fileSize = openContext.storage().fileSize(openContext.originalUri());
+          if (fileSize > 0) {
+            // Treat 0 as "unknown file size".
+            size = fileSize;
+          }
+        } catch (UnsupportedFileStorageOperation ex) {
+          // Ignore.
+        }
+      }
+
+      if (size == null) {
+        // Bummer. Read stream of unknown length. Inefficient but always works.
+        return ByteStreams.toByteArray(in);
+      }
+
+      byte[] bytes = new byte[Ints.checkedCast(size)];
+      ByteStreams.readFully(in, bytes);
+      return bytes;
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/openers/ReadFileOpener.java b/java/com/google/android/libraries/mobiledatadownload/file/openers/ReadFileOpener.java
new file mode 100644
index 0000000..a02b9bb
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/openers/ReadFileOpener.java
@@ -0,0 +1,165 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import android.content.Context;
+import android.util.Log;
+import androidx.annotation.VisibleForTesting;
+import com.google.android.libraries.mobiledatadownload.file.OpenContext;
+import com.google.android.libraries.mobiledatadownload.file.Opener;
+import com.google.android.libraries.mobiledatadownload.file.common.FileConvertible;
+import com.google.android.libraries.mobiledatadownload.file.common.ReleasableResource;
+import com.google.android.libraries.mobiledatadownload.file.common.UnsupportedFileStorageOperation;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicInteger;
+import javax.annotation.Nullable;
+
+/**
+ * Opener for reading data from a {@link java.io.File} object. Depending on the backend, this may
+ * return...
+ *
+ * <ol>
+ *   <li>The simple posix path.
+ *   <li>A path to a FIFO (named pipe) from which data can be streamed.
+ * </ol>
+ *
+ * Note that the second option is disabled by default, and must be turned on with {@link
+ * #withFallbackToPipeUsingExecutor}.
+ *
+ * <p>Usage: <code>
+ * File file = storage.open(uri,
+ *    ReadFileOpener.create().withFallbackToPipeUsingExecutor(executor, context)));
+ * try (FileInputStream in = new FileInputStream(file)) {
+ *   // Read file
+ * }
+ * </code>
+ */
+public final class ReadFileOpener implements Opener<File> {
+
+  private static final String TAG = "ReadFileOpener";
+  private static final int STREAM_BUFFER_SIZE = 4096;
+  private static final AtomicInteger FIFO_COUNTER = new AtomicInteger();
+
+  @Nullable private ExecutorService executor;
+  @Nullable private Context context;
+  @Nullable private Future<Throwable> pumpFuture;
+  private boolean shortCircuit = false;
+
+  private ReadFileOpener() {}
+
+  public static ReadFileOpener create() {
+    return new ReadFileOpener();
+  }
+
+  /**
+   * If enabled, still try to return a raw file path but, if that fails, return a FIFO (aka named
+   * pipe) from which the data can be consumed as a stream. Raw file paths are not available if
+   * there are any transforms installed; if there are any monitors installed; or if the backend
+   * lacks such support.
+   *
+   * <p>The caller MUST open the returned file in order to avoid a thread leak. It may only open it
+   * once.
+   *
+   * <p>The caller may block on {@link #waitForPump} and handle any exceptions in order to monitor
+   * failures.
+   *
+   * <p>WARNING: FIFOs require SDK level 21+ (Lollipop). If the raw file path is unavailable and the
+   * current SDK level is insufficient for FIFOs, the fallback will fail (throw IOException).
+   *
+   * @param executor Executor for pump threads.
+   * @param context Android context for the root directory where fifos are stored.
+   * @return This opener.
+   */
+  public ReadFileOpener withFallbackToPipeUsingExecutor(ExecutorService executor, Context context) {
+    this.executor = executor;
+    this.context = context;
+    return this;
+  }
+
+  /**
+   * If enabled, will ONLY attempt to convert the URI to a path using string processing. Fails if
+   * there are any transforms enabled. This is like the {@link UriAdapter} interface, but with more
+   * guard rails to make it safe to expose publicly.
+   */
+  public ReadFileOpener withShortCircuit() {
+    this.shortCircuit = true;
+    return this;
+  }
+
+  @Override
+  public File open(OpenContext openContext) throws IOException {
+    if (shortCircuit) {
+      if (openContext.hasTransforms()) {
+        throw new UnsupportedFileStorageOperation("Short circuit would skip transforms.");
+      }
+      return openContext.backend().toFile(openContext.encodedUri());
+    }
+
+    try (ReleasableResource<InputStream> in =
+        ReleasableResource.create(ReadStreamOpener.create().open(openContext))) {
+      // TODO(b/118888044): FileConvertible probably can be deprecated.
+      if (in.get() instanceof FileConvertible) {
+        return ((FileConvertible) in.get()).toFile();
+      }
+      if (executor != null) {
+        return pipeToFile(in.release());
+      }
+      throw new IOException("Not convertible and fallback to pipe is disabled.");
+    }
+  }
+
+  /** Wait for pump and propagate any exceptions it may have encountered. */
+  @VisibleForTesting
+  void waitForPump() throws IOException {
+    Pipes.getAndPropagateAsIOException(pumpFuture);
+  }
+
+  private File pipeToFile(InputStream in) throws IOException {
+    File fifo = Pipes.makeFifo(context.getCacheDir(), TAG, FIFO_COUNTER);
+    pumpFuture =
+        executor.submit(
+            () -> {
+              try (FileOutputStream out = new FileOutputStream(fifo)) {
+                // In order to reach this point, reader must have opened the FIFO, so it's ok
+                // to delete it.
+                fifo.delete();
+                byte[] tmp = new byte[STREAM_BUFFER_SIZE];
+                try {
+                  int len;
+                  while ((len = in.read(tmp)) != -1) {
+                    out.write(tmp, 0, len);
+                  }
+                  out.flush();
+                } finally {
+                  in.close();
+                }
+              } catch (IOException e) {
+                Log.w(TAG, "pump", e);
+                return e;
+              } catch (Throwable t) {
+                Log.e(TAG, "pump", t);
+                return t;
+              }
+              return null;
+            });
+    return fifo;
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/openers/ReadProtoOpener.java b/java/com/google/android/libraries/mobiledatadownload/file/openers/ReadProtoOpener.java
new file mode 100644
index 0000000..9762803
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/openers/ReadProtoOpener.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import com.google.android.libraries.mobiledatadownload.file.OpenContext;
+import com.google.android.libraries.mobiledatadownload.file.Opener;
+import com.google.protobuf.ExtensionRegistryLite;
+import com.google.protobuf.MessageLite;
+import com.google.protobuf.Parser;
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * Opener for reading Protocol Buffers from file. It is based on the Java Proto Lite implementation;
+ * see <internal> for more information. Note that this opener reads the entire stream into a single
+ * message, meaning that delimited files are not supported. If the byte contents of the Uri cannot
+ * be parsed as a valid protocol message, throws {@link InvalidProtocolBufferException}.
+ *
+ * <p>Usage: <code>
+ * MyProto proto = storage.open(uri, ReadProtoOpener.create(MyProto.parser()));
+ * </code>
+ *
+ * <p>TODO(b/75909287): consider adding alternative implementation for multi-proto files
+ */
+public final class ReadProtoOpener<T extends MessageLite> implements Opener<T> {
+
+  private final Parser<T> parser;
+  private ExtensionRegistryLite registry = ExtensionRegistryLite.getEmptyRegistry();
+
+  private ReadProtoOpener(Parser<T> parser) {
+    this.parser = parser;
+  }
+
+  /** Creates a new opener instance that reads protos using the given {@code parser}. */
+  public static <T extends MessageLite> ReadProtoOpener<T> create(Parser<T> parser) {
+    return new ReadProtoOpener<T>(parser);
+  }
+
+  /**
+   * Creates a new opener instance that reads protos using the parser of the given {@code message}.
+   * This can be useful if the type of T is unavailable at compile time (i.e. it's a generic).
+   */
+  public static <T extends MessageLite> ReadProtoOpener<T> create(T message) {
+    @SuppressWarnings("unchecked") // safe cast because getParserForType API must return a Parser<T>
+    Parser<T> parser = (Parser<T>) message.getParserForType();
+    return new ReadProtoOpener<T>(parser);
+  }
+
+  /** Adds an extension registry used while parsing the proto. */
+  public ReadProtoOpener<T> withExtensionRegistry(ExtensionRegistryLite registry) {
+    this.registry = registry;
+    return this;
+  }
+
+  @Override
+  public T open(OpenContext openContext) throws IOException {
+    try (InputStream in = ReadStreamOpener.create().open(openContext)) {
+      return parser.parseFrom(in, registry);
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/openers/ReadStreamOpener.java b/java/com/google/android/libraries/mobiledatadownload/file/openers/ReadStreamOpener.java
new file mode 100644
index 0000000..94848ee
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/openers/ReadStreamOpener.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import com.google.android.libraries.mobiledatadownload.file.Behavior;
+import com.google.android.libraries.mobiledatadownload.file.OpenContext;
+import com.google.android.libraries.mobiledatadownload.file.Opener;
+import java.io.BufferedInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.List;
+
+/** An opener that returns a simple InputStream opened for read. */
+public final class ReadStreamOpener implements Opener<InputStream> {
+
+  private boolean bufferIo = false;
+  private Behavior[] behaviors;
+
+  private ReadStreamOpener() {}
+
+  public static ReadStreamOpener create() {
+    return new ReadStreamOpener();
+  }
+
+  public ReadStreamOpener withBehaviors(Behavior... behaviors) {
+    this.behaviors = behaviors;
+    return this;
+  }
+
+  /**
+   * If enabled, uses {@link BufferedInputStream} to buffer IO from the backend. Depending on the
+   * situation this can help or hurt performance, so please contact <internal> before using it.
+   *
+   * <p>Encouraged: zip files.
+   *
+   * <p>Discouraged: protos (already buffered internally).
+   */
+  public ReadStreamOpener withBufferedIo() {
+    this.bufferIo = true;
+    return this;
+  }
+
+  @Override
+  public InputStream open(OpenContext openContext) throws IOException {
+    InputStream backendInput = openContext.backend().openForRead(openContext.encodedUri());
+    if (bufferIo) {
+      backendInput = new BufferedInputStream(backendInput);
+    }
+    List<InputStream> chain = openContext.chainTransformsForRead(backendInput);
+    if (behaviors != null) {
+      for (Behavior behavior : behaviors) {
+        behavior.forInputChain(chain);
+      }
+    }
+    return chain.get(0);
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/openers/ReadStringOpener.java b/java/com/google/android/libraries/mobiledatadownload/file/openers/ReadStringOpener.java
new file mode 100644
index 0000000..ff434aa
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/openers/ReadStringOpener.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import com.google.android.libraries.mobiledatadownload.file.OpenContext;
+import com.google.android.libraries.mobiledatadownload.file.Opener;
+import com.google.android.libraries.mobiledatadownload.file.common.internal.Charsets;
+import java.io.IOException;
+import java.nio.charset.Charset;
+
+/** An opener that reads entire contents of file into a String. */
+public final class ReadStringOpener implements Opener<String> {
+  private Charset charset = Charsets.UTF_8;
+
+  protected ReadStringOpener() {}
+
+  public static ReadStringOpener create() {
+    return new ReadStringOpener();
+  }
+
+  public ReadStringOpener withCharset(Charset charset) {
+    this.charset = charset;
+    return this;
+  }
+
+  @Override
+  public String open(OpenContext openContext) throws IOException {
+    byte[] bytes = ReadByteArrayOpener.create().open(openContext);
+    return new String(bytes, charset);
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/openers/RecursiveDeleteOpener.java b/java/com/google/android/libraries/mobiledatadownload/file/openers/RecursiveDeleteOpener.java
new file mode 100644
index 0000000..80fe27f
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/openers/RecursiveDeleteOpener.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.OpenContext;
+import com.google.android.libraries.mobiledatadownload.file.Opener;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.common.internal.Exceptions;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Deletes the file or directory at the given URI recursively. This behaves similarly to {@link
+ * SynchronousFileStorage#deleteRecursively} except as described in the following paragraph.
+ *
+ * <p>If an IO exception occurs attempting to read, open, or delete any file under the given
+ * directory, this method skips that file and continues. All such exceptions are collected and,
+ * after attempting to delete all files, an {@code IOException} is thrown containing those
+ * exceptions as {@linkplain Throwable#getSuppressed() suppressed exceptions}.
+ *
+ * <p>WARNING: this opener suffers from the following caveats and should be used with caution:
+ *
+ * <ul>
+ *   <li>Directory tree traversal is not an atomic operation
+ *   <li>There are no special considerations for symlinks, meaning the opener could get caught in a
+ *       recursive directory loop (i.e. a directory that contains a symlink to itself)
+ * </ul>
+ *
+ * <p>Usage: <code>
+ * storage.open(uri, RecursiveDeleteOpener.create());
+ * </code>
+ */
+public final class RecursiveDeleteOpener implements Opener<Void> {
+  private RecursiveDeleteOpener() {}
+
+  public static RecursiveDeleteOpener create() {
+    return new RecursiveDeleteOpener();
+  }
+
+  @Override
+  public Void open(OpenContext openContext) throws IOException {
+    List<IOException> exceptions = new ArrayList<>();
+    deleteRecursively(openContext.storage(), openContext.encodedUri(), exceptions);
+    if (!exceptions.isEmpty()) {
+      throw Exceptions.combinedIOException("Failed to delete one or more files", exceptions);
+    }
+
+    return null; // for Void return type
+  }
+
+  private static void deleteRecursively(
+      SynchronousFileStorage storage, Uri uri, List<IOException> exceptions) {
+    try {
+      if (storage.isDirectory(uri)) {
+        for (Uri child : storage.children(uri)) {
+          deleteRecursively(storage, child, exceptions);
+        }
+        storage.deleteDirectory(uri);
+      } else {
+        storage.deleteFile(uri);
+      }
+    } catch (IOException e) {
+      exceptions.add(e);
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/openers/RecursiveSizeOpener.java b/java/com/google/android/libraries/mobiledatadownload/file/openers/RecursiveSizeOpener.java
new file mode 100644
index 0000000..3c6c244
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/openers/RecursiveSizeOpener.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.OpenContext;
+import com.google.android.libraries.mobiledatadownload.file.Opener;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.common.collect.Iterables;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.util.ArrayDeque;
+import java.util.Deque;
+
+/**
+ * Calculates the size of a directory by recursively summing the size of all of its children. If the
+ * Uri refers to an empty directory, returns 0. If the Uri refers to a file or does not exist,
+ * throws {@link FileNotFoundException}.
+ *
+ * <p>WARNING: this opener suffers from the following caveats and should be used with caution:
+ *
+ * <ul>
+ *   <li>Directory tree traversal is not an atomic operation
+ *   <li>There are no special considerations for symlinks, meaning the opener could get caught in a
+ *       recursive directory loop (i.e. a directory that contains a symlink to itself)
+ *   <li>Fails fast if there is an I/O error while processing a given child Uri
+ * </ul>
+ *
+ * <p>Usage: long size = storage.open(uri, RecursiveSizeOpener.create());
+ */
+public final class RecursiveSizeOpener implements Opener<Long> {
+  private RecursiveSizeOpener() {}
+
+  public static RecursiveSizeOpener create() {
+    return new RecursiveSizeOpener();
+  }
+
+  @Override
+  public Long open(OpenContext context) throws IOException {
+    long totalSize = 0;
+    Deque<Uri> toProcess = new ArrayDeque<>();
+    SynchronousFileStorage storage = context.storage();
+
+    // Stripping the Uri fragment means children filenames don't get encoded by transforms. This is
+    // intentional: we're simply calculating the total file size regardless of the "correct" names.
+    // Children API call throws FNF if the Uri is a file or does not exist.
+    Uri uriWithoutFragment = context.originalUri().buildUpon().fragment(null).build();
+    Iterables.addAll(toProcess, storage.children(uriWithoutFragment));
+
+    // NOTE: breadth-first traversal is an arbitrary implementation choice
+    while (!toProcess.isEmpty()) {
+      Uri uri = toProcess.remove();
+      if (storage.isDirectory(uri)) {
+        Iterables.addAll(toProcess, storage.children(uri));
+      } else if (storage.exists(uri)) {
+        totalSize += storage.fileSize(uri);
+      } else {
+        throw new FileNotFoundException(String.format("Child %s could not be opened", uri));
+      }
+    }
+
+    return totalSize;
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/openers/ScratchFile.java b/java/com/google/android/libraries/mobiledatadownload/file/openers/ScratchFile.java
new file mode 100644
index 0000000..ed7546b
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/openers/ScratchFile.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import android.net.Uri;
+import android.os.Process;
+import java.util.concurrent.atomic.AtomicLong;
+
+/** Utility for creating a temp file based on PID and ThreadID. */
+// TODO: consolidate with Pipes.java
+final class ScratchFile {
+
+  private static final AtomicLong SCRATCH_COUNTER = new AtomicLong();
+
+  static Uri scratchUri(Uri file) {
+    int process = Process.myPid();
+    long thread = Thread.currentThread().getId();
+    long timestamp = System.currentTimeMillis();
+    long count = SCRATCH_COUNTER.getAndIncrement();
+    String suffix = ".mobstore_tmp-" + process + "-" + thread + "-" + timestamp + "-" + count;
+    return file.buildUpon().path(file.getPath() + suffix).build();
+  }
+
+  private ScratchFile() {}
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/openers/StreamMutationOpener.java b/java/com/google/android/libraries/mobiledatadownload/file/openers/StreamMutationOpener.java
new file mode 100644
index 0000000..d26538f
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/openers/StreamMutationOpener.java
@@ -0,0 +1,165 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.Behavior;
+import com.google.android.libraries.mobiledatadownload.file.OpenContext;
+import com.google.android.libraries.mobiledatadownload.file.Opener;
+import java.io.ByteArrayInputStream;
+import java.io.Closeable;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.List;
+import javax.annotation.Nullable;
+
+/**
+ * An opener for updating a file atomically: does not modify the destination file until all of the
+ * data has been successfully written. Instead, it writes into a scratch file which it renames to
+ * the destination file once the data has been written successfully.
+ *
+ * <p>In order to implement isolation (preventing other processes from modifying this file during
+ * read-modify-write transaction), pass in a LockFileOpener instance to {@link #withLocking} call.
+ *
+ * <p>In order to implement durability (ensuring the data is in persistent storage), pass
+ * SyncBehavior to the original open call.
+ *
+ * <p>NOTE: This does not fsync the directory itself. See <internal> for possible implementation
+ * using NIO.
+ */
+public final class StreamMutationOpener implements Opener<StreamMutationOpener.Mutator> {
+
+  private Behavior[] behaviors;
+
+  /**
+   * Override this interface to implement the transformation. It is ok to read input and output in
+   * parallel. If an exception is thrown, execution stops and the destination file remains
+   * untouched.
+   */
+  public interface Mutation {
+    boolean apply(InputStream in, OutputStream out) throws IOException;
+  }
+
+  @Nullable private LockFileOpener locking = null;
+
+  private StreamMutationOpener() {}
+
+  /** Create an instance of this opener. */
+  public static StreamMutationOpener create() {
+    return new StreamMutationOpener();
+  }
+
+  /**
+   * Enable exclusive locking with this opener. This is useful if multiple processes or threads need
+   * to maintain transactional isolation.
+   */
+  public StreamMutationOpener withLocking(LockFileOpener locking) {
+    this.locking = locking;
+    return this;
+  }
+
+  /** Apply these behaviors while writing only. */
+  public StreamMutationOpener withBehaviors(Behavior... behaviors) {
+    this.behaviors = behaviors;
+    return this;
+  }
+
+  /** Open this URI for mutating. If the file does not exist, create it. */
+  @Override
+  public Mutator open(OpenContext openContext) throws IOException {
+    return new Mutator(openContext, locking, behaviors);
+  }
+
+  /** An intermediate result returned by this opener. */
+  public static final class Mutator implements Closeable {
+    private static final InputStream EMPTY_INPUTSTREAM = new ByteArrayInputStream(new byte[0]);
+    private final OpenContext openContext;
+    private final Closeable lock;
+    private final Behavior[] behaviors;
+
+    private Mutator(
+        OpenContext openContext, @Nullable LockFileOpener locking, @Nullable Behavior[] behaviors)
+        throws IOException {
+      this.openContext = openContext;
+      this.behaviors = behaviors;
+      if (locking != null) {
+        lock = locking.open(openContext);
+        if (lock == null) {
+          throw new IOException("Couldn't acquire lock");
+        }
+      } else {
+        lock = null;
+      }
+    }
+
+    public void mutate(Mutation mutation) throws IOException {
+      try (InputStream backendIn = openForReadOrEmpty(openContext.encodedUri());
+          InputStream in = openContext.chainTransformsForRead(backendIn).get(0)) {
+        Uri tempUri = ScratchFile.scratchUri(openContext.originalUri());
+        boolean commit = false;
+        try (OutputStream backendOut = openContext.backend().openForWrite(tempUri)) {
+          List<OutputStream> outputChain = openContext.chainTransformsForWrite(backendOut);
+          if (behaviors != null) {
+            for (Behavior behavior : behaviors) {
+              behavior.forOutputChain(outputChain);
+            }
+          }
+          try (OutputStream out = outputChain.get(0)) {
+            commit = mutation.apply(in, out);
+            if (commit) {
+              if (behaviors != null) {
+                for (Behavior behavior : behaviors) {
+                  behavior.commit();
+                }
+              }
+            }
+          }
+        } catch (Exception ex) {
+          try {
+            openContext.storage().deleteFile(tempUri);
+          } catch (FileNotFoundException ex2) {
+            // Ignore.
+          }
+          if (ex instanceof IOException) {
+            throw (IOException) ex;
+          }
+          throw new IOException(ex);
+        }
+        if (commit) {
+          openContext.storage().rename(tempUri, openContext.originalUri());
+        }
+      }
+    }
+
+    @Override
+    public void close() throws IOException {
+      if (lock != null) {
+        lock.close();
+      }
+    }
+
+    // Open the file for read if it's present, otherwise return an empty stream.
+    private InputStream openForReadOrEmpty(Uri uri) throws IOException {
+      try {
+        return openContext.backend().openForRead(uri);
+      } catch (FileNotFoundException ex) {
+        return EMPTY_INPUTSTREAM;
+      }
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/openers/SystemLibraryOpener.java b/java/com/google/android/libraries/mobiledatadownload/file/openers/SystemLibraryOpener.java
new file mode 100644
index 0000000..0f90b41
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/openers/SystemLibraryOpener.java
@@ -0,0 +1,110 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import android.annotation.SuppressLint;
+import android.net.Uri;
+import android.util.Base64;
+import com.google.android.libraries.mobiledatadownload.file.OpenContext;
+import com.google.android.libraries.mobiledatadownload.file.Opener;
+import com.google.common.io.ByteStreams;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import javax.annotation.Nullable;
+
+/**
+ * Opener for loading URIs as shared libraries.
+ *
+ * <p>In many cases, the URI cannot be loaded directly and must be copied to a cache directory
+ * first. Caller is responsible for ensuring that this cache directory is cleaned periodically, but
+ * they can do so using a MobStore URI with TTL, LRU, or other garbage collection method.
+ *
+ * <p>WARNING: This opener does no validation and assumes that the data it receives is good. Note
+ * that MobStore can validate a checksum present in the URI.
+ *
+ * <p>TODO Consider requiring validation of checksum.
+ *
+ * <p>Usage: <code>
+ * storage.open(uri, SystemLibraryOpener.create().withCacheDirectory(cacheRootUri))
+ * </code>
+ */
+public final class SystemLibraryOpener implements Opener<Void> {
+
+  @Nullable private Uri cacheDirectory;
+
+  private SystemLibraryOpener() {}
+
+  public SystemLibraryOpener withCacheDirectory(Uri dir) {
+    this.cacheDirectory = dir;
+    return this;
+  }
+
+  public static SystemLibraryOpener create() {
+    return new SystemLibraryOpener();
+  }
+
+  @Override
+  @SuppressLint("UnsafeDynamicallyLoadedCode") // System.load is needed to load from arbitrary Uris
+  public Void open(OpenContext openContext) throws IOException {
+    File file = null;
+    try {
+      // NOTE: could be backend().openFile() if we added to Backend interface.
+      file = ReadFileOpener.create().open(openContext);
+      System.load(file.getAbsolutePath());
+    } catch (IOException e) {
+      if (cacheDirectory == null) {
+        throw new IOException("Cannot directly open file and no cache directory available.");
+      }
+      Uri cachedUri =
+          cacheDirectory
+              .buildUpon()
+              .appendPath(hashedLibraryName(openContext.originalUri()))
+              .build();
+      try {
+        file = openContext.storage().open(cachedUri, ReadFileOpener.create());
+        System.load(file.getAbsolutePath());
+      } catch (FileNotFoundException e2) {
+        // NOTE: this could be extracted as CopyOpener if the need arises
+        try (InputStream from =
+                openContext.storage().open(openContext.originalUri(), ReadStreamOpener.create());
+            OutputStream to = openContext.storage().open(cachedUri, WriteStreamOpener.create())) {
+          ByteStreams.copy(from, to);
+          to.flush();
+        }
+        file = openContext.storage().open(cachedUri, ReadFileOpener.create());
+        System.load(file.getAbsolutePath());
+      }
+    }
+    return null; // Required by Void return type.
+  }
+
+  private static String hashedLibraryName(Uri uri) {
+    try {
+      MessageDigest digest = MessageDigest.getInstance("MD5");
+      byte[] bytes = digest.digest(uri.toString().getBytes());
+      String hash = Base64.encodeToString(bytes, Base64.NO_WRAP | Base64.URL_SAFE);
+      return ".mobstore-lib." + hash + ".so";
+    } catch (NoSuchAlgorithmException e) {
+      // Unreachable.
+      throw new RuntimeException("Missing MD5 algorithm implementation", e);
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/openers/WriteByteArrayOpener.java b/java/com/google/android/libraries/mobiledatadownload/file/openers/WriteByteArrayOpener.java
new file mode 100644
index 0000000..4676e7e
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/openers/WriteByteArrayOpener.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import com.google.android.libraries.mobiledatadownload.file.Behavior;
+import com.google.android.libraries.mobiledatadownload.file.OpenContext;
+import com.google.android.libraries.mobiledatadownload.file.Opener;
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * An opener that writes a byte[] of data for a Uri.
+ *
+ * <p>Usage: <code>
+ * storage.open(uri, WriteByteArrayOpener.create(bytes));
+ * </code>
+ */
+public final class WriteByteArrayOpener implements Opener<Void> {
+
+  private final byte[] bytesToWrite;
+  private Behavior[] behaviors;
+
+  private WriteByteArrayOpener(byte[] bytesToWrite) {
+    this.bytesToWrite = bytesToWrite;
+  }
+
+  public WriteByteArrayOpener withBehaviors(Behavior... behaviors) {
+    this.behaviors = behaviors;
+    return this;
+  }
+
+  /** Creates a new opener instance to write a byte array. */
+  public static WriteByteArrayOpener create(byte[] bytesToWrite) {
+    return new WriteByteArrayOpener(bytesToWrite);
+  }
+
+  @Override
+  public Void open(OpenContext openContext) throws IOException {
+    try (OutputStream out = WriteStreamOpener.create().withBehaviors(behaviors).open(openContext)) {
+      out.write(bytesToWrite);
+      if (behaviors != null) {
+        for (Behavior behavior : behaviors) {
+          behavior.commit();
+        }
+      }
+    }
+    return null; // Required by Void return type.
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/openers/WriteFileOpener.java b/java/com/google/android/libraries/mobiledatadownload/file/openers/WriteFileOpener.java
new file mode 100644
index 0000000..c930f11
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/openers/WriteFileOpener.java
@@ -0,0 +1,203 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import android.content.Context;
+import android.util.Log;
+import com.google.android.libraries.mobiledatadownload.file.OpenContext;
+import com.google.android.libraries.mobiledatadownload.file.Opener;
+import com.google.android.libraries.mobiledatadownload.file.common.FileConvertible;
+import com.google.android.libraries.mobiledatadownload.file.common.ReleasableResource;
+import com.google.android.libraries.mobiledatadownload.file.openers.WriteFileOpener.FileCloser;
+import java.io.Closeable;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicInteger;
+import javax.annotation.Nullable;
+
+/**
+ * Opener for writing data to a {@link java.io.File} object. Depending on the backend, this may work
+ * one of three ways,
+ *
+ * <ol>
+ *   <li>The simple posix path.
+ *   <li>A /proc/self/fd/ path referring to a file descriptor for the original file.
+ *   <li>A path to a FIFO (named pipe) to which data can be written.
+ * </ol>
+ *
+ * Note that the third option is disabled by default, and must be turned on with {@link
+ * #withFallbackToPipeUsingExecutor}.
+ *
+ * <p>Usage: <code>
+ * try (WriteFileOpener.FileCloser closer =
+ *     storage.open(
+ *         uri, WriteFileOpener.create().withFallbackToPipeUsingExecutor(executor, context))) {
+ *   // Write to closer.file()
+ * }
+ * </code>
+ */
+public final class WriteFileOpener implements Opener<FileCloser> {
+
+  private static final String TAG = "WriteFileOpener";
+  private static final int STREAM_BUFFER_SIZE = 4096;
+  private static final AtomicInteger FIFO_COUNTER = new AtomicInteger();
+
+  /** A file, closeable pair. */
+  public interface FileCloser extends Closeable {
+    File file();
+  }
+
+  /** A FileCloser that contains a stream. */
+  private static class StreamFileCloser implements FileCloser {
+
+    private final File file;
+    private final OutputStream stream;
+
+    StreamFileCloser(File file, OutputStream stream) {
+      this.file = file;
+      this.stream = stream;
+    }
+
+    @Override
+    public File file() {
+      return file;
+    }
+
+    @Override
+    public void close() throws IOException {
+      stream.close();
+    }
+  }
+
+  /** A FileCloser that contains a named pipe and a future to the thread pumping data through it. */
+  private static class PipeFileCloser implements FileCloser {
+
+    private final File fifo;
+    private final Future<Throwable> pumpFuture;
+
+    PipeFileCloser(File fifo, Future<Throwable> pumpFuture) {
+      this.fifo = fifo;
+      this.pumpFuture = pumpFuture;
+    }
+
+    @Override
+    public File file() {
+      return fifo;
+    }
+
+    /**
+     * Closes the wrapped file and any associated system resources. This method will block on system
+     * IO if the file is piped and there is remaining data to be written to the stream.
+     *
+     * @throws IOException
+     */
+    @Override
+    public void close() throws IOException {
+      // If the pipe's write-side was never opened, open it in order to unblock the pump thread.
+      // Otherwise, this is harmless to the existing stream.
+      try (FileOutputStream unused = new FileOutputStream(fifo)) {
+        // Do nothing.
+      } catch (IOException e) {
+        Log.w(TAG, "close() threw exception when trying to unblock pump", e);
+      } finally {
+        fifo.delete();
+      }
+      Pipes.getAndPropagateAsIOException(pumpFuture);
+    }
+  }
+
+  @Nullable private ExecutorService executor;
+  @Nullable private Context context;
+
+  private WriteFileOpener() {}
+
+  public static WriteFileOpener create() {
+    return new WriteFileOpener();
+  }
+
+  /**
+   * If enabled, still try to return a raw file path but, if that fails, return a FIFO (aka named
+   * pipe) to which the data can be written as a stream. Raw file paths are not available if there
+   * are any transforms installed; if there are any monitors installed; or if the backend lacks such
+   * support.
+   *
+   * <p>The caller MUST close the returned closeable in order to avoid a possible thread leak.
+   *
+   * <p>WARNING: FIFOs require SDK level 21+ (Lollipop). If the raw file path is unavailable and the
+   * current SDK level is insufficient for FIFOs, the fallback will fail (throw IOException).
+   *
+   * @param executor Executor that pumps data.
+   * @param context Android context for the root directory where fifos are stored.
+   * @return This opener.
+   */
+  public WriteFileOpener withFallbackToPipeUsingExecutor(
+      ExecutorService executor, Context context) {
+    this.executor = executor;
+    this.context = context;
+    return this;
+  }
+
+  @Override
+  public FileCloser open(OpenContext openContext) throws IOException {
+    try (ReleasableResource<OutputStream> out =
+        ReleasableResource.create(WriteStreamOpener.create().open(openContext))) {
+      if (out.get() instanceof FileConvertible) {
+        File file = ((FileConvertible) out.get()).toFile();
+        return new StreamFileCloser(file, out.release());
+      }
+      if (executor != null) {
+        return pipeFromFile(out.release());
+      }
+      throw new IOException("Not convertible and fallback to pipe is disabled.");
+    }
+  }
+
+  private FileCloser pipeFromFile(OutputStream out) throws IOException {
+    File fifo = Pipes.makeFifo(context.getCacheDir(), TAG, FIFO_COUNTER);
+    Future<Throwable> future =
+        executor.submit(
+            () -> {
+              try (FileInputStream in = new FileInputStream(fifo)) {
+                // In order to reach this point, writer must have opened the FIFO, so it's ok
+                // to delete it.
+                fifo.delete();
+                byte[] tmp = new byte[STREAM_BUFFER_SIZE];
+                try {
+                  int len;
+                  while ((len = in.read(tmp)) != -1) {
+                    out.write(tmp, 0, len);
+                  }
+                  out.flush();
+                } finally {
+                  out.close();
+                }
+              } catch (IOException e) {
+                Log.w(TAG, "pump", e);
+                return e;
+              } catch (Throwable t) {
+                Log.e(TAG, "pump", t);
+                return t;
+              }
+              return null;
+            });
+    return new PipeFileCloser(fifo, future);
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/openers/WriteProtoOpener.java b/java/com/google/android/libraries/mobiledatadownload/file/openers/WriteProtoOpener.java
new file mode 100644
index 0000000..81f3eb6
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/openers/WriteProtoOpener.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.Behavior;
+import com.google.android.libraries.mobiledatadownload.file.OpenContext;
+import com.google.android.libraries.mobiledatadownload.file.Opener;
+import com.google.protobuf.MessageLite;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.List;
+
+/** Writes a proto to a file atomically. */
+public final class WriteProtoOpener implements Opener<Void> {
+
+  private final MessageLite proto;
+  private Behavior[] behaviors;
+
+  private WriteProtoOpener(MessageLite proto) {
+    this.proto = proto;
+  }
+
+  public static WriteProtoOpener create(MessageLite proto) {
+    return new WriteProtoOpener(proto);
+  }
+
+  /**
+   * Supports adding options to writes. For example, SyncBehavior will force data to be flushed and
+   * durably persisted.
+   */
+  public WriteProtoOpener withBehaviors(Behavior... behaviors) {
+    this.behaviors = behaviors;
+    return this;
+  }
+
+  @Override
+  public Void open(OpenContext openContext) throws IOException {
+    Uri tempUri = ScratchFile.scratchUri(openContext.encodedUri());
+    OutputStream backendOutput = openContext.backend().openForWrite(tempUri);
+    List<OutputStream> chain = openContext.chainTransformsForWrite(backendOutput);
+    if (behaviors != null) {
+      for (Behavior behavior : behaviors) {
+        behavior.forOutputChain(chain);
+      }
+    }
+    try (OutputStream out = chain.get(0)) {
+      proto.writeTo(out);
+      if (behaviors != null) {
+        for (Behavior behavior : behaviors) {
+          behavior.commit();
+        }
+      }
+    } catch (Exception ex) {
+      try {
+        openContext.backend().deleteFile(tempUri);
+      } catch (FileNotFoundException ex2) {
+        // Ignore.
+      }
+      if (ex instanceof IOException) {
+        throw (IOException) ex;
+      }
+      throw new IOException(ex);
+    }
+    openContext.backend().rename(tempUri, openContext.encodedUri());
+    return null;
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/openers/WriteStreamOpener.java b/java/com/google/android/libraries/mobiledatadownload/file/openers/WriteStreamOpener.java
new file mode 100644
index 0000000..f6e6c37
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/openers/WriteStreamOpener.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import com.google.android.libraries.mobiledatadownload.file.Behavior;
+import com.google.android.libraries.mobiledatadownload.file.OpenContext;
+import com.google.android.libraries.mobiledatadownload.file.Opener;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.List;
+
+/**
+ * An opener that returns a simple OutputStream opened for write. Any existing content is truncated.
+ */
+public final class WriteStreamOpener implements Opener<OutputStream> {
+
+  private Behavior[] behaviors;
+
+  private WriteStreamOpener() {}
+
+  public static WriteStreamOpener create() {
+    return new WriteStreamOpener();
+  }
+
+  public WriteStreamOpener withBehaviors(Behavior... behaviors) {
+    this.behaviors = behaviors;
+    return this;
+  }
+
+  @Override
+  public OutputStream open(OpenContext openContext) throws IOException {
+    OutputStream backendOutput = openContext.backend().openForWrite(openContext.encodedUri());
+    List<OutputStream> chain = openContext.chainTransformsForWrite(backendOutput);
+    if (behaviors != null) {
+      for (Behavior behavior : behaviors) {
+        behavior.forOutputChain(chain);
+      }
+    }
+    return chain.get(0);
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/openers/WriteStringOpener.java b/java/com/google/android/libraries/mobiledatadownload/file/openers/WriteStringOpener.java
new file mode 100644
index 0000000..9c2a98c
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/openers/WriteStringOpener.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import com.google.android.libraries.mobiledatadownload.file.Behavior;
+import com.google.android.libraries.mobiledatadownload.file.OpenContext;
+import com.google.android.libraries.mobiledatadownload.file.Opener;
+import com.google.android.libraries.mobiledatadownload.file.common.internal.Charsets;
+import java.io.IOException;
+import java.nio.charset.Charset;
+
+/** An opener that reads entire contents of file into a String. */
+public final class WriteStringOpener implements Opener<Void> {
+  private final String string;
+  private Charset charset = Charsets.UTF_8;
+  private Behavior[] behaviors;
+
+  WriteStringOpener(String string) {
+    this.string = string;
+  }
+
+  public static WriteStringOpener create(String string) {
+    return new WriteStringOpener(string);
+  }
+
+  public WriteStringOpener withCharset(Charset charset) {
+    this.charset = charset;
+    return this;
+  }
+
+  public WriteStringOpener withBehaviors(Behavior... behaviors) {
+    this.behaviors = behaviors;
+    return this;
+  }
+
+  @Override
+  public Void open(OpenContext openContext) throws IOException {
+    WriteByteArrayOpener.create(string.getBytes(charset))
+        .withBehaviors(behaviors)
+        .open(openContext);
+    return null;
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/samples/BUILD b/java/com/google/android/libraries/mobiledatadownload/file/samples/BUILD
new file mode 100644
index 0000000..0d21230
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/samples/BUILD
@@ -0,0 +1,35 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_library")
+
+package(
+    default_visibility = ["//:__subpackages__"],
+    licenses = ["notice"],
+)
+
+android_library(
+    name = "samples",
+    srcs = glob(
+        ["*.java"],
+        exclude = ["DemoActivity.java"],
+    ),
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/internal:charsets",
+        "//java/com/google/android/libraries/mobiledatadownload/file/spi",
+        "@androidx_appcompat_appcompat",  # buildcleaner: keep
+        "@com_google_guava_guava",
+    ],
+)
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/samples/Base64Backend.java b/java/com/google/android/libraries/mobiledatadownload/file/samples/Base64Backend.java
new file mode 100644
index 0000000..f65de0b
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/samples/Base64Backend.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.samples;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.backends.JavaFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.spi.Backend;
+import com.google.android.libraries.mobiledatadownload.file.spi.ForwardingBackend;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+/** An example backend that wraps another backend and a transform. Encodes all data as base64. */
+public class Base64Backend extends ForwardingBackend {
+  private final Base64Transform base64 = new Base64Transform();
+  private final Backend javaBackend = new JavaFileBackend();
+
+  @Override
+  protected Backend delegate() {
+    return javaBackend;
+  }
+
+  @Override
+  public String name() {
+    return "base64";
+  }
+
+  @Override
+  public InputStream openForRead(Uri uri) throws IOException {
+    return base64.wrapForRead(uri, super.openForRead(uri));
+  }
+
+  @Override
+  public OutputStream openForWrite(Uri uri) throws IOException {
+    return base64.wrapForWrite(uri, super.openForWrite(uri));
+  }
+
+  @Override
+  public OutputStream openForAppend(Uri uri) throws IOException {
+    return base64.wrapForAppend(uri, super.openForAppend(uri));
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/samples/Base64Transform.java b/java/com/google/android/libraries/mobiledatadownload/file/samples/Base64Transform.java
new file mode 100644
index 0000000..a1f1641
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/samples/Base64Transform.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.samples;
+
+import static com.google.android.libraries.mobiledatadownload.file.common.internal.Charsets.US_ASCII;
+import static com.google.android.libraries.mobiledatadownload.file.common.internal.Charsets.UTF_8;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.common.UnsupportedFileStorageOperation;
+import com.google.android.libraries.mobiledatadownload.file.spi.Transform;
+import com.google.common.io.BaseEncoding;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.Reader;
+import java.io.Writer;
+
+/** A demo transform that encodes/decodes to base64. */
+public final class Base64Transform implements Transform {
+
+  @Override
+  public String name() {
+    return "base64";
+  }
+
+  @Override
+  public InputStream wrapForRead(Uri uri, InputStream wrapped) throws IOException {
+    Reader reader = new InputStreamReader(wrapped, US_ASCII.newDecoder());
+    return BaseEncoding.base64().decodingStream(reader);
+  }
+
+  @Override
+  public OutputStream wrapForWrite(Uri uri, OutputStream wrapped) throws IOException {
+    Writer writer = new OutputStreamWriter(wrapped, US_ASCII.newEncoder());
+    return BaseEncoding.base64().encodingStream(writer);
+  }
+
+  @Override
+  public OutputStream wrapForAppend(Uri uri, OutputStream wrapped) throws IOException {
+    throw new UnsupportedFileStorageOperation("Cannot append to encoded file because of padding.");
+  }
+
+  @Override
+  public String encode(Uri uri, String filename) {
+    return BaseEncoding.base64().encode(filename.getBytes());
+  }
+
+  @Override
+  public String decode(Uri uri, String filename) {
+    return new String(BaseEncoding.base64().decode(filename), UTF_8);
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/samples/ByteCountingMonitor.java b/java/com/google/android/libraries/mobiledatadownload/file/samples/ByteCountingMonitor.java
new file mode 100644
index 0000000..facf9c8
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/samples/ByteCountingMonitor.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.samples;
+
+import android.net.Uri;
+import androidx.annotation.VisibleForTesting;
+import com.google.android.libraries.mobiledatadownload.file.spi.Monitor;
+import java.util.concurrent.atomic.AtomicLong;
+
+/** A monitor that counts bytes read and written. */
+public class ByteCountingMonitor implements Monitor {
+  private final AtomicLong bytesRead = new AtomicLong();
+  private final AtomicLong bytesWritten = new AtomicLong();
+
+  // NOTE: A real implementation of this would transmit these stats to a logging
+  // system such as <internal>. The counters are atomic so that such a monitoring
+  // task can happen in another thread safely.
+  @VisibleForTesting
+  public long[] stats() {
+    return new long[] {bytesRead.longValue(), bytesWritten.longValue()};
+  }
+
+  @Override
+  public Monitor.InputMonitor monitorRead(Uri uri) {
+    return new InputCounter();
+  }
+
+  @Override
+  public Monitor.OutputMonitor monitorWrite(Uri uri) {
+    return new OutputCounter();
+  }
+
+  @Override
+  public Monitor.OutputMonitor monitorAppend(Uri uri) {
+    return new OutputCounter();
+  }
+
+  class InputCounter implements Monitor.InputMonitor {
+    @Override
+    public void bytesRead(byte[] b, int off, int len) {
+      bytesRead.getAndAdd(len);
+    }
+  }
+
+  class OutputCounter implements Monitor.OutputMonitor {
+    @Override
+    public void bytesWritten(byte[] b, int off, int len) {
+      bytesWritten.getAndAdd(len);
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/samples/CapitalizationTransform.java b/java/com/google/android/libraries/mobiledatadownload/file/samples/CapitalizationTransform.java
new file mode 100644
index 0000000..73d99d8
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/samples/CapitalizationTransform.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.samples;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.common.Sizable;
+import com.google.android.libraries.mobiledatadownload.file.spi.Transform;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+/**
+ * This is a toy transform that is useful to illustrate that the invocation order is correct when
+ * combined with a base64 transform.
+ */
+public final class CapitalizationTransform implements Transform {
+
+  @Override
+  public String name() {
+    return "capitalize";
+  }
+
+  @Override
+  public InputStream wrapForRead(Uri uri, InputStream wrapped) {
+    return new CapitalizationInputStream(wrapped);
+  }
+
+  @SuppressWarnings("InputStreamSlowMultibyteRead")
+  // NOTE: Not bothering to override read(byte[],int,int) b/c this transform
+  // is never intended to be used for anything besides an example.
+  private static class CapitalizationInputStream extends InputStream implements Sizable {
+
+    private final InputStream in;
+
+    private CapitalizationInputStream(InputStream in) {
+      this.in = in;
+    }
+
+    @Override
+    public int read() throws IOException {
+      int b = in.read();
+      if (b == -1) {
+        return b;
+      }
+      return Character.toString((char) b).toUpperCase().charAt(0);
+    }
+
+    @Override
+    public Long size() throws IOException {
+      if (!(in instanceof Sizable)) {
+        return null;
+      }
+      return ((Sizable) in).size();
+    }
+  }
+
+  @Override
+  public OutputStream wrapForWrite(Uri uri, OutputStream wrapped) {
+    OutputStream stream =
+        new OutputStream() {
+          @Override
+          public void write(int b) throws IOException {
+            wrapped.write(Character.toString((char) b).toLowerCase().charAt(0));
+          }
+
+          @Override
+          public void close() throws IOException {
+            wrapped.close();
+          }
+        };
+    return stream;
+  }
+
+  @Override
+  public OutputStream wrapForAppend(Uri uri, OutputStream wrapped) throws IOException {
+    return wrapForWrite(uri, wrapped);
+  }
+
+  @Override
+  public String encode(Uri uri, String filename) {
+    return filename.toLowerCase();
+  }
+
+  @Override
+  public String decode(Uri uri, String filename) {
+    return filename.toUpperCase();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/spi/BUILD b/java/com/google/android/libraries/mobiledatadownload/file/spi/BUILD
new file mode 100644
index 0000000..23cc319
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/spi/BUILD
@@ -0,0 +1,31 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_library")
+
+package(
+    default_visibility = [
+        "//visibility:public",
+    ],
+    licenses = ["notice"],
+)
+
+android_library(
+    name = "spi",
+    srcs = glob(["*.java"]),
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "@com_google_code_findbugs_jsr305",
+        # NOTE: dependency of gmscore client lib <internal>
+    ],
+)
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/spi/Backend.java b/java/com/google/android/libraries/mobiledatadownload/file/spi/Backend.java
new file mode 100644
index 0000000..a2daef1
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/spi/Backend.java
@@ -0,0 +1,192 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.spi;
+
+import android.net.Uri;
+import android.util.Pair;
+import com.google.android.libraries.mobiledatadownload.file.common.GcParam;
+import com.google.android.libraries.mobiledatadownload.file.common.LockScope;
+import com.google.android.libraries.mobiledatadownload.file.common.UnsupportedFileStorageOperation;
+import java.io.Closeable;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+/**
+ * Backends are instantiated once per protocol during initialization. They encapsulate the methods
+ * needed to interact with a concrete storage mechanism.
+ *
+ * <p>Backend methods are expected to ignore the URI fragment, as the transform encoded in it are
+ * interpreted by the higher-level API (see {@link SynchronousFileStorage}).
+ */
+public interface Backend {
+  /**
+   * Name for this backend. Must be ASCII alphanumeric. Used as the scheme in the Uri.
+   *
+   * @return The name, which appears as the scheme of the uri.
+   */
+  String name();
+
+  /**
+   * Open this Uri for reading.
+   *
+   * @param uri
+   * @return A InputStream for reading from.
+   * @throws IOException
+   */
+  default InputStream openForRead(Uri uri) throws IOException {
+    throw new UnsupportedFileStorageOperation("openForRead not supported by " + name());
+  }
+
+  /**
+   * Open this Uri for reading by native code in the form of a file descriptor URI.
+   *
+   * @return An fd URI. Caller is responsible for closing.
+   * @throws IOException
+   */
+  default Pair<Uri, Closeable> openForNativeRead(Uri uri) throws IOException {
+    throw new UnsupportedFileStorageOperation("openForNativeRead not supported by " + name());
+  }
+
+  /**
+   * Open this Uri for writing, overwriting any existing content.
+   *
+   * <p>Any non-existent directories will be created as part of opening the file.
+   *
+   * @param uri
+   * @return A OutputStream to write to.
+   * @throws IOException
+   */
+  default OutputStream openForWrite(Uri uri) throws IOException {
+    throw new UnsupportedFileStorageOperation("openForWrite not supported by " + name());
+  }
+
+  /**
+   * Open this Uri for append.
+   *
+   * <p>Any non-existent directories will be created as part of opening the file.
+   *
+   * @param uri
+   * @return A OutputStream to write to.
+   * @throws IOException
+   */
+  default OutputStream openForAppend(Uri uri) throws IOException {
+    throw new UnsupportedFileStorageOperation("openForAppend not supported by " + name());
+  }
+
+  /**
+   * Delete the file identified with uri.
+   *
+   * @param uri
+   * @throws FileNotFoundException if the file does not exist, or is a directory
+   * @throws IOException if the file could not be deleted for any other reason
+   */
+  default void deleteFile(Uri uri) throws IOException {
+    throw new UnsupportedFileStorageOperation("deleteFile not supported by " + name());
+  }
+
+  /**
+   * Deletes the directory denoted by {@code uri}. The directory must be empty in order to be
+   * deleted.
+   *
+   * @throws IOException if the directory could not be deleted for any reason
+   */
+  default void deleteDirectory(Uri uri) throws IOException {
+    throw new UnsupportedFileStorageOperation("deleteDirectory not supported by " + name());
+  }
+
+  /**
+   * Rename the file or directory identified with {@code from} to {@code to}.
+   *
+   * <p>Any non-existent directories will be created as part of the rename.
+   *
+   * @throws IOException if the file could not be renamed for any reason
+   */
+  default void rename(Uri from, Uri to) throws IOException {
+    throw new UnsupportedFileStorageOperation("rename not supported by " + name());
+  }
+
+  /**
+   * Tells whether this file or directory exists.
+   *
+   * @param uri
+   * @return True if it exists.
+   */
+  default boolean exists(Uri uri) throws IOException {
+    throw new UnsupportedFileStorageOperation("exists not supported by " + name());
+  }
+
+  /**
+   * Tells whether this uri refers to a directory.
+   *
+   * @param uri
+   * @return True if it is a directory.
+   */
+  default boolean isDirectory(Uri uri) throws IOException {
+    throw new UnsupportedFileStorageOperation("isDirectory not supported by " + name());
+  }
+
+  /**
+   * Creates a new directory. Any non-existent parent directories will also be created.
+   *
+   * @throws IOException if the directory could not be created for any reason
+   */
+  default void createDirectory(Uri uri) throws IOException {
+    throw new UnsupportedFileStorageOperation("createDirectory not supported by " + name());
+  }
+
+  /**
+   * Gets the file size. If the uri refers to a directory or non-existent, returns 0.
+   *
+   * @param uri
+   * @return The size in bytes of the file.
+   */
+  default long fileSize(Uri uri) throws IOException {
+    throw new UnsupportedFileStorageOperation("fileSize not supported by " + name());
+  }
+
+  /**
+   * Lists the children of this parent directory. If the Uri refers to a non-directory, an exception
+   * is thrown.
+   *
+   * @param parentUri The parent directory to query for children.
+   * @return List of fully qualified URIs.
+   */
+  default Iterable<Uri> children(Uri parentUri) throws IOException {
+    throw new UnsupportedFileStorageOperation("children not supported by " + name());
+  }
+
+  /** Retrieves the {@link GcParam} associated with the given URI. */
+  default GcParam getGcParam(Uri uri) throws IOException {
+    throw new UnsupportedFileStorageOperation("getGcParam not supported by " + name());
+  }
+
+  /** Sets the {@link GcParam} associated with the given URI. */
+  default void setGcParam(Uri uri, GcParam param) throws IOException {
+    throw new UnsupportedFileStorageOperation("setGcParam not supported by " + name());
+  }
+
+  /** Converts the URI to a File if possible. Like all Backend methods, ignores fragment. */
+  default File toFile(Uri uri) throws IOException {
+    throw new UnsupportedFileStorageOperation("Cannot convert uri to file " + name() + " " + uri);
+  }
+
+  /** Retrieves the {@link LockScope} responsible for locking files in this Backend. */
+  default LockScope lockScope() throws IOException {
+    throw new UnsupportedFileStorageOperation("lockScope not supported by " + name());
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/spi/ForwardingBackend.java b/java/com/google/android/libraries/mobiledatadownload/file/spi/ForwardingBackend.java
new file mode 100644
index 0000000..86cfd47
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/spi/ForwardingBackend.java
@@ -0,0 +1,133 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.spi;
+
+import android.net.Uri;
+import android.util.Pair;
+import com.google.android.libraries.mobiledatadownload.file.common.LockScope;
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A backend which forwards all its calls to another backend. Subclasses should override one or more
+ * methods to to modify the behavior of the wrapped backend.
+ */
+public abstract class ForwardingBackend implements Backend {
+
+  /**
+   * Gets the backend to which this backend forwards calls.
+   *
+   * @return The delegate.
+   */
+  protected abstract Backend delegate();
+
+  /**
+   * Rewrites the Uri to use the delegate's scheme. If subclass calls super.method(), this is
+   * invoked on the Uri before calling method() on delegate.
+   *
+   * <p>This method is a good place to do validation. If the Uri is invalid, implementation should
+   * throw MalformedUriException.
+   *
+   * @param uri The uri that will have its scheme rewritten.
+   * @return The uri with the scheme of the delegate.
+   */
+  protected Uri rewriteUri(Uri uri) throws IOException {
+    return uri.buildUpon().scheme(delegate().name()).build();
+  }
+
+  /**
+   * Reverses the rewrite performed by rewriteUri. This is used for directory listing operations.
+   *
+   * @param uri The uri that had its scheme rewritten.
+   * @return The uri with the scheme from this backend.
+   */
+  protected Uri reverseRewriteUri(Uri uri) throws IOException {
+    return uri.buildUpon().scheme(name()).build();
+  }
+
+  @Override
+  public InputStream openForRead(Uri uri) throws IOException {
+    return delegate().openForRead(rewriteUri(uri));
+  }
+
+  @Override
+  public Pair<Uri, Closeable> openForNativeRead(Uri uri) throws IOException {
+    return delegate().openForNativeRead(rewriteUri(uri));
+  }
+
+  @Override
+  public OutputStream openForWrite(Uri uri) throws IOException {
+    return delegate().openForWrite(rewriteUri(uri));
+  }
+
+  @Override
+  public OutputStream openForAppend(Uri uri) throws IOException {
+    return delegate().openForAppend(rewriteUri(uri));
+  }
+
+  @Override
+  public void deleteFile(Uri uri) throws IOException {
+    delegate().deleteFile(rewriteUri(uri));
+  }
+
+  @Override
+  public void deleteDirectory(Uri uri) throws IOException {
+    delegate().deleteDirectory(rewriteUri(uri));
+  }
+
+  @Override
+  public void rename(Uri from, Uri to) throws IOException {
+    delegate().rename(rewriteUri(from), rewriteUri(to));
+  }
+
+  @Override
+  public boolean exists(Uri uri) throws IOException {
+    return delegate().exists(rewriteUri(uri));
+  }
+
+  @Override
+  public boolean isDirectory(Uri uri) throws IOException {
+    return delegate().isDirectory(rewriteUri(uri));
+  }
+
+  @Override
+  public void createDirectory(Uri uri) throws IOException {
+    delegate().createDirectory(rewriteUri(uri));
+  }
+
+  @Override
+  public long fileSize(Uri uri) throws IOException {
+    return delegate().fileSize(rewriteUri(uri));
+  }
+
+  @Override
+  public Iterable<Uri> children(Uri parentUri) throws IOException {
+    List<Uri> result = new ArrayList<Uri>();
+    for (Uri child : delegate().children(rewriteUri(parentUri))) {
+      result.add(reverseRewriteUri(child));
+    }
+    return result;
+  }
+
+  @Override
+  public LockScope lockScope() throws IOException {
+    return delegate().lockScope();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/spi/Monitor.java b/java/com/google/android/libraries/mobiledatadownload/file/spi/Monitor.java
new file mode 100644
index 0000000..24bd623
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/spi/Monitor.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.spi;
+
+import android.net.Uri;
+import java.io.Closeable;
+import javax.annotation.Nullable;
+
+/**
+ * A Monitor observes the stream. It can be used for passively tracking activity such as number of
+ * bytes read and written. Monitors are not invoked for non-stream-based operations.
+ *
+ * <p>Monitors are expected to passively observe and not mutate the stream. However, for efficiency,
+ * the raw byte[] is passed to the implementation so this is not enforced. If the array is mutated,
+ * the behavior is undefined. Moreover, monitors run as best-effort: if they encounter a problem,
+ * they are expected to get out of the way and not block the monitored stream transaction. This is
+ * why they do NOT have IOException in their method signatures.
+ *
+ * <p>Monitors are invoked after all transforms are applied for write, and before on read. For
+ * example, if there is a compression transform, this monitor will see the compressed bytes.
+ *
+ * <p>The original Uri passed to the FileStorage API, including transforms, is passed to this API.
+ */
+public interface Monitor {
+
+  /** Creates a new input monitor for this Uri, or null if this IO doesn't need to be monitored. */
+  @Nullable
+  InputMonitor monitorRead(Uri uri);
+
+  /** Creates a new output monitor for this Uri, or null if this IO doesn't need to be monitored. */
+  @Nullable
+  OutputMonitor monitorWrite(Uri uri);
+
+  /** Creates a new output monitor for this Uri, or null if this IO doesn't need to be monitored. */
+  @Nullable
+  OutputMonitor monitorAppend(Uri uri);
+
+  /** Monitors data read. */
+  interface InputMonitor extends Closeable {
+    /** Called with bytes as they are read from the stream. */
+    void bytesRead(byte[] b, int off, int len);
+
+    @Override
+    default void close() {
+      // Ignore.
+    }
+  }
+
+  /** Monitors data written. */
+  interface OutputMonitor extends Closeable {
+    /** Called with bytes as they are written to the stream. */
+    void bytesWritten(byte[] b, int off, int len);
+
+    @Override
+    default void close() {
+      // Ignore.
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/spi/Transform.java b/java/com/google/android/libraries/mobiledatadownload/file/spi/Transform.java
new file mode 100644
index 0000000..075c506
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/spi/Transform.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.spi;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.common.UnsupportedFileStorageOperation;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+/**
+ * A Transform modifies the stream to provide encryption, compression, and other such features. When
+ * registered, they can be invoked by using a fragment like #transform=<name>. They can also be
+ * explicitly invoked by a Backends.
+ *
+ * <p>Transforms can be parameterized on the Uri using the fragment subparameters. For example,
+ * #transform=compress(algorithm=GZip) would make the key/value pair "algorithm"/"GZip" available to
+ * the transform. Taken together, the name and subparameters are the transform spec.
+ *
+ * <p>Transforms can modify both the byte stream and the names of resources. For example, an
+ * encryption Transform can encrypt file names by implementing the {@link #encode} and {@link
+ * #decode} methods.
+ */
+public interface Transform {
+
+  /**
+   * The name as it would appear in the fragment. Must be ASCII and [a-zA-Z0-9-].
+   *
+   * @return The name.
+   */
+  String name();
+
+  /**
+   * Wrap the requested stream with another that performs the desired decoding transform.
+   *
+   * @param uri The whole URI with fragment for this transaction.
+   * @param wrapped The wrapped stream.
+   * @return the wrapped stream.
+   * @throws IOException
+   */
+  default InputStream wrapForRead(Uri uri, InputStream wrapped) throws IOException {
+    try (InputStream w = wrapped) {}
+    throw new UnsupportedFileStorageOperation("wrapForRead not supported by " + name());
+  }
+
+  /**
+   * Wrap the requested stream with another that performs the desired encoding transform for write
+   * operations.
+   *
+   * @param uri The whole URI with fragment for this transaction.
+   * @param wrapped The wrapped stream.
+   * @return the wrapped stream.
+   * @throws IOException
+   */
+  default OutputStream wrapForWrite(Uri uri, OutputStream wrapped) throws IOException {
+    try (OutputStream w = wrapped) {}
+    throw new UnsupportedFileStorageOperation("wrapForWrite not supported by " + name());
+  }
+
+  /**
+   * Wrap the requested stream with another that performs the desired encoding transform for append
+   * operations. The fragment param is and can be modified until the stream is closed.
+   *
+   * @param uri The whole URI with fragment for this transaction.
+   * @param wrapped The stream.
+   * @return the wrapped stream.
+   * @throws IOException
+   */
+  default OutputStream wrapForAppend(Uri uri, OutputStream wrapped) throws IOException {
+    try (OutputStream w = wrapped) {}
+    throw new UnsupportedFileStorageOperation("wrapForAppend not supported by " + name());
+  }
+
+  /**
+   * Rewrite the file name. This is called by all file-based methods of FileStorage when this
+   * transform is specified with a fragment param. If there are multiple transforms, their {@link
+   * #encode} methods are invoked in the order in which they are specified.
+   *
+   * @param uri The whole URI with fragment for this transaction.
+   * @param filename The original filename.
+   * @return The encoded filename.
+   */
+  default String encode(Uri uri, String filename) {
+    return filename;
+  }
+
+  /**
+   * Reverse the rewriting of the filename done by {@link #encode}. Called on all directory scanning
+   * methods in FileStorage in the reverse order in which {@link #encode} is called.
+   *
+   * @param param The fragment param value for this transform.
+   * @param filename The encoded filename.
+   * @return The decoded filename.
+   */
+  default String decode(Uri uri, String filename) {
+    return filename;
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/transforms/BUILD b/java/com/google/android/libraries/mobiledatadownload/file/transforms/BUILD
new file mode 100644
index 0000000..9f9525d
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/transforms/BUILD
@@ -0,0 +1,81 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_library")
+
+package(
+    default_visibility = [
+        "//visibility:public",
+    ],
+    licenses = ["notice"],
+)
+
+# Most clients should depend on this target. It ensures that the "standard" transforms
+# are available. Care will be taken to keep the size small. However, if a client wants
+# even more granular control of dependencies, it can depend on a narrower build targets below.
+android_library(
+    name = "transforms",
+    exports = [
+        ":buffer",
+        ":compress",
+        ":integrity",
+    ],
+)
+
+android_library(
+    name = "integrity",
+    srcs = ["IntegrityTransform.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/common:fragment",
+        "//java/com/google/android/libraries/mobiledatadownload/file/spi",
+        "@com_google_code_findbugs_jsr305",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "buffer",
+    srcs = ["BufferTransform.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/common:fragment",  # TODO(b/132818747) Remove
+        "//java/com/google/android/libraries/mobiledatadownload/file/spi",
+    ],
+)
+
+android_library(
+    name = "compress",
+    srcs = ["CompressTransform.java"],
+    deps = ["//java/com/google/android/libraries/mobiledatadownload/file/spi"],
+)
+
+android_library(
+    name = "proto",
+    srcs = ["TransformProtos.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/internal:charsets",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/internal:lite_transform_fragments",
+        "//proto:transform_java_proto_lite",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "proto_fragments",
+    srcs = ["TransformProtoFragments.java"],
+    deps = [
+        ":proto",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/internal:lite_transform_fragments",
+        "//proto:transform_java_proto_lite",
+        "@com_google_guava_guava",
+    ],
+)
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/transforms/BufferTransform.java b/java/com/google/android/libraries/mobiledatadownload/file/transforms/BufferTransform.java
new file mode 100644
index 0000000..ce80575
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/transforms/BufferTransform.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.transforms;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.common.Fragment;
+import com.google.android.libraries.mobiledatadownload.file.spi.Transform;
+import java.io.BufferedOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+/**
+ * BufferTransform takes writes and holds on to it until enough has been written to justify flushing
+ * to the disk. Reads are not affected by this transform.
+ *
+ * <p>A flush of the buffer can be performed using {@link OutputStream#flush()}.
+ *
+ * <p>It is configured using transform fragment int param {@code size}. For example: {@code
+ * file:///tmp/file#transform=buffer(size=1024)}.
+ */
+public class BufferTransform implements Transform {
+
+  private static final int DEFAULT_BUFFER_SIZE = 8192;
+
+  private static final String TRANSFORM_NAME = "buffer";
+  private static final String SUBPARAM_SIZE = "size";
+
+  @Override
+  public String name() {
+    return TRANSFORM_NAME;
+  }
+
+  @Override
+  public InputStream wrapForRead(Uri uri, InputStream wrapped) throws IOException {
+    return wrapped;
+  }
+
+  @Override
+  public OutputStream wrapForAppend(Uri uri, OutputStream wrapped) throws IOException {
+    return wrapForWrite(uri, wrapped);
+  }
+
+  @Override
+  public OutputStream wrapForWrite(Uri uri, OutputStream wrapped) throws IOException {
+    String bufferSizeStr = Fragment.getTransformSubParam(uri, name(), SUBPARAM_SIZE);
+    int bufferSize = DEFAULT_BUFFER_SIZE;
+    if (bufferSizeStr != null) {
+      bufferSize = Integer.parseInt(bufferSizeStr);
+    }
+    return new BufferedOutputStream(wrapped, bufferSize);
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/transforms/CompressTransform.java b/java/com/google/android/libraries/mobiledatadownload/file/transforms/CompressTransform.java
new file mode 100644
index 0000000..3697dd4
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/transforms/CompressTransform.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.transforms;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.spi.Transform;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.zip.DeflaterOutputStream;
+import java.util.zip.InflaterInputStream;
+
+/** A transform that compresses/decompresses with java.util.zip Inflater/Deflater. */
+public final class CompressTransform implements Transform {
+
+  private static final String TRANSFORM_NAME = "compress";
+
+  @Override
+  public String name() {
+    return TRANSFORM_NAME;
+  }
+
+  @Override
+  public InputStream wrapForRead(Uri uri, InputStream wrapped) throws IOException {
+    return new InflaterInputStream(wrapped);
+  }
+
+  @Override
+  public OutputStream wrapForWrite(Uri uri, OutputStream wrapped) throws IOException {
+    return new DeflaterOutputStream(wrapped);
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/transforms/IntegrityTransform.java b/java/com/google/android/libraries/mobiledatadownload/file/transforms/IntegrityTransform.java
new file mode 100644
index 0000000..83a9205
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/transforms/IntegrityTransform.java
@@ -0,0 +1,208 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.transforms;
+
+import android.net.Uri;
+import android.text.TextUtils;
+import android.util.Base64;
+import com.google.android.libraries.mobiledatadownload.file.common.Fragment;
+import com.google.android.libraries.mobiledatadownload.file.common.ParamComputer;
+import com.google.android.libraries.mobiledatadownload.file.spi.Transform;
+import com.google.common.primitives.Ints;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.security.DigestInputStream;
+import java.security.DigestOutputStream;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Arrays;
+import javax.annotation.Nullable;
+
+/**
+ * A transform that implements integrity check using a cryptographic hash (SHA-256). This transform
+ * can work in 3 modes:
+ *
+ * <ol>
+ *   <li>While reading, verify that content matches hash present in URI.
+ *   <li>Compute hash from existing file and return with {@link
+ *       ComputedUriInputStream#consumeStreamAndGetComputedUri}
+ *   <li>Compute hash while writing, and return with {@link ComputedUriOutputStream#getComputedUri}
+ * </ol>
+ *
+ * There are vulnerabilities present in this design:
+ *
+ * <ul>
+ *   <li>While reading, the verification happens only when the end of the stream is reached. No
+ *       assumptions can be made about integrity of data until then.
+ *   <li>If the hash is computed on an existing file stored on insecure medium, it's possible for
+ *       that file to have already been modified, or to be modified while the hash is being
+ *       computed.
+ * </ul>
+ *
+ * <p>Clients are expected to use the computed URI methods to produce a valid URI with hash embedded
+ * in it. The name of the digest subparam (eg, "sha256") is used to identify the hash and hashing
+ * algorithm. Future implementations may use different algorithms and subparams, but are expected to
+ * retain backwards compatibility.
+ *
+ * <p>Computed URIs that contain hashes must be stored in a safe place to avoid tampering.
+ *
+ * <p>Does not support appending to an existing file.
+ *
+ * <p>See <internal> for further documentation.
+ */
+public final class IntegrityTransform implements Transform {
+
+  private static final String TRANSFORM_NAME = "integrity";
+  private static final String SUBPARAM_NAME = "sha256";
+
+  /** Returns the base64-encoded SHA-256 digest encoded in {@code uri}, or null if not present. */
+  @Nullable
+  public static String getDigestIfPresent(Uri uri) {
+    return Fragment.getTransformSubParam(uri, TRANSFORM_NAME, SUBPARAM_NAME);
+  }
+
+  @Override
+  public String name() {
+    return TRANSFORM_NAME;
+  }
+
+  private static MessageDigest newDigester() {
+    try {
+      return MessageDigest.getInstance("SHA-256");
+    } catch (NoSuchAlgorithmException e) {
+      throw new IllegalArgumentException(e);
+    }
+  }
+
+  @Override
+  public InputStream wrapForRead(Uri uri, InputStream wrapped) throws IOException {
+    Fragment.ParamValue param = Fragment.getTransformParamValue(uri, name());
+    return new DigestVerifyingInputStream(wrapped, param);
+  }
+
+  @Override
+  public OutputStream wrapForWrite(Uri uri, OutputStream wrapped) throws IOException {
+    Fragment.ParamValue param = Fragment.getTransformParamValue(uri, name());
+    return new DigestComputingOutputStream(wrapped, param);
+  }
+
+  private static class DigestComputingOutputStream extends DigestOutputStream
+      implements ParamComputer {
+    Fragment.ParamValue param;
+    ParamComputer.Callback paramComputerCallback;
+    boolean didComputeDigest = false;
+
+    DigestComputingOutputStream(OutputStream stream, Fragment.ParamValue param) {
+      super(stream, newDigester());
+      this.param = param;
+    }
+
+    @Override
+    public void close() throws IOException {
+      super.close();
+      if (!didComputeDigest) {
+        didComputeDigest = true;
+        if (paramComputerCallback != null) {
+          paramComputerCallback.onParamValueComputed(
+              param.toBuilder()
+                  .addSubParam(
+                      SUBPARAM_NAME, Base64.encodeToString(digest.digest(), Base64.NO_WRAP))
+                  .build());
+        }
+      }
+    }
+
+    @Override
+    public void setCallback(ParamComputer.Callback callback) {
+      this.paramComputerCallback = callback;
+    }
+  }
+
+  private static class DigestVerifyingInputStream extends DigestInputStream
+      implements ParamComputer {
+    byte[] skipBuffer = new byte[4096];
+    byte[] expectedDigest;
+    byte[] computedDigest;
+    Fragment.ParamValue param;
+    ParamComputer.Callback paramComputerCallback;
+
+    DigestVerifyingInputStream(InputStream stream, Fragment.ParamValue param) {
+      super(stream, newDigester());
+      this.param = param;
+      String digest = param.findSubParamValue(SUBPARAM_NAME);
+      if (!TextUtils.isEmpty(digest)) {
+        this.expectedDigest = Base64.decode(digest, Base64.NO_WRAP);
+      }
+    }
+
+    @Override
+    public int read(byte[] b, int off, int len) throws IOException {
+      int result = super.read(b, off, len);
+      if (result == -1) {
+        checkDigest();
+      }
+      return result;
+    }
+
+    @Override
+    public int read() throws IOException {
+      int result = super.read();
+      if (result == -1) {
+        checkDigest();
+      }
+      return result;
+    }
+
+    @Override
+    public void close() throws IOException {
+      // Close underlying stream first since checkDigest can throw exception. If the underlying
+      // close fails, OTOH, digest should be assumed to be invalid.
+      super.close();
+      checkDigest();
+    }
+
+    @Override
+    public long skip(long n) throws IOException {
+      // Consume min(buffer.length, n) bytes, processing the bytes the ensure the digest is updated.
+      int length = Math.min(skipBuffer.length, Ints.checkedCast(n));
+      return read(skipBuffer, 0, length);
+    }
+
+    @Override
+    public void setCallback(ParamComputer.Callback callback) {
+      this.paramComputerCallback = callback;
+    }
+
+    private void checkDigest() throws IOException {
+      if (computedDigest != null) {
+        return;
+      }
+      computedDigest = digest.digest();
+      if (paramComputerCallback != null) {
+        paramComputerCallback.onParamValueComputed(
+            param.toBuilder()
+                .addSubParam(SUBPARAM_NAME, Base64.encodeToString(computedDigest, Base64.NO_WRAP))
+                .build());
+      }
+      if (expectedDigest != null && !Arrays.equals(computedDigest, expectedDigest)) {
+        String a = Base64.encodeToString(computedDigest, Base64.NO_WRAP);
+        String e = Base64.encodeToString(expectedDigest, Base64.NO_WRAP);
+        throw new IOException("Mismatched digest: " + a + " expected: " + e);
+      }
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/transforms/TransformProtoFragments.java b/java/com/google/android/libraries/mobiledatadownload/file/transforms/TransformProtoFragments.java
new file mode 100644
index 0000000..c6793f5
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/transforms/TransformProtoFragments.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.transforms;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.common.internal.LiteTransformFragments;
+import com.google.common.collect.ImmutableList;
+import com.google.mobiledatadownload.TransformProto;
+
+/** Helper for Uri classes to handle transform fragments using proto specs. */
+public final class TransformProtoFragments {
+
+  /** Adds this spec to the URI, replacing existing spec if present. */
+  public static Uri addOrReplaceTransform(Uri uri, TransformProto.Transform spec) {
+    ImmutableList.Builder<String> result = ImmutableList.builder();
+    ImmutableList<String> oldSpecs = LiteTransformFragments.parseTransformSpecs(uri);
+    String newSpec = TransformProtos.toEncodedSpec(spec);
+    String newSpecName = LiteTransformFragments.parseSpecName(newSpec);
+    for (String oldSpec : oldSpecs) {
+      String oldSpecName = LiteTransformFragments.parseSpecName(oldSpec);
+      if (!oldSpecName.equals(newSpecName)) {
+        result.add(oldSpec);
+      }
+    }
+    result.add(newSpec);
+    return uri.buildUpon()
+        .encodedFragment(LiteTransformFragments.joinTransformSpecs(result.build()))
+        .build();
+  }
+
+  private TransformProtoFragments() {}
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/file/transforms/TransformProtos.java b/java/com/google/android/libraries/mobiledatadownload/file/transforms/TransformProtos.java
new file mode 100644
index 0000000..781fec1
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/file/transforms/TransformProtos.java
@@ -0,0 +1,152 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.transforms;
+
+import static com.google.android.libraries.mobiledatadownload.file.common.internal.Charsets.UTF_8;
+
+import android.util.Base64;
+import com.google.android.libraries.mobiledatadownload.file.common.internal.LiteTransformFragments;
+import com.google.common.base.Joiner;
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableList;
+import com.google.mobiledatadownload.TransformProto;
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+
+/** Utility to convert Transform proto into URI fragment. */
+public final class TransformProtos {
+
+  public static final TransformProto.Transform DEFAULT_COMPRESS_SPEC =
+      TransformProto.Transform.newBuilder()
+          .setCompress(TransformProto.CompressTransform.getDefaultInstance())
+          .build();
+
+  public static final TransformProto.Transform DEFAULT_ENCRYPT_SPEC =
+      TransformProto.Transform.newBuilder()
+          .setEncrypt(TransformProto.EncryptTransform.getDefaultInstance())
+          .build();
+
+  public static final TransformProto.Transform DEFAULT_INTEGRITY_SPEC =
+      TransformProto.Transform.newBuilder()
+          .setIntegrity(TransformProto.IntegrityTransform.getDefaultInstance())
+          .build();
+
+  public static TransformProto.Transform encryptTransformSpecWithKey(byte[] key) {
+    return encryptTransformSpecWithBase64Key(Base64.encodeToString(key, Base64.NO_WRAP));
+  }
+
+  public static TransformProto.Transform encryptTransformSpecWithBase64Key(String base64Key) {
+    return TransformProto.Transform.newBuilder()
+        .setEncrypt(TransformProto.EncryptTransform.newBuilder().setAesGcmKeyBase64(base64Key))
+        .build();
+  }
+
+  public static TransformProto.Transform integrityTransformSpecWithSha256(String sha256) {
+    return TransformProto.Transform.newBuilder()
+        .setIntegrity(TransformProto.IntegrityTransform.newBuilder().setSha256(sha256))
+        .build();
+  }
+
+  public static TransformProto.Transform zipTransformSpecWithTarget(String target) {
+    return TransformProto.Transform.newBuilder()
+        .setZip(TransformProto.ZipTransform.newBuilder().setTarget(target))
+        .build();
+  }
+
+  /**
+   * Translate from proto representation to an encoded fragment, which is suitable for appending to
+   * a URI.
+   *
+   * <p>To build an {@link android.net.Uri} with such a fragment: <code>
+   * Fragment fragment = TransformProtos.toEncodedFragment(transformProto);
+   * Uri uri = origUri.buildUpon().encodedFragment(fragment.toString()).build();
+   * </code>
+   *
+   * @param transformsProto The proto to translate to a fragment.
+   * @return The encoded fragment representation of the proto.
+   */
+  public static String toEncodedFragment(TransformProto.Transforms transformsProto) {
+    ImmutableList.Builder<String> encodedSpecs = ImmutableList.builder();
+    for (TransformProto.Transform transformProto : transformsProto.getTransformList()) {
+      encodedSpecs.add(toEncodedSpec(transformProto));
+    }
+    return LiteTransformFragments.joinTransformSpecs(encodedSpecs.build());
+  }
+
+  /**
+   * Translate proto representation to an encoded transform spec.
+   *
+   * @param transformProto The proto to translate to a spec.
+   * @return An encoded spec suitable for joining with other specs to form fragment.
+   */
+  public static String toEncodedSpec(TransformProto.Transform transformProto) {
+    String encodedSpec;
+    switch (transformProto.getTransformCase()) {
+      case COMPRESS:
+        encodedSpec = "compress";
+        break;
+      case ENCRYPT:
+        TransformProto.EncryptTransform encrypt = transformProto.getEncrypt();
+        if (encrypt.hasAesGcmKeyBase64()) {
+          encodedSpec = "encrypt(aes_gcm_key=" + urlEncode(encrypt.getAesGcmKeyBase64()) + ")";
+        } else {
+          encodedSpec = "encrypt";
+        }
+        break;
+      case INTEGRITY:
+        TransformProto.IntegrityTransform integrity = transformProto.getIntegrity();
+        if (integrity.hasSha256()) {
+          encodedSpec = "integrity(sha256=" + urlEncode(integrity.getSha256()) + ")";
+        } else {
+          encodedSpec = "integrity";
+        }
+        break;
+      case ZIP:
+        TransformProto.ZipTransform zip = transformProto.getZip();
+        Preconditions.checkArgument(zip.hasTarget());
+        encodedSpec = "zip(target=" + urlEncode(zip.getTarget()) + ")";
+        break;
+      case CUSTOM:
+        TransformProto.CustomTransform custom = transformProto.getCustom();
+        String params = "";
+        if (custom.getSubparamCount() > 0) {
+          ImmutableList.Builder<String> subparams = ImmutableList.builder();
+          for (TransformProto.CustomTransform.SubParam subparam : custom.getSubparamList()) {
+            Preconditions.checkArgument(subparam.hasKey());
+            if (subparam.hasValue()) {
+              subparams.add(subparam.getKey() + "=" + urlEncode(subparam.getValue()));
+            } else {
+              subparams.add(subparam.getKey());
+            }
+          }
+          params = "(" + Joiner.on(",").join(subparams.build()) + ")";
+        }
+        encodedSpec = custom.getName() + params;
+        break;
+      default:
+        throw new IllegalArgumentException("No transform specified");
+    }
+    return encodedSpec;
+  }
+
+  private static final String urlEncode(String str) {
+    try {
+      return URLEncoder.encode(str, UTF_8.displayName());
+    } catch (UnsupportedEncodingException e) {
+      throw new IllegalStateException(e); // Expects UTF8 to be available.
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/foreground/AndroidManifest.xml b/java/com/google/android/libraries/mobiledatadownload/foreground/AndroidManifest.xml
new file mode 100644
index 0000000..9dcd410
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/foreground/AndroidManifest.xml
@@ -0,0 +1,25 @@
+<!--
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+-->
+<manifest
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.google.android.libraries.mobiledatadownload.foreground">
+
+  <uses-sdk android:minSdkVersion="16"/>
+
+  <application />
+</manifest>
diff --git a/java/com/google/android/libraries/mobiledatadownload/foreground/BUILD b/java/com/google/android/libraries/mobiledatadownload/foreground/BUILD
new file mode 100644
index 0000000..b98c623
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/foreground/BUILD
@@ -0,0 +1,44 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_library")
+
+package(
+    default_visibility = [
+        "//visibility:public",
+    ],
+    licenses = ["notice"],
+)
+
+filegroup(
+    name = "resource_files",
+    srcs = glob([
+        "res/**",
+    ]),
+)
+
+# This includes all translated strings for MDD Notifications. Apps can choose to include subset of the
+# supported locale resources in their binary using the `resource_configuration_filters` option in
+# their android_binary rule. For more info, see: <internal>
+android_library(
+    name = "NotificationUtil",
+    srcs = ["NotificationUtil.java"],
+    manifest = "AndroidManifest.xml",
+    resource_files = glob(["res/**"]),
+    deps = [
+        "@androidx_annotation_annotation",
+        "@androidx_core_core",
+        "@com_google_code_findbugs_jsr305",
+        "@com_google_guava_guava",
+    ],
+)
diff --git a/java/com/google/android/libraries/mobiledatadownload/foreground/NotificationUtil.java b/java/com/google/android/libraries/mobiledatadownload/foreground/NotificationUtil.java
new file mode 100644
index 0000000..5ba9a90
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/foreground/NotificationUtil.java
@@ -0,0 +1,199 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.foreground;
+
+import android.annotation.SuppressLint;
+import android.app.NotificationChannel;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Build.VERSION;
+import android.os.Build.VERSION_CODES;
+import androidx.annotation.RequiresApi;
+import androidx.core.app.NotificationCompat;
+import androidx.core.app.NotificationCompat.BigTextStyle;
+import androidx.core.app.NotificationManagerCompat;
+import androidx.core.content.ContextCompat;
+import com.google.common.base.Preconditions;
+import javax.annotation.Nullable;
+
+/** Utilities for creating and managing notifications. */
+// TODO(b/148401016): Add UI test for NotificationUtil.
+public final class NotificationUtil {
+  public static final String CANCEL_ACTION_EXTRA = "cancel-action";
+  public static final String KEY_EXTRA = "key";
+  public static final String STOP_SERVICE_EXTRA = "stop-service";
+
+  private NotificationUtil() {}
+
+  public static final String NOTIFICATION_CHANNEL_ID = "download-notification-channel-id";
+
+  /** Create the NotificationBuilder for the Foreground Download Service */
+  public static NotificationCompat.Builder createForegroundServiceNotificationBuilder(
+      Context context) {
+    return getNotificationBuilder(context)
+        .setContentTitle(
+                "Downloading")
+        .setSmallIcon(android.R.drawable.stat_notify_sync_noanim);
+  }
+
+  /** Create a Notification.Builder. */
+  public static NotificationCompat.Builder createNotificationBuilder(
+      Context context, int size, String contentTitle, String contentText) {
+    return getNotificationBuilder(context)
+        .setContentTitle(contentTitle)
+        .setContentText(contentText)
+        .setSmallIcon(android.R.drawable.stat_sys_download)
+        .setOngoing(true)
+        .setProgress(size, 0, false)
+        .setStyle(new BigTextStyle().bigText(contentText));
+  }
+
+  private static NotificationCompat.Builder getNotificationBuilder(Context context) {
+    return new NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID)
+        .setCategory(NotificationCompat.CATEGORY_SERVICE)
+        .setOnlyAlertOnce(true);
+  }
+
+  /**
+   * Create a Notification for a key.
+   *
+   * @param key Key to identify the download this notification is created for.
+   */
+  public static void cancelNotificationForKey(Context context, String key) {
+    NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
+    notificationManager.cancel(notificationKeyForKey(key));
+  }
+
+  /** Create the Cancel Menu Action which will be attach to the download notification. */
+  // FLAG_IMMUTABLE is only for api >= 23, however framework still recommends to use this:
+  // <internal>
+  @SuppressLint("InlinedApi")
+  public static void createCancelAction(
+      Context context,
+      Class<?> foregroundDownloadServiceClass,
+      String key,
+      NotificationCompat.Builder notification,
+      int notificationKey) {
+    SaferIntentUtils intentUtils = new SaferIntentUtils() {};
+
+    Intent cancelIntent = new Intent(context, foregroundDownloadServiceClass);
+    cancelIntent.setPackage(context.getPackageName());
+    cancelIntent.putExtra(CANCEL_ACTION_EXTRA, notificationKey);
+    cancelIntent.putExtra(KEY_EXTRA, key);
+
+    // It should be safe since we are using SaferPendingIntent, setting Package and Component, and
+    // use PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_IMMUTABLE.
+    PendingIntent pendingCancelIntent;
+    if (VERSION.SDK_INT >= VERSION_CODES.O) {
+      pendingCancelIntent =
+          intentUtils.getForegroundService(
+              context,
+              notificationKey,
+              cancelIntent,
+              PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_IMMUTABLE);
+    } else {
+      pendingCancelIntent =
+          intentUtils.getService(
+              context,
+              notificationKey,
+              cancelIntent,
+              PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_IMMUTABLE);
+    }
+    NotificationCompat.Action action =
+        new NotificationCompat.Action.Builder(
+                android.R.drawable.stat_sys_warning,
+                "Cancel",
+                Preconditions.checkNotNull(pendingCancelIntent))
+            .build();
+    notification.addAction(action);
+  }
+
+  /** Generate the Notification Key for the Key */
+  public static int notificationKeyForKey(String key) {
+    // Consider if we could have collision.
+    // Heavier alternative is Hashing.goodFastHash(32).hashUnencodedChars(key).asInt();
+    return key.hashCode();
+  }
+
+  /** Send intent to start the DownloadService in foreground. */
+  public static void startForegroundDownloadService(
+      Context context, Class<?> foregroundDownloadService, String key) {
+    Intent intent = new Intent(context, foregroundDownloadService);
+    intent.putExtra(KEY_EXTRA, key);
+
+    // Start ForegroundDownloadService to download in the foreground.
+    ContextCompat.startForegroundService(context, intent);
+  }
+
+  /** Sending the intent to stop the foreground download service */
+  public static void stopForegroundDownloadService(
+      Context context, Class<?> foregroundDownloadService) {
+    Intent intent = new Intent(context, foregroundDownloadService);
+    intent.putExtra(STOP_SERVICE_EXTRA, true);
+
+    // This will send the intent to stop the service.
+    ContextCompat.startForegroundService(context, intent);
+  }
+
+  /**
+   * Return the String message to display in Notification when the download is paused to wait for
+   * network connection.
+   */
+  public static String getDownloadPausedMessage(Context context) {
+    return "Waiting for network connection";
+  }
+
+  /** Return the String message to display in Notification when the download is failed. */
+  public static String getDownloadFailedMessage(Context context) {
+    return "Download failed";
+  }
+
+  /** Return the String message to display in Notification when the download is success. */
+  public static String getDownloadSuccessMessage(Context context) {
+    return "Downloaded";
+  }
+
+  /** Create the Notification Channel for Downloading. */
+  public static void createNotificationChannel(Context context) {
+    if (VERSION.SDK_INT >= VERSION_CODES.O) {
+      NotificationChannel notificationChannel =
+          new NotificationChannel(
+              NOTIFICATION_CHANNEL_ID,
+                  "Data Download Notification Channel",
+              android.app.NotificationManager.IMPORTANCE_DEFAULT);
+
+      android.app.NotificationManager manager =
+          context.getSystemService(android.app.NotificationManager.class);
+      manager.createNotificationChannel(notificationChannel);
+    }
+  }
+
+  /** Utilities for safely accessing PendingIntent APIs. */
+  private interface SaferIntentUtils {
+    @Nullable
+    @RequiresApi(VERSION_CODES.O) // to match PendingIntent.getForegroundService()
+    default PendingIntent getForegroundService(
+        Context context, int requestCode, Intent intent, int flags) {
+      return PendingIntent.getForegroundService(context, requestCode, intent, flags);
+    }
+
+    @Nullable
+    default PendingIntent getService(Context context, int requestCode, Intent intent, int flags) {
+      return PendingIntent.getService(context, requestCode, intent, flags);
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/foreground/res/values/strings.xml b/java/com/google/android/libraries/mobiledatadownload/foreground/res/values/strings.xml
new file mode 100644
index 0000000..1896b0c
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/foreground/res/values/strings.xml
@@ -0,0 +1,54 @@
+<!--
+/*
+ * Copyright 2022 Google LLC
+ *
+ * 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.
+ */
+-->
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <!-- Notification title that is shown when MDD is
+    downloading a file.[CHAR_LIMIT=80] -->
+  <string name="mdd_foreground_service_notification_title">
+    Downloading
+  </string>
+
+  <!-- Notification channel group that is shown when long pressing any notification.
+[CHAR_LIMIT=80] -->
+  <string name="mdd_download_notification_channel_name">
+    Data Download Notification Channel
+  </string>
+
+  <!-- Notification title that is shown for every file that is currently
+    downloading but is temporary paused due to network connection. [CHAR_LIMIT=80] -->
+  <string name="mdd_notification_download_paused">
+    Waiting for network connection
+  </string>
+
+  <!-- Notification title that is shown for every file that was successfully
+    downloaded.[CHAR_LIMIT=80] -->
+  <string name="mdd_notification_download_success">
+    Downloaded
+  </string>
+
+  <!-- Notification title that is shown for every file that failed to download.
+    [CHAR_LIMIT=80] -->
+  <string name="mdd_notification_download_failed">
+    Download failed
+  </string>
+
+  <!-- The cancel action menu.[CHAR_LIMIT=80] -->
+  <string name="mdd_notification_action_cancel">
+    Cancel
+  </string>
+</resources>
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/ActivationRequiredForGroupException.java b/java/com/google/android/libraries/mobiledatadownload/internal/ActivationRequiredForGroupException.java
new file mode 100644
index 0000000..4f20f5c
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/ActivationRequiredForGroupException.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal;
+
+/** Thrown when trying to add a File Group that requires device side activation. */
+public class ActivationRequiredForGroupException extends Exception {}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/ApplicationContext.java b/java/com/google/android/libraries/mobiledatadownload/internal/ApplicationContext.java
new file mode 100644
index 0000000..424bb84
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/ApplicationContext.java
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal;
+
+import javax.inject.Qualifier;
+
+/** Qualifier for the application Context. */
+@Qualifier
+public @interface ApplicationContext {}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/BUILD b/java/com/google/android/libraries/mobiledatadownload/internal/BUILD
new file mode 100644
index 0000000..68f9139
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/BUILD
@@ -0,0 +1,292 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_library")
+
+package(
+    default_visibility = ["//:__subpackages__"],
+    licenses = ["notice"],
+)
+
+android_library(
+    name = "MobileDataDownloadManager",
+    srcs = ["MobileDataDownloadManager.java"],
+    deps = [
+        ":ApplicationContext",
+        ":DataFileGroupValidator",
+        ":ExpirationHandler",
+        ":FileGroupManager",
+        ":FileGroupsMetadata",
+        ":MddExceptions",
+        ":Migrations",
+        ":SharedFileManager",
+        ":SharedFilesMetadata",
+        "//java/com/google/android/libraries/mobiledatadownload:FileSource",
+        "//java/com/google/android/libraries/mobiledatadownload:Flags",
+        "//java/com/google/android/libraries/mobiledatadownload:SilentFeedback",
+        "//java/com/google/android/libraries/mobiledatadownload/annotations",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:proto",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/annotations:SequentialControlExecutor",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/downloader:FileValidator",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/experimentation:DownloadStageManager",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:EventLogger",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:FileGroupStatsLogger",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LogUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LoggingStateStore",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:NetworkLogger",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:StorageLogger",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:FileGroupUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:SharedPreferencesUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/tracing:concurrent",
+        "//proto:transform_java_proto_lite",
+        "@androidx_annotation_annotation",
+        "@com_google_code_findbugs_jsr305",
+        "@com_google_dagger",
+        "@com_google_errorprone_error_prone_annotations",
+        "@com_google_guava_guava",
+        "@com_google_protobuf//:any_proto",
+        "@javax_inject",
+        "@org_checkerframework_qual",
+    ],
+)
+
+android_library(
+    name = "DataFileGroupValidator",
+    srcs = [
+        "DataFileGroupValidator.java",
+    ],
+    deps = [
+        ":MddConstants",
+        ":Migrations",
+        "//java/com/google/android/libraries/mobiledatadownload:Flags",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:proto",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LogUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:FileGroupUtil",
+        "//proto:transform_java_proto_lite",
+    ],
+)
+
+android_library(
+    name = "MddExceptions",
+    srcs = [
+        "ActivationRequiredForGroupException.java",
+        "ExpiredFileGroupException.java",
+        "SharedFileMissingException.java",
+        "UninstalledAppException.java",
+    ],
+)
+
+android_library(
+    name = "MddConstants",
+    srcs = ["MddConstants.java"],
+)
+
+android_library(
+    name = "Migrations",
+    srcs = ["Migrations.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload:SilentFeedback",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LogUtil",
+    ],
+)
+
+android_library(
+    name = "ApplicationContext",
+    srcs = [
+        "ApplicationContext.java",
+    ],
+    deps = [
+        "@com_google_dagger",
+        "@javax_inject",
+    ],
+)
+
+android_library(
+    name = "FileGroupManager",
+    srcs = ["FileGroupManager.java"],
+    deps = [
+        ":ApplicationContext",
+        ":FileGroupsMetadata",
+        ":MddConstants",
+        ":MddExceptions",
+        ":SharedFileManager",
+        ":SharedFilesMetadata",
+        "//java/com/google/android/libraries/mobiledatadownload:AccountSource",
+        "//java/com/google/android/libraries/mobiledatadownload:AggregateException",
+        "//java/com/google/android/libraries/mobiledatadownload:DownloadException",
+        "//java/com/google/android/libraries/mobiledatadownload:FileSource",
+        "//java/com/google/android/libraries/mobiledatadownload:Flags",
+        "//java/com/google/android/libraries/mobiledatadownload:SilentFeedback",
+        "//java/com/google/android/libraries/mobiledatadownload:TimeSource",
+        "//java/com/google/android/libraries/mobiledatadownload/account:AccountUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/annotations",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/annotations:SequentialControlExecutor",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/experimentation:DownloadStageManager",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:DownloadStateLogger",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:EventLogger",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LogUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:AndroidSharingUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:DirectoryUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:FileGroupUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:SymlinkUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/tracing",
+        "//java/com/google/android/libraries/mobiledatadownload/tracing:concurrent",
+        "@androidx_annotation_annotation",
+        "@com_google_auto_value",
+        "@com_google_code_findbugs_jsr305",
+        "@com_google_guava_guava",
+        "@com_google_protobuf//:any_proto",
+        "@javax_inject",
+        "@org_checkerframework_qual",
+    ],
+)
+
+android_library(
+    name = "FileGroupsMetadata",
+    srcs = ["FileGroupsMetadata.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "@com_google_guava_guava",
+        "@org_checkerframework_qual",
+    ],
+)
+
+android_library(
+    name = "SharedPreferencesFileGroupsMetadata",
+    srcs = ["SharedPreferencesFileGroupsMetadata.java"],
+    deps = [
+        ":ApplicationContext",
+        ":FileGroupsMetadata",
+        "//java/com/google/android/libraries/mobiledatadownload:SilentFeedback",
+        "//java/com/google/android/libraries/mobiledatadownload:TimeSource",
+        "//java/com/google/android/libraries/mobiledatadownload/annotations",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/annotations:SequentialControlExecutor",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LogUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:FileGroupUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:FileGroupsMetadataUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:ProtoLiteUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:SharedPreferencesUtil",
+        "@androidx_annotation_annotation",
+        "@com_google_errorprone_error_prone_annotations",
+        "@com_google_guava_guava",
+        "@javax_inject",
+        "@org_checkerframework_qual",
+    ],
+)
+
+android_library(
+    name = "ExpirationHandler",
+    srcs = ["ExpirationHandler.java"],
+    deps = [
+        ":ApplicationContext",
+        ":FileGroupsMetadata",
+        ":SharedFileManager",
+        ":SharedFilesMetadata",
+        "//java/com/google/android/libraries/mobiledatadownload:Flags",
+        "//java/com/google/android/libraries/mobiledatadownload:SilentFeedback",
+        "//java/com/google/android/libraries/mobiledatadownload:TimeSource",
+        "//java/com/google/android/libraries/mobiledatadownload/annotations",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/annotations:SequentialControlExecutor",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:EventLogger",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LogUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:DirectoryUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:FileGroupUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/tracing:concurrent",
+        "@androidx_annotation_annotation",
+        "@com_google_guava_guava",
+        "@javax_inject",
+    ],
+)
+
+android_library(
+    name = "SharedFileManager",
+    srcs = ["SharedFileManager.java"],
+    deps = [
+        ":ApplicationContext",
+        ":FileGroupsMetadata",
+        ":MddConstants",
+        ":MddExceptions",
+        ":Migrations",
+        ":SharedFilesMetadata",
+        "//java/com/google/android/libraries/mobiledatadownload:DownloadException",
+        "//java/com/google/android/libraries/mobiledatadownload:FileSource",
+        "//java/com/google/android/libraries/mobiledatadownload:Flags",
+        "//java/com/google/android/libraries/mobiledatadownload:SilentFeedback",
+        "//java/com/google/android/libraries/mobiledatadownload/annotations",
+        "//java/com/google/android/libraries/mobiledatadownload/delta:DeltaDecoder",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/annotations:SequentialControlExecutor",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/downloader:DeltaFileDownloaderCallbackImpl",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/downloader:DownloaderCallbackImpl",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/downloader:FileNameUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/downloader:FileValidator",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/downloader:MddFileDownloader",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:EventLogger",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LogUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:DirectoryUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:SharedPreferencesUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/monitor:DownloadProgressMonitor",
+        "@androidx_annotation_annotation",
+        "@com_google_code_findbugs_jsr305",
+        "@com_google_dagger",
+        "@com_google_errorprone_error_prone_annotations",
+        "@com_google_guava_guava",
+        "@javax_inject",
+        "@org_checkerframework_qual",
+    ],
+)
+
+android_library(
+    name = "SharedFilesMetadata",
+    srcs = ["SharedFilesMetadata.java"],
+    deps = [
+        ":Migrations",
+        "//java/com/google/android/libraries/mobiledatadownload:SilentFeedback",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:FileGroupUtil",
+        "//proto:transform_java_proto_lite",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "SharedPreferencesSharedFilesMetadata",
+    srcs = ["SharedPreferencesSharedFilesMetadata.java"],
+    deps = [
+        ":ApplicationContext",
+        ":MddConstants",
+        ":Migrations",
+        ":SharedFilesMetadata",
+        "//java/com/google/android/libraries/mobiledatadownload:Flags",
+        "//java/com/google/android/libraries/mobiledatadownload:SilentFeedback",
+        "//java/com/google/android/libraries/mobiledatadownload/annotations",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LogUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:SharedFilesMetadataUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:SharedPreferencesUtil",
+        "//proto:transform_java_proto_lite",
+        "@androidx_annotation_annotation",
+        "@com_google_errorprone_error_prone_annotations",
+        "@com_google_guava_guava",
+        "@javax_inject",
+    ],
+)
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/DataFileGroupValidator.java b/java/com/google/android/libraries/mobiledatadownload/internal/DataFileGroupValidator.java
new file mode 100644
index 0000000..3d49157
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/DataFileGroupValidator.java
@@ -0,0 +1,254 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal;
+
+import android.content.Context;
+import com.google.android.libraries.mobiledatadownload.Flags;
+import com.google.android.libraries.mobiledatadownload.file.transforms.TransformProtos;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil;
+import com.google.android.libraries.mobiledatadownload.internal.util.FileGroupUtil;
+import com.google.mobiledatadownload.TransformProto.Transforms;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFile;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFile.ChecksumType;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal.AllowedReaders;
+import com.google.mobiledatadownload.internal.MetadataProto.DeltaFile;
+import com.google.mobiledatadownload.internal.MetadataProto.DeltaFile.DiffDecoder;
+import com.google.mobiledatadownload.internal.MetadataProto.DownloadConditions.DeviceNetworkPolicy;
+
+/** DataFileGroupValidator - validates the passed in DataFileGroup */
+public class DataFileGroupValidator {
+
+  private static final String TAG = "DataFileGroupValidator";
+
+  /**
+   * Checks if the data file group that we received is a valid group. A group is valid if all the
+   * data required to download it is present. If any field that is not required is set, it will be
+   * ignored.
+   *
+   * <p>This is just a sanity check. For example, it doesn't check if the file is present at the
+   * given location.
+   *
+   * @param dataFileGroup The data file group on which sanity check needs to be done.
+   * @return true if the group is valid.
+   */
+  // TODO(b/124072754): Change to package private once all code is refactored.
+  public static boolean isValidGroup(
+      DataFileGroupInternal dataFileGroup, Context context, Flags flags) {
+    // Check if the group name is empty.
+    if (dataFileGroup.getGroupName().isEmpty()) {
+      LogUtil.e("%s Group name missing in added group", TAG);
+      return false;
+    }
+
+    if (dataFileGroup.getGroupName().contains(MddConstants.SPLIT_CHAR)) {
+      LogUtil.e("%s Group name = %s contains '|'", TAG, dataFileGroup.getGroupName());
+      return false;
+    }
+
+    if (dataFileGroup.getOwnerPackage().contains(MddConstants.SPLIT_CHAR)) {
+      LogUtil.e("%s Owner package = %s contains '|'", TAG, dataFileGroup.getOwnerPackage());
+      return false;
+    }
+
+    // Check if any file is missing any of the required fields.
+    for (DataFile dataFile : dataFileGroup.getFileList()) {
+      if (dataFile.getFileId().isEmpty()
+          || dataFile.getFileId().contains(MddConstants.SPLIT_CHAR)
+          || !isValidDataFile(dataFile)) {
+        LogUtil.e(
+            "%s File details missing in added group = %s, file id = %s",
+            TAG, dataFileGroup.getGroupName(), dataFile.getFileId());
+        return false;
+      }
+      if (!hasValidTransforms(dataFileGroup, dataFile, flags)) {
+        return false;
+      }
+      if (!hasValidDeltaFiles(dataFileGroup.getGroupName(), dataFile)) {
+        return false;
+      }
+      // Check if sideloaded files are present and if sideloading is enabled
+      if (FileGroupUtil.isSideloadedFile(dataFile) && !flags.enableSideloading()) {
+        LogUtil.e(
+            "%s File detected as sideloaded, but sideloading is not enabled. group = %s, file id ="
+                + " %s, file url = %s",
+            TAG, dataFileGroup.getGroupName(), dataFile.getFileId(), dataFile.getUrlToDownload());
+        return false;
+      }
+    }
+
+    // Check if a file id is repeated.
+    for (int i = 0; i < dataFileGroup.getFileCount(); i++) {
+      for (int j = i + 1; j < dataFileGroup.getFileCount(); j++) {
+        if (dataFileGroup.getFile(i).getFileId().equals(dataFileGroup.getFile(j).getFileId())) {
+          LogUtil.e(
+              "%s Repeated file id in added group = %s, file id = %s",
+              TAG, dataFileGroup.getGroupName(), dataFileGroup.getFile(i).getFileId());
+          return false;
+        }
+      }
+    }
+
+    if (dataFileGroup
+            .getDownloadConditions()
+            .getDeviceNetworkPolicy()
+            .equals(DeviceNetworkPolicy.DOWNLOAD_FIRST_ON_WIFI_THEN_ON_ANY_NETWORK)
+        && dataFileGroup.getDownloadConditions().getDownloadFirstOnWifiPeriodSecs() <= 0) {
+      LogUtil.e(
+          "%s For DOWNLOAD_FIRST_ON_WIFI_THEN_ON_ANY_NETWORK policy, "
+              + "the download_first_on_wifi_period_secs must be > 0",
+          TAG);
+      return false;
+    }
+
+    if (!Migrations.isMigratedToNewFileKey(context)
+        && dataFileGroup.getAllowedReadersEnum().equals(AllowedReaders.ALL_APPS)) {
+      LogUtil.e(
+          "%s For AllowedReaders ALL_APPS policy, the device should be migrated to new key", TAG);
+      return false;
+    }
+
+    return true;
+  }
+
+  private static boolean hasValidTransforms(
+      DataFileGroupInternal dataFileGroup, DataFile dataFile, Flags flags) {
+    // Verify for Download transforms
+    if (dataFile.hasDownloadTransforms()) {
+      if (!isValidTransforms(dataFile.getDownloadTransforms())) {
+        return false;
+      }
+      if (!hasValidZipDownloadTransform(dataFileGroup.getGroupName(), dataFile, flags)) {
+        return false;
+      }
+      if (dataFile.getChecksumType() != ChecksumType.NONE
+          && !dataFile.hasDownloadedFileChecksum()) {
+        LogUtil.e(
+            "Download checksum must be provided. Group = %s, file id = %s",
+            dataFileGroup.getGroupName(), dataFile.getFileId());
+        return false;
+      }
+    }
+    // Verify for Read transforms
+    if (dataFile.hasReadTransforms() && !isValidTransforms(dataFile.getReadTransforms())) {
+      return false;
+    }
+    return true;
+  }
+
+  private static boolean hasValidZipDownloadTransform(
+      String groupName, DataFile dataFile, Flags flags) {
+    if (FileGroupUtil.hasZipDownloadTransform(dataFile)) {
+      if (!flags.enableZipFolder()) {
+        LogUtil.e(
+            "Feature enableZipFolder is not enabled. Group = %s, file id = %s",
+            groupName, dataFile.getFileId());
+        return false;
+      }
+      if (dataFile.getDownloadTransforms().getTransformCount() > 1) {
+        LogUtil.e(
+            "Download zip folder transform cannot not be applied with other transforms. Group ="
+                + " %s, file id = %s",
+            groupName, dataFile.getFileId());
+        return false;
+      }
+      if (!"*".equals(dataFile.getDownloadTransforms().getTransform(0).getZip().getTarget())) {
+        LogUtil.e(
+            "Download zip folder transform can only have * as target. Group = %s, file id = %s",
+            groupName, dataFile.getFileId());
+        return false;
+      }
+    }
+    return true;
+  }
+
+  private static boolean isValidTransforms(Transforms transforms) {
+    try {
+      TransformProtos.toEncodedFragment(transforms);
+      return true;
+    } catch (IllegalArgumentException illegalArgumentException) {
+      LogUtil.e(illegalArgumentException, "Invalid transform specification");
+      return false;
+    }
+  }
+
+  private static boolean hasValidDeltaFiles(String groupName, DataFile dataFile) {
+    for (DeltaFile deltaFile : dataFile.getDeltaFileList()) {
+      if (!isValidDeltaFile(deltaFile)) {
+        LogUtil.e(
+            "%s Delta File of Datafile details missing in added group = %s, file id = %s"
+                + ", delta file UrlToDownload = %s.",
+            TAG, groupName, dataFile.getFileId(), deltaFile.getUrlToDownload());
+        return false;
+      }
+    }
+    return true;
+  }
+
+  private static boolean isValidDataFile(DataFile dataFile) {
+    // When a data file has zip transform, downloaded file checksum is used for identifying the data
+    // file; otherwise, checksum is used.
+    boolean hasNonEmptyChecksum;
+    if (FileGroupUtil.hasZipDownloadTransform(dataFile)) {
+      hasNonEmptyChecksum =
+          dataFile.hasDownloadedFileChecksum() && !dataFile.getDownloadedFileChecksum().isEmpty();
+    } else {
+      hasNonEmptyChecksum = dataFile.hasChecksum() && !dataFile.getChecksum().isEmpty();
+    }
+
+    boolean validChecksum;
+    switch (dataFile.getChecksumType()) {
+        // Default stands for SHA1.
+      case DEFAULT:
+        validChecksum = hasNonEmptyChecksum;
+        break;
+      case NONE:
+        validChecksum = !hasNonEmptyChecksum;
+        break;
+      default:
+        validChecksum = false;
+    }
+
+    // File checksum is not needed for zip folder download transforms.
+    validChecksum |= FileGroupUtil.hasZipDownloadTransform(dataFile) && !hasNonEmptyChecksum;
+
+    boolean validAndroidSharingConfig =
+        dataFile.getAndroidSharingChecksumType() == DataFile.AndroidSharingChecksumType.SHA2_256
+            ? !dataFile.getAndroidSharingChecksum().isEmpty()
+            : true;
+
+    return !dataFile.getUrlToDownload().isEmpty()
+        && !dataFile.getUrlToDownload().contains(MddConstants.SPLIT_CHAR)
+        && dataFile.getByteSize() >= 0
+        && validChecksum
+        && validAndroidSharingConfig
+        && !FileGroupUtil.getFileChecksum(dataFile).contains(MddConstants.SPLIT_CHAR);
+  }
+
+  private static boolean isValidDeltaFile(DeltaFile deltaFile) {
+    return !deltaFile.getUrlToDownload().isEmpty()
+        && !deltaFile.getUrlToDownload().contains(MddConstants.SPLIT_CHAR)
+        && deltaFile.hasByteSize()
+        && deltaFile.getByteSize() >= 0
+        && !deltaFile.getChecksum().isEmpty()
+        && !deltaFile.getChecksum().contains(MddConstants.SPLIT_CHAR)
+        && deltaFile.hasDiffDecoder()
+        && !deltaFile.getDiffDecoder().equals(DiffDecoder.UNSPECIFIED)
+        && deltaFile.hasBaseFile()
+        && !deltaFile.getBaseFile().getChecksum().isEmpty()
+        && !deltaFile.getBaseFile().getChecksum().contains(MddConstants.SPLIT_CHAR);
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/ExpirationHandler.java b/java/com/google/android/libraries/mobiledatadownload/internal/ExpirationHandler.java
new file mode 100644
index 0000000..b5efe22
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/ExpirationHandler.java
@@ -0,0 +1,460 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal;
+
+import static com.google.common.util.concurrent.Futures.immediateVoidFuture;
+import static java.lang.Math.min;
+
+import android.content.Context;
+import android.net.Uri;
+import android.util.Pair;
+import androidx.annotation.VisibleForTesting;
+import com.google.android.libraries.mobiledatadownload.Flags;
+import com.google.android.libraries.mobiledatadownload.SilentFeedback;
+import com.google.android.libraries.mobiledatadownload.TimeSource;
+import com.google.android.libraries.mobiledatadownload.annotations.InstanceId;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.internal.annotations.SequentialControlExecutor;
+import com.google.android.libraries.mobiledatadownload.internal.logging.EventLogger;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil;
+import com.google.android.libraries.mobiledatadownload.internal.util.DirectoryUtil;
+import com.google.android.libraries.mobiledatadownload.internal.util.FileGroupUtil;
+import com.google.android.libraries.mobiledatadownload.tracing.PropagatedFutures;
+import com.google.common.base.Optional;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFile;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal;
+import com.google.mobiledatadownload.internal.MetadataProto.GroupKey;
+import com.google.mobiledatadownload.internal.MetadataProto.NewFileKey;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.Executor;
+import java.util.concurrent.atomic.AtomicInteger;
+import javax.inject.Inject;
+
+/**
+ * A class that handles of the logic for file group expiration and file expiration. Expiration is
+ * determined by two sources: 1) when the active_expiration_date (set server-side by the client) has
+ * passed 2) when stale_lifetime_secs has passed since the group became stale.
+ */
+public class ExpirationHandler {
+
+  private static final String TAG = "ExpirationHandler";
+
+  @VisibleForTesting
+  static final String MDD_EXPIRATION_HANDLER = "gms_icing_mdd_expiration_handler";
+
+  private final Context context;
+  private final FileGroupsMetadata fileGroupsMetadata;
+  private final SharedFileManager sharedFileManager;
+  private final SharedFilesMetadata sharedFilesMetadata;
+  private final EventLogger eventLogger;
+  private final TimeSource timeSource;
+  private final SynchronousFileStorage fileStorage;
+  private final Optional<String> instanceId;
+  private final SilentFeedback silentFeedback;
+  private final Executor sequentialControlExecutor;
+  private final Flags flags;
+
+  @Inject
+  public ExpirationHandler(
+      @ApplicationContext Context context,
+      FileGroupsMetadata fileGroupsMetadata,
+      SharedFileManager sharedFileManager,
+      SharedFilesMetadata sharedFilesMetadata,
+      EventLogger eventLogger,
+      TimeSource timeSource,
+      SynchronousFileStorage fileStorage,
+      @InstanceId Optional<String> instanceId,
+      SilentFeedback silentFeedback,
+      @SequentialControlExecutor Executor sequentialControlExecutor,
+      Flags flags) {
+    this.context = context;
+    this.fileGroupsMetadata = fileGroupsMetadata;
+    this.sharedFileManager = sharedFileManager;
+    this.sharedFilesMetadata = sharedFilesMetadata;
+    this.eventLogger = eventLogger;
+    this.timeSource = timeSource;
+    this.fileStorage = fileStorage;
+    this.instanceId = instanceId;
+    this.silentFeedback = silentFeedback;
+    this.sequentialControlExecutor = sequentialControlExecutor;
+    this.flags = flags;
+  }
+
+  // TODO(b/124072754): Change to package private once all code is refactored.
+  public ListenableFuture<Void> updateExpiration() {
+    return PropagatedFutures.transformAsync(
+        removeExpiredStaleGroups(),
+        voidArg0 ->
+            PropagatedFutures.transformAsync(
+                removeExpiredFreshGroups(),
+                voidArg1 -> removeUnaccountedFiles(),
+                sequentialControlExecutor),
+        sequentialControlExecutor);
+  }
+
+  /** Returns a future that checks all File Groups and remove expired ones from FileGroupManager */
+  private ListenableFuture<Void> removeExpiredFreshGroups() {
+    return PropagatedFutures.transformAsync(
+        fileGroupsMetadata.getAllFreshGroups(),
+        groups -> {
+          List<GroupKey> expiredGroupKeys = new ArrayList<>();
+          for (Pair<GroupKey, DataFileGroupInternal> pair : groups) {
+            GroupKey groupKey = pair.first;
+            DataFileGroupInternal dataFileGroup = pair.second;
+            Long groupExpirationDateMillis = FileGroupUtil.getExpirationDateMillis(dataFileGroup);
+            LogUtil.d(
+                "%s: Checking group %s with expiration date %s",
+                TAG, dataFileGroup.getGroupName(), groupExpirationDateMillis);
+            if (FileGroupUtil.isExpired(groupExpirationDateMillis, timeSource)) {
+              eventLogger.logEventSampled(
+                  0,
+                  dataFileGroup.getGroupName(),
+                  dataFileGroup.getFileGroupVersionNumber(),
+                  dataFileGroup.getBuildId(),
+                  dataFileGroup.getVariantId());
+              LogUtil.d(
+                  "%s: Expired group %s with expiration date %s",
+                  TAG, dataFileGroup.getGroupName(), groupExpirationDateMillis);
+              expiredGroupKeys.add(groupKey);
+
+              // Remove Isolated structure if necessary.
+              if (FileGroupUtil.isIsolatedStructureAllowed(dataFileGroup)) {
+                FileGroupUtil.removeIsolatedFileStructure(
+                    context, instanceId, dataFileGroup, fileStorage);
+              }
+            }
+          }
+
+          return PropagatedFutures.transform(
+              fileGroupsMetadata.removeAllGroupsWithKeys(expiredGroupKeys),
+              removeSuccess -> {
+                if (!removeSuccess) {
+                  eventLogger.logEventSampled(0);
+                  LogUtil.e("%s: Failed to remove expired groups!", TAG);
+                }
+                return null;
+              },
+              sequentialControlExecutor);
+        },
+        sequentialControlExecutor);
+  }
+
+  /** Check and update all stale File Groups; remove staled ones */
+  private ListenableFuture<Void> removeExpiredStaleGroups() {
+    return PropagatedFutures.transformAsync(
+        fileGroupsMetadata.getAllStaleGroups(),
+        staleGroups -> {
+          List<DataFileGroupInternal> nonExpiredStaleGroups = new ArrayList<>();
+          for (DataFileGroupInternal staleGroup : staleGroups) {
+            long groupStaleExpirationDateMillis =
+                FileGroupUtil.getStaleExpirationDateMillis(staleGroup);
+            long groupExpirationDateMillis = FileGroupUtil.getExpirationDateMillis(staleGroup);
+            long actualExpirationDateMillis =
+                min(groupStaleExpirationDateMillis, groupExpirationDateMillis);
+
+            // Remove the group from this list if its expired.
+            if (FileGroupUtil.isExpired(actualExpirationDateMillis, timeSource)) {
+              eventLogger.logEventSampled(
+                  0,
+                  staleGroup.getGroupName(),
+                  staleGroup.getFileGroupVersionNumber(),
+                  staleGroup.getBuildId(),
+                  staleGroup.getVariantId());
+
+              // Remove Isolated structure if necessary.
+              if (FileGroupUtil.isIsolatedStructureAllowed(staleGroup)) {
+                FileGroupUtil.removeIsolatedFileStructure(
+                    context, instanceId, staleGroup, fileStorage);
+              }
+            } else {
+              nonExpiredStaleGroups.add(staleGroup);
+            }
+          }
+
+          // Empty the list of stale groups in the FGGC and write only the non-expired stale groups.
+          return PropagatedFutures.transformAsync(
+              fileGroupsMetadata.removeAllStaleGroups(),
+              voidArg ->
+                  PropagatedFutures.transformAsync(
+                      fileGroupsMetadata.writeStaleGroups(nonExpiredStaleGroups),
+                      writeSuccess -> {
+                        if (!writeSuccess) {
+                          eventLogger.logEventSampled(0);
+                          LogUtil.e("%s: Failed to write back stale groups!", TAG);
+                        }
+                        return immediateVoidFuture();
+                      },
+                      sequentialControlExecutor),
+              sequentialControlExecutor);
+        },
+        sequentialControlExecutor);
+  }
+
+  private ListenableFuture<Void> removeUnaccountedFiles() {
+    return PropagatedFutures.transformAsync(
+        getFileKeysReferencedByAnyGroup(),
+        // Remove all shared file metadata that are not referenced by any group.
+        fileKeysReferencedByAnyGroup ->
+            PropagatedFutures.transformAsync(
+                sharedFilesMetadata.getAllFileKeys(),
+                allFileKeys -> {
+                  List<Uri> filesRequiredByMdd = new ArrayList<>();
+                  List<Uri> androidSharedFilesToBeReleased = new ArrayList<>();
+                  // Use AtomicInteger because variables captured by lambdas must be effectively
+                  // final.
+                  AtomicInteger removedMetadataCount = new AtomicInteger(0);
+                  List<ListenableFuture<Void>> futures = new ArrayList<>();
+                  for (NewFileKey newFileKey : allFileKeys) {
+                    if (!fileKeysReferencedByAnyGroup.contains(newFileKey)) {
+                      ListenableFuture<Void> removeEntryFuture =
+                          PropagatedFutures.transformAsync(
+                              sharedFilesMetadata.read(newFileKey),
+                              sharedFile -> {
+                                if (sharedFile != null && sharedFile.getAndroidShared()) {
+                                  androidSharedFilesToBeReleased.add(
+                                      DirectoryUtil.getBlobUri(
+                                          context, sharedFile.getAndroidSharingChecksum()));
+                                }
+                                return PropagatedFutures.transform(
+                                    sharedFileManager.removeFileEntry(newFileKey),
+                                    success -> {
+                                      if (success) {
+                                        removedMetadataCount.getAndIncrement();
+                                      } else {
+                                        eventLogger.logEventSampled(0);
+                                        LogUtil.e(
+                                            "%s: Unsubscribe from file %s failed!",
+                                            TAG, newFileKey);
+                                      }
+                                      return null;
+                                    },
+                                    sequentialControlExecutor);
+                              },
+                              sequentialControlExecutor);
+                      futures.add(removeEntryFuture);
+                    } else {
+                      futures.add(
+                          PropagatedFutures.transform(
+                              sharedFileManager.getOnDeviceUri(newFileKey),
+                              uri -> {
+                                if (uri != null) {
+                                  filesRequiredByMdd.add(uri);
+                                }
+                                return null;
+                              },
+                              sequentialControlExecutor));
+                    }
+                  }
+
+                  // If isolated structure verification is enabled, include all individual isolated
+                  // file uris referenced by fresh groups. This ensures any unaccounted isolated
+                  // file uris are removed (i.e. verification is performed).
+                  if (flags.enableIsolatedStructureVerification()) {
+                    futures.add(
+                        PropagatedFutures.transform(
+                            getIsolatedFileUrisReferencedByFreshGroups(),
+                            referencedIsolatedFileUris -> {
+                              filesRequiredByMdd.addAll(referencedIsolatedFileUris);
+                              return null;
+                            },
+                            sequentialControlExecutor));
+                  } else {
+                    // Isolated structure verification is disabled, include the base symlink
+                    // directory as required so all isolated file uris under this directory are
+                    // _not_ removed (i.e. verification is not performed).
+                    filesRequiredByMdd.add(
+                        DirectoryUtil.getBaseDownloadSymlinkDirectory(context, instanceId));
+                  }
+                  return PropagatedFutures.whenAllComplete(futures)
+                      .call(
+                          () -> {
+                            if (removedMetadataCount.get() > 0) {
+                              eventLogger.logMddDataDownloadFileExpirationEvent(
+                                  0, removedMetadataCount.get());
+                            }
+                            Uri parentDirectory =
+                                DirectoryUtil.getBaseDownloadDirectory(context, instanceId);
+                            int releasedFiles =
+                                releaseUnaccountedAndroidSharedFiles(
+                                    androidSharedFilesToBeReleased);
+                            LogUtil.d(
+                                "%s: Total %d unaccounted file released. ", TAG, releasedFiles);
+
+                            int unaccountedFileCount =
+                                deleteUnaccountedFilesRecursively(
+                                    parentDirectory, filesRequiredByMdd);
+                            LogUtil.d(
+                                "%s: Total %d unaccounted file deleted. ",
+                                TAG, unaccountedFileCount);
+                            if (unaccountedFileCount > 0) {
+                              eventLogger.logMddDataDownloadFileExpirationEvent(
+                                  0, unaccountedFileCount);
+                            }
+                            if (releasedFiles > 0) {
+                              eventLogger.logMddDataDownloadFileExpirationEvent(0, releasedFiles);
+                            }
+                            return null;
+                          },
+                          sequentialControlExecutor);
+                },
+                sequentialControlExecutor),
+        sequentialControlExecutor);
+  }
+
+  private ListenableFuture<Set<NewFileKey>> getFileKeysReferencedByAnyGroup() {
+    return PropagatedFutures.transformAsync(
+        fileGroupsMetadata.getAllFreshGroups(),
+        allGroupsByKey -> {
+          Set<NewFileKey> fileKeysReferencedByAnyGroup = new HashSet<>();
+          List<DataFileGroupInternal> dataFileGroups = new ArrayList<>();
+          for (Pair<GroupKey, DataFileGroupInternal> dataFileGroupPair : allGroupsByKey) {
+            dataFileGroups.add(dataFileGroupPair.second);
+          }
+          return PropagatedFutures.transform(
+              fileGroupsMetadata.getAllStaleGroups(),
+              staleGroups -> {
+                dataFileGroups.addAll(staleGroups);
+                for (DataFileGroupInternal dataFileGroup : dataFileGroups) {
+                  for (DataFile dataFile : dataFileGroup.getFileList()) {
+                    fileKeysReferencedByAnyGroup.add(
+                        SharedFilesMetadata.createKeyFromDataFileForCurrentVersion(
+                            context,
+                            dataFile,
+                            dataFileGroup.getAllowedReadersEnum(),
+                            silentFeedback));
+                  }
+                }
+                return fileKeysReferencedByAnyGroup;
+              },
+              sequentialControlExecutor);
+        },
+        sequentialControlExecutor);
+  }
+
+  /**
+   * Get all isolated file uris that are referenced by any fresh groups.
+   *
+   * <p>Fresh groups are active/pending groups. Isolated file uris are expected when 1) the OS
+   * version supports symlinks (at least Lollipop (21)); and 2) The file group enables file
+   * isolation.
+   *
+   * @return ListenableFuture that resolves with List of isolated uris that are referenced by
+   *     active/pending groups
+   */
+  private ListenableFuture<List<Uri>> getIsolatedFileUrisReferencedByFreshGroups() {
+    List<Uri> referencedIsolatedFileUris = new ArrayList<>();
+    return PropagatedFutures.transform(
+        fileGroupsMetadata.getAllFreshGroups(),
+        groupKeyAndGroupList -> {
+          for (Pair<GroupKey, DataFileGroupInternal> groupKeyAndGroup : groupKeyAndGroupList) {
+            DataFileGroupInternal freshGroup = groupKeyAndGroup.second;
+            // Skip any groups that don't support isolated structures
+            if (!FileGroupUtil.isIsolatedStructureAllowed(freshGroup)) {
+              continue;
+            }
+
+            // Add the expected isolated file uris for each file
+            for (DataFile file : freshGroup.getFileList()) {
+              Uri isolatedFileUri =
+                  FileGroupUtil.getIsolatedFileUri(context, instanceId, file, freshGroup);
+              referencedIsolatedFileUris.add(isolatedFileUri);
+            }
+          }
+
+          return referencedIsolatedFileUris;
+        },
+        sequentialControlExecutor);
+  }
+
+  private int releaseUnaccountedAndroidSharedFiles(List<Uri> androidSharedFilesToBeReleased) {
+    int releasedFiles = 0;
+    for (Uri sharedFile : androidSharedFilesToBeReleased) {
+      try {
+        fileStorage.deleteFile(sharedFile);
+        releasedFiles += 1;
+        eventLogger.logEventSampled(0);
+      } catch (IOException e) {
+        eventLogger.logEventSampled(0);
+        LogUtil.e(e, "%s: Failed to release unaccounted file!", TAG);
+      }
+    }
+    return releasedFiles;
+  }
+
+  // TODO(b/119622504) Fix nullness violation: incompatible types in argument.
+  @SuppressWarnings("nullness:argument")
+  private int deleteUnaccountedFilesRecursively(Uri directory, List<Uri> filesRequiredByMdd) {
+    int unaccountedFileCount = 0;
+    try {
+      if (!fileStorage.exists(directory)) {
+        return unaccountedFileCount;
+      }
+
+      for (Uri uri : fileStorage.children(directory)) {
+        try {
+          if (isContainedInUriList(uri, filesRequiredByMdd)) {
+            continue;
+          }
+          if (fileStorage.isDirectory(uri)) {
+            unaccountedFileCount += deleteUnaccountedFilesRecursively(uri, filesRequiredByMdd);
+          } else {
+            LogUtil.d("%s: Deleted unaccounted file with uri %s!", TAG, uri.getPath());
+            fileStorage.deleteFile(uri);
+            unaccountedFileCount++;
+          }
+
+        } catch (IOException e) {
+          eventLogger.logEventSampled(0);
+          LogUtil.e(e, "%s: Failed to delete unaccounted file!", TAG);
+        }
+      }
+
+    } catch (IOException e) {
+      eventLogger.logEventSampled(0);
+      LogUtil.e(e, "%s: Failed to delete unaccounted file!", TAG);
+    }
+    return unaccountedFileCount;
+  }
+
+  /**
+   * Returns true if given uri is within the given uri list or is a child of any uri in the list.
+   *
+   * <p>Used by MDD's unaccounted file logic to filter out files that shouldn't be deleted. This is
+   * used in two cases:
+   *
+   * <ul>
+   *   <li>files referred by any active MDD files. This includes internal MDD files, such as delta
+   *       files of a full active file, which are stored using the active file name and a checksum
+   *       suffix.
+   *   <li>symlinks created for an isolated file structure. These symlinks will reference active
+   *       files and their lifecycle is managed on the file group level, rather than as individual
+   *       files.
+   * </ul>
+   */
+  private boolean isContainedInUriList(Uri uri, List<Uri> uriList) {
+    for (Uri activeUri : uriList) {
+      if (uri.toString().startsWith(activeUri.toString())) {
+        return true;
+      }
+    }
+    return false;
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/ExpiredFileGroupException.java b/java/com/google/android/libraries/mobiledatadownload/internal/ExpiredFileGroupException.java
new file mode 100644
index 0000000..ad7ecff
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/ExpiredFileGroupException.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal;
+
+/** Thrown when trying to add a File Group that has already expired. */
+public class ExpiredFileGroupException extends Exception {}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/FileGroupManager.java b/java/com/google/android/libraries/mobiledatadownload/internal/FileGroupManager.java
new file mode 100644
index 0000000..1ec6eca
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/FileGroupManager.java
@@ -0,0 +1,2944 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal;
+
+import static com.google.android.libraries.mobiledatadownload.tracing.TracePropagation.propagateAsyncFunction;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.util.concurrent.Futures.immediateFailedFuture;
+import static com.google.common.util.concurrent.Futures.immediateFuture;
+import static com.google.common.util.concurrent.Futures.immediateVoidFuture;
+import static java.lang.Math.max;
+
+import android.accounts.Account;
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.net.Uri;
+import android.os.Build.VERSION;
+import android.os.Build.VERSION_CODES;
+import android.text.TextUtils;
+import android.util.Pair;
+import androidx.annotation.RequiresApi;
+import com.google.android.libraries.mobiledatadownload.AccountSource;
+import com.google.android.libraries.mobiledatadownload.AggregateException;
+import com.google.android.libraries.mobiledatadownload.DownloadException;
+import com.google.android.libraries.mobiledatadownload.DownloadException.DownloadResultCode;
+import com.google.android.libraries.mobiledatadownload.FileSource;
+import com.google.android.libraries.mobiledatadownload.Flags;
+import com.google.android.libraries.mobiledatadownload.SilentFeedback;
+import com.google.android.libraries.mobiledatadownload.TimeSource;
+import com.google.android.libraries.mobiledatadownload.account.AccountUtil;
+import com.google.android.libraries.mobiledatadownload.annotations.InstanceId;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.internal.annotations.SequentialControlExecutor;
+import com.google.android.libraries.mobiledatadownload.internal.experimentation.DownloadStageManager;
+import com.google.android.libraries.mobiledatadownload.internal.logging.DownloadStateLogger;
+import com.google.android.libraries.mobiledatadownload.internal.logging.EventLogger;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil;
+import com.google.android.libraries.mobiledatadownload.internal.util.AndroidSharingUtil;
+import com.google.android.libraries.mobiledatadownload.internal.util.AndroidSharingUtil.AndroidSharingException;
+import com.google.android.libraries.mobiledatadownload.internal.util.DirectoryUtil;
+import com.google.android.libraries.mobiledatadownload.internal.util.FileGroupUtil;
+import com.google.android.libraries.mobiledatadownload.internal.util.SymlinkUtil;
+import com.google.android.libraries.mobiledatadownload.tracing.PropagatedFluentFuture;
+import com.google.android.libraries.mobiledatadownload.tracing.PropagatedFutures;
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Function;
+import com.google.common.base.Optional;
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ComparisonChain;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Maps;
+import com.google.common.util.concurrent.AsyncFunction;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.errorprone.annotations.CheckReturnValue;
+import com.google.mobiledatadownload.internal.MetadataProto;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFile;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFile.AndroidSharingType;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupBookkeeping;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal;
+import com.google.mobiledatadownload.internal.MetadataProto.DownloadConditions;
+import com.google.mobiledatadownload.internal.MetadataProto.DownloadConditions.ActivatingCondition;
+import com.google.mobiledatadownload.internal.MetadataProto.DownloadConditions.DeviceNetworkPolicy;
+import com.google.mobiledatadownload.internal.MetadataProto.FileStatus;
+import com.google.mobiledatadownload.internal.MetadataProto.GroupKey;
+import com.google.mobiledatadownload.internal.MetadataProto.GroupKeyProperties;
+import com.google.mobiledatadownload.internal.MetadataProto.NewFileKey;
+import com.google.mobiledatadownload.internal.MetadataProto.SharedFile;
+import com.google.protobuf.Any;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.Executor;
+import java.util.concurrent.atomic.AtomicReference;
+import javax.annotation.Nullable;
+import javax.inject.Inject;
+import org.checkerframework.checker.nullness.compatqual.NullableType;
+
+/**
+ * Keeps track of pending groups to download and stores the downloaded groups for retrieval. It's
+ * not thread safe. Currently it works by being called from a single thread executor.
+ *
+ * <p>Also provides methods to register and verify download complete for all pending downloads.
+ */
+@CheckReturnValue
+public class FileGroupManager {
+
+  /** The current state of the group. */
+  public enum GroupDownloadStatus {
+    /** At least one file has not downloaded fully, but no file download has failed. */
+    PENDING,
+
+    /** All files have successfully downloaded and should now be fully available. */
+    DOWNLOADED,
+
+    /** The download of at least one file failed. */
+    FAILED,
+  }
+
+  private static final String TAG = "FileGroupManager";
+
+  private final Context context;
+  private final EventLogger eventLogger;
+  private final SilentFeedback silentFeedback;
+  private final FileGroupsMetadata fileGroupsMetadata;
+  private final SharedFileManager sharedFileManager;
+  private final TimeSource timeSource;
+  private final SynchronousFileStorage fileStorage;
+  private final Optional<AccountSource> accountSourceOptional;
+  private final Executor sequentialControlExecutor;
+  private final Optional<String> instanceId;
+  private final DownloadStageManager downloadStageManager;
+  private final Flags flags;
+
+  @Inject
+  public FileGroupManager(
+      @ApplicationContext Context context,
+      EventLogger eventLogger,
+      SilentFeedback silentFeedback,
+      FileGroupsMetadata fileGroupsMetadata,
+      SharedFileManager sharedFileManager,
+      TimeSource timeSource,
+      Optional<AccountSource> accountSourceOptional,
+      @SequentialControlExecutor Executor sequentialControlExecutor,
+      @InstanceId Optional<String> instanceId,
+      SynchronousFileStorage fileStorage,
+      DownloadStageManager downloadStageManager,
+      Flags flags) {
+    this.context = context;
+    this.eventLogger = eventLogger;
+    this.silentFeedback = silentFeedback;
+    this.fileGroupsMetadata = fileGroupsMetadata;
+    this.sharedFileManager = sharedFileManager;
+    this.timeSource = timeSource;
+    this.accountSourceOptional = accountSourceOptional;
+    this.sequentialControlExecutor = sequentialControlExecutor;
+    this.instanceId = instanceId;
+    this.fileStorage = fileStorage;
+    this.downloadStageManager = downloadStageManager;
+    this.flags = flags;
+  }
+
+  /**
+   * Adds the given data file group for download.
+   *
+   * <p>Calling this method with the exact same file group multiple times is a no op.
+   *
+   * @param groupKey The key for the group.
+   * @param receivedGroup The File group that needs to be downloaded.
+   * @return A future that resolves to true if the received group was new/upgrade and was
+   *     successfully added, false otherwise.
+   */
+  // TODO(b/124072754): Change to package private once all code is refactored.
+  @SuppressWarnings("nullness")
+  public ListenableFuture<Boolean> addGroupForDownload(
+      GroupKey groupKey, DataFileGroupInternal receivedGroup)
+      throws ExpiredFileGroupException, IOException, UninstalledAppException,
+          ActivationRequiredForGroupException {
+    if (FileGroupUtil.isActiveGroupExpired(receivedGroup, timeSource)) {
+      LogUtil.e("%s: Trying to add expired group %s.", TAG, groupKey.getGroupName());
+      logEventWithDataFileGroup(0, eventLogger, receivedGroup);
+      throw new ExpiredFileGroupException();
+    }
+    if (!isAppInstalled(groupKey.getOwnerPackage())) {
+      LogUtil.e(
+          "%s: Trying to add group %s for uninstalled app %s.",
+          TAG, groupKey.getGroupName(), groupKey.getOwnerPackage());
+      logEventWithDataFileGroup(0, eventLogger, receivedGroup);
+      throw new UninstalledAppException();
+    }
+
+    ListenableFuture<Boolean> resultFuture = immediateFuture(null);
+    if (flags.enableDelayedDownload()
+        && receivedGroup.getDownloadConditions().getActivatingCondition()
+            == ActivatingCondition.DEVICE_ACTIVATED) {
+
+      resultFuture =
+          transformSequentialAsync(
+              fileGroupsMetadata.readGroupKeyProperties(groupKey),
+              groupKeyProperties -> {
+                // It shouldn't make a difference if we found an existing value or not.
+                if (groupKeyProperties == null) {
+                  groupKeyProperties = GroupKeyProperties.getDefaultInstance();
+                }
+
+                if (!groupKeyProperties.getActivatedOnDevice()) {
+                  LogUtil.d(
+                      "%s: Trying to add group %s that requires activation %s.",
+                      TAG, groupKey.getGroupName(), groupKey.getOwnerPackage());
+
+                  logEventWithDataFileGroup(0, eventLogger, receivedGroup);
+
+                  throw new ActivationRequiredForGroupException();
+                }
+                return immediateFuture(null);
+              });
+    }
+
+    return PropagatedFluentFuture.from(resultFuture)
+        .transformAsync(
+            voidArg -> isAddedGroupDuplicate(groupKey, receivedGroup), sequentialControlExecutor)
+        .transformAsync(
+            isDuplicate -> {
+              if (isDuplicate) {
+                LogUtil.d(
+                    "%s: Received duplicate config for group: %s", TAG, groupKey.getGroupName());
+                return immediateFuture(false);
+              }
+              return transformSequentialAsync(
+                  maybeSetGroupNewFilesReceivedTimestamp(groupKey, receivedGroup),
+                  receivedGroupCopy -> {
+                    LogUtil.d(
+                        "%s: Received new config for group: %s", TAG, groupKey.getGroupName());
+
+                    logEventWithDataFileGroup(0, eventLogger, receivedGroupCopy);
+
+                    return transformSequentialAsync(
+                        subscribeGroup(receivedGroupCopy),
+                        subscribed -> {
+                          if (!subscribed) {
+                            throw new IOException("Subscribing to group failed");
+                          }
+
+                          // TODO(b/160164032): if the File Group has new SyncId, clear the old
+                          // sync.
+                          // TODO(b/160164032): triggerSync in daily maintenance for not
+                          // completed groups.
+                          // Write to Metadata then schedule task via SPE.
+                          return transformSequentialAsync(
+                              writeUpdatedGroupToMetadata(groupKey, receivedGroupCopy),
+                              (voidArg) -> {
+                                return immediateFuture(true);
+                              });
+                        });
+                  });
+            },
+            sequentialControlExecutor);
+  }
+
+  private ListenableFuture<Void> writeUpdatedGroupToMetadata(
+      GroupKey groupKey, MetadataProto.DataFileGroupInternal receivedGroupCopy) {
+    // Write the received group as a pending group. If there was a
+    // pending group already present, it will be overwritten and any
+    // files will be garbage collected later.
+    GroupKey pendingGroupKey = groupKey.toBuilder().setDownloaded(false).build();
+
+    ListenableFuture<@NullableType DataFileGroupInternal> toBeOverwrittenPendingGroupFuture =
+        fileGroupsMetadata.read(pendingGroupKey);
+
+    return PropagatedFluentFuture.from(toBeOverwrittenPendingGroupFuture)
+        .transformAsync(
+            nullVoid -> fileGroupsMetadata.write(pendingGroupKey, receivedGroupCopy),
+            sequentialControlExecutor)
+        .transformAsync(
+            writeSuccess -> {
+              if (!writeSuccess) {
+                eventLogger.logEventSampled(0);
+                return immediateFailedFuture(
+                    new IOException("Failed to commit new group metadata to disk."));
+              }
+              return immediateVoidFuture();
+            },
+            sequentialControlExecutor)
+        .transformAsync(
+            nullVoid -> downloadStageManager.updateExperimentIds(receivedGroupCopy.getGroupName()),
+            sequentialControlExecutor)
+        .transformAsync(
+            nullVoid -> {
+              // We need to make sure to clear the experiment ids for this group here, since it will
+              // be overwritten afterwards.
+              DataFileGroupInternal toBeOverwrittenPendingGroup =
+                  Futures.getDone(toBeOverwrittenPendingGroupFuture);
+              if (toBeOverwrittenPendingGroup != null) {
+                return downloadStageManager.clearExperimentIdsForBuildsIfNoneActive(
+                    ImmutableList.of(toBeOverwrittenPendingGroup));
+              }
+
+              return immediateVoidFuture();
+            },
+            sequentialControlExecutor);
+  }
+
+  /**
+   * Removes data file group with the given group key, and cancels any ongoing download of the file
+   * group.
+   *
+   * @param groupKey The key of the data file group to be removed.
+   * @param pendingOnly If true, only remove the pending version of this filegroup.
+   * @return ListenableFuture that may throw an IOException if some error is encountered when
+   *     removing from metadata or a SharedFileMissingException if some of the shared file metadata
+   *     is missing.
+   */
+  ListenableFuture<Void> removeFileGroup(GroupKey groupKey, boolean pendingOnly)
+      throws SharedFileMissingException, IOException {
+    // Remove the pending version from metadata.
+    GroupKey pendingGroupKey = groupKey.toBuilder().setDownloaded(false).build();
+    return transformSequentialAsync(
+        fileGroupsMetadata.read(pendingGroupKey),
+        pendingFileGroup -> {
+          ListenableFuture<Void> removePendingGroupFuture = immediateVoidFuture();
+          if (pendingFileGroup != null) {
+            // Clear Sync Reason before removing the file group.
+            ListenableFuture<Void> clearSyncReasonFuture = immediateVoidFuture();
+            removePendingGroupFuture =
+                transformSequentialAsync(
+                    clearSyncReasonFuture,
+                    voidArg ->
+                        transformSequentialAsync(
+                            fileGroupsMetadata.remove(pendingGroupKey),
+                            removeSuccess -> {
+                              if (!removeSuccess) {
+                                LogUtil.e(
+                                    "%s: Failed to remove pending version for group: '%s';"
+                                        + " account: '%s'",
+                                    TAG, groupKey.getGroupName(), groupKey.getAccount());
+                                eventLogger.logEventSampled(0);
+                                return immediateFailedFuture(
+                                    new IOException(
+                                        "Failed to remove pending group: "
+                                            + groupKey.getGroupName()));
+                              }
+                              return downloadStageManager.clearExperimentIdsForBuildsIfNoneActive(
+                                  ImmutableList.of(pendingFileGroup));
+                            }));
+          }
+          return transformSequentialAsync(
+              removePendingGroupFuture,
+              voidArg0 -> {
+                GroupKey downloadedGroupKey = groupKey.toBuilder().setDownloaded(true).build();
+                return transformSequentialAsync(
+                    fileGroupsMetadata.read(downloadedGroupKey),
+                    downloadedFileGroup -> {
+                      ListenableFuture<Void> removeDownloadedGroupFuture = immediateVoidFuture();
+                      if (downloadedFileGroup != null && !pendingOnly) {
+                        // Remove the downloaded version from metadata.
+                        removeDownloadedGroupFuture =
+                            transformSequentialAsync(
+                                fileGroupsMetadata.remove(downloadedGroupKey),
+                                removeSuccess -> {
+                                  if (!removeSuccess) {
+                                    LogUtil.e(
+                                        "%s: Failed to remove the downloaded version for group:"
+                                            + " '%s'; account: '%s'",
+                                        TAG, groupKey.getGroupName(), groupKey.getAccount());
+                                    eventLogger.logEventSampled(0);
+                                    return immediateFailedFuture(
+                                        new IOException(
+                                            "Failed to remove downloaded group: "
+                                                + groupKey.getGroupName()));
+                                  }
+                                  // Add the downloaded version to stale.
+                                  return transformSequentialAsync(
+                                      fileGroupsMetadata.addStaleGroup(downloadedFileGroup),
+                                      addSuccess -> {
+                                        if (!addSuccess) {
+                                          LogUtil.e(
+                                              "%s: Failed to add to stale for group: '%s';"
+                                                  + " account: '%s'",
+                                              TAG, groupKey.getGroupName(), groupKey.getAccount());
+                                          eventLogger.logEventSampled(0);
+                                          return immediateFailedFuture(
+                                              new IOException(
+                                                  "Failed to add downloaded group to stale: "
+                                                      + groupKey.getGroupName()));
+                                        }
+                                        return downloadStageManager.updateExperimentIds(
+                                            downloadedFileGroup.getGroupName());
+                                      });
+                                });
+                      }
+
+                      return transformSequentialAsync(
+                          removeDownloadedGroupFuture,
+                          voidArg1 -> {
+                            // Cancel any ongoing download of the data files in the file group, if
+                            // the data file
+                            // is not referenced by any fresh group.
+                            if (pendingFileGroup != null) {
+                              return transformSequentialAsync(
+                                  getFileKeysReferencedByFreshGroups(),
+                                  referencedFileKeys -> {
+                                    List<ListenableFuture<Void>> cancelDownloadsFutures =
+                                        new ArrayList<>();
+                                    for (DataFile dataFile : pendingFileGroup.getFileList()) {
+                                      // Skip sideloaded files -- they will not have a pending
+                                      // download by definition
+                                      if (FileGroupUtil.isSideloadedFile(dataFile)) {
+                                        continue;
+                                      }
+
+                                      NewFileKey newFileKey =
+                                          SharedFilesMetadata.createKeyFromDataFile(
+                                              dataFile, pendingFileGroup.getAllowedReadersEnum());
+                                      // Cancel the ongoing download, if the file is not referenced
+                                      // by any fresh file group.
+                                      if (!referencedFileKeys.contains(newFileKey)) {
+                                        cancelDownloadsFutures.add(
+                                            sharedFileManager.cancelDownload(newFileKey));
+                                      }
+                                    }
+                                    return PropagatedFutures.whenAllComplete(cancelDownloadsFutures)
+                                        .call(() -> null, sequentialControlExecutor);
+                                  });
+                            }
+                            return immediateVoidFuture();
+                          });
+                    });
+              });
+        });
+  }
+
+  /**
+   * Removes data file groups with given group keys and cancels any ongoing downloads of the file
+   * groups.
+   *
+   * <p>The following steps are performed for each file group to remove. If any step fails, the
+   * operation stops and failures are returned.
+   *
+   * <ol>
+   *   <li>Clear SPE Sync Reasons (if applicable) and remove pending file group metadata
+   *   <li>Remove downloaded file group metadata
+   *   <li>Move any removed file groups from downloaded to stale
+   *   <li>Remove any pending downloads for files no longer referenced
+   * </ol>
+   *
+   * @param groupKeys Keys of the File Groups to remove
+   * @return ListenableFuture that resolves when file groups have been removed, or fails if unable
+   *     to remove file groups from metadata.
+   */
+  ListenableFuture<Void> removeFileGroups(List<GroupKey> groupKeys) {
+    // Track Pending and Downloaded Group Keys to remove
+    Map<GroupKey, DataFileGroupInternal> pendingGroupsToRemove =
+        Maps.newHashMapWithExpectedSize(groupKeys.size());
+    Map<GroupKey, DataFileGroupInternal> downloadedGroupsToRemove =
+        Maps.newHashMapWithExpectedSize(groupKeys.size());
+
+    // Track Pending File Keys that should be canceled
+    Set<NewFileKey> pendingFileKeysToCancel = new HashSet<>();
+
+    // Track Downloaded File Groups that should be moved to Stale
+    List<DataFileGroupInternal> fileGroupsToAddAsStale = new ArrayList<>(groupKeys.size());
+
+    return PropagatedFluentFuture.from(
+            PropagatedFutures.submitAsync(
+                () -> {
+                  // First, Clear SPE Sync Reasons (if applicable) and remove pending file group
+                  // metadata.
+                  List<ListenableFuture<Void>> clearSpeSyncReasonFutures =
+                      new ArrayList<>(groupKeys.size());
+                  for (GroupKey groupKey : groupKeys) {
+                    GroupKey pendingGroupKey = groupKey.toBuilder().setDownloaded(false).build();
+
+                    clearSpeSyncReasonFutures.add(
+                        PropagatedFluentFuture.from(fileGroupsMetadata.read(pendingGroupKey))
+                            .transformAsync(
+                                pendingFileGroup -> {
+                                  if (pendingFileGroup == null) {
+                                    // no pending group found, return early
+                                    return immediateVoidFuture();
+                                  }
+
+                                  // Pending group exists, add it to remove list
+                                  pendingGroupsToRemove.put(pendingGroupKey, pendingFileGroup);
+
+                                  // Add all pending file keys to cancel
+                                  for (DataFile dataFile : pendingFileGroup.getFileList()) {
+                                    NewFileKey newFileKey =
+                                        SharedFilesMetadata.createKeyFromDataFile(
+                                            dataFile, pendingFileGroup.getAllowedReadersEnum());
+                                    pendingFileKeysToCancel.add(newFileKey);
+                                  }
+
+                                  return Futures.immediateVoidFuture();
+                                },
+                                sequentialControlExecutor));
+                  }
+
+                  return PropagatedFutures.whenAllComplete(clearSpeSyncReasonFutures)
+                      .callAsync(
+                          () -> {
+                            // Throw aggregate exception if any reasons failed.
+                            AggregateException.throwIfFailed(
+                                clearSpeSyncReasonFutures, "Unable to clear SPE Sync Reasons");
+                            return transformSequentialAsync(
+                                fileGroupsMetadata.removeAllGroupsWithKeys(
+                                    ImmutableList.copyOf(pendingGroupsToRemove.keySet())),
+                                removePendingGroupsResult -> {
+                                  if (!removePendingGroupsResult.booleanValue()) {
+                                    LogUtil.e(
+                                        "%s: Failed to remove %d pending versions of %d requested"
+                                            + " groups",
+                                        TAG, pendingGroupsToRemove.size(), groupKeys.size());
+                                    eventLogger.logEventSampled(0);
+                                    return immediateFailedFuture(
+                                        new IOException(
+                                            "Failed to remove pending group keys, count = "
+                                                + groupKeys.size()));
+                                  }
+                                  return downloadStageManager
+                                      .clearExperimentIdsForBuildsIfNoneActive(
+                                          pendingGroupsToRemove.values());
+                                });
+                          },
+                          sequentialControlExecutor);
+                },
+                sequentialControlExecutor))
+        .transformAsync(
+            unused -> {
+              // Second, remove downloaded file group metadata.
+              List<ListenableFuture<Void>> readDownloadedFileGroupFutures =
+                  new ArrayList<>(groupKeys.size());
+              for (GroupKey groupKey : groupKeys) {
+                GroupKey downloadedGroupKey = groupKey.toBuilder().setDownloaded(true).build();
+
+                readDownloadedFileGroupFutures.add(
+                    transformSequentialAsync(
+                        fileGroupsMetadata.read(downloadedGroupKey),
+                        downloadedFileGroup -> {
+                          if (downloadedFileGroup != null) {
+                            // Downloaded group exists, add to remove list
+                            downloadedGroupsToRemove.put(downloadedGroupKey, downloadedFileGroup);
+
+                            // Store downloaded group so it can be moved to stale when all metadata
+                            // is updated.
+                            fileGroupsToAddAsStale.add(downloadedFileGroup);
+                          }
+                          return immediateVoidFuture();
+                        }));
+              }
+
+              return PropagatedFutures.whenAllComplete(readDownloadedFileGroupFutures)
+                  .callAsync(
+                      () -> {
+                        AggregateException.throwIfFailed(
+                            readDownloadedFileGroupFutures,
+                            "Unable to read downloaded file groups to remove");
+                        return transformSequentialAsync(
+                            fileGroupsMetadata.removeAllGroupsWithKeys(
+                                ImmutableList.copyOf(downloadedGroupsToRemove.keySet())),
+                            removeDownloadedGroupsResult -> {
+                              if (!removeDownloadedGroupsResult.booleanValue()) {
+                                LogUtil.e(
+                                    "%s: Failed to remove %d downloaded versions of %d requested"
+                                        + " groups",
+                                    TAG, downloadedGroupsToRemove.size(), groupKeys.size());
+                                eventLogger.logEventSampled(0);
+                                return immediateFailedFuture(
+                                    new IOException(
+                                        "Failed to remove downloaded groups, count = "
+                                            + downloadedGroupsToRemove.size()));
+                              }
+                              return downloadStageManager.clearExperimentIdsForBuildsIfNoneActive(
+                                  downloadedGroupsToRemove.values());
+                            });
+                      },
+                      sequentialControlExecutor);
+            },
+            sequentialControlExecutor)
+        .transformAsync(
+            unused -> {
+              // Third, move any removed file groups from downloaded to stale.
+              // This prevents a files in the group from being removed before its
+              // stale_lifetime_secs has expired.
+              if (downloadedGroupsToRemove.isEmpty()) {
+                // No downloaded groups were removed, return early
+                return immediateVoidFuture();
+              }
+
+              List<ListenableFuture<Void>> addStaleGroupFutures = new ArrayList<>();
+              for (DataFileGroupInternal staleGroup : fileGroupsToAddAsStale) {
+                addStaleGroupFutures.add(
+                    transformSequentialAsync(
+                        fileGroupsMetadata.addStaleGroup(staleGroup),
+                        addStaleGroupResult -> {
+                          if (!addStaleGroupResult.booleanValue()) {
+                            LogUtil.e(
+                                "%s: Failed to add to stale for group: '%s';",
+                                TAG, staleGroup.getGroupName());
+                            eventLogger.logEventSampled(0);
+                            return immediateFailedFuture(
+                                new IOException(
+                                    "Failed to add downloaded group to stale: "
+                                        + staleGroup.getGroupName()));
+                          }
+                          return immediateVoidFuture();
+                        }));
+              }
+              return PropagatedFutures.whenAllComplete(addStaleGroupFutures)
+                  .call(
+                      () -> {
+                        AggregateException.throwIfFailed(
+                            addStaleGroupFutures,
+                            "Unable to add removed downloaded groups as stale");
+                        return null;
+                      },
+                      sequentialControlExecutor);
+            },
+            sequentialControlExecutor)
+        .transformAsync(
+            unused -> {
+              // Fourth, remove any pending downloads for files no longer referenced.
+              // A file that was referenced by a removed file group may still be referenced by an
+              // existing pending group and should not be cancelled. Only cancel pending downloads
+              // that are no longer referenced by any active/pending file groups.
+              if (pendingGroupsToRemove.isEmpty()) {
+                // No pending groups were removed, return early
+                return immediateVoidFuture();
+              }
+
+              return transformSequentialAsync(
+                  getFileKeysReferencedByFreshGroups(),
+                  referencedFileKeys -> {
+                    List<ListenableFuture<Void>> cancelDownloadFutures = new ArrayList<>();
+                    for (NewFileKey newFileKey : pendingFileKeysToCancel) {
+                      // Only cancel file download if it's not referenced by a fresh group
+                      if (!referencedFileKeys.contains(newFileKey)) {
+                        cancelDownloadFutures.add(sharedFileManager.cancelDownload(newFileKey));
+                      }
+                    }
+                    return PropagatedFutures.whenAllComplete(cancelDownloadFutures)
+                        .call(
+                            () -> {
+                              AggregateException.throwIfFailed(
+                                  cancelDownloadFutures,
+                                  "Unable to cancel downloads for removed groups");
+                              return null;
+                            },
+                            sequentialControlExecutor);
+                  });
+            },
+            sequentialControlExecutor);
+  }
+
+  /**
+   * Returns the required version of the group that we have for the given client key.
+   *
+   * <p>If the group is downloaded and requires an isolated structure, this structure is verified
+   * before returning. If we are unable to verify the isolated structure, null will be returned.
+   *
+   * @param groupKey The key for the data to be returned. This is a combination of many parameters
+   *     like group name, user account.
+   * @return A ListenableFuture that resolves to the requested data file group for the given group
+   *     name, if it exists, null otherwise.
+   */
+  // TODO(b/124072754): Change to package private once all code is refactored.
+  public ListenableFuture<@NullableType DataFileGroupInternal> getFileGroup(
+      GroupKey groupKey, boolean downloaded) {
+    GroupKey downloadedKey = groupKey.toBuilder().setDownloaded(downloaded).build();
+    return transformSequentialAsync(
+        fileGroupsMetadata.read(downloadedKey),
+        dataFileGroup ->
+            transformSequentialAsync(
+                // TODO(b/194688687): consider moving this verification to the
+                // MobileDataDownloadManager level since that is where verification happens for
+                // getDataFileUri.
+                maybeVerifyIsolatedStructure(dataFileGroup, downloaded),
+                result -> immediateFuture(result ? dataFileGroup : null)));
+  }
+
+  /**
+   * Returns a file group/state pair based on the given key and additional identifying information.
+   *
+   * <p>This method allows callers to specify identifying information (buildId, variantId and
+   * customPropertyOptional). It is assumed that different identifying information will be used for
+   * pending/downloded states of a file group, so the downloaded status in the given groupKey is not
+   * considered by this method.
+   *
+   * <p>If a group is found, state of the file group (downloaded/pending) and file group will be
+   * returned in a Pair. If a group is not found, null will be returned. The boolean returned will
+   * be true if the group is downloaded and false if the group is pending.
+   *
+   * @param groupKey The key for the data to be returned. This is should include group name, owner
+   *     package and user account
+   * @param buildId The expected buildId of the file group
+   * @param variantId The expected variantId of the file group
+   * @param customPropertyOptional The expected customProperty, if necessary
+   * @return A ListenableFuture that resolves, if the requested group is found, with a Pair
+   *     containing Boolean value of whether or not the Group is downloaded and the Group itself, or
+   *     null otherwise.
+   */
+  private ListenableFuture<@NullableType Pair<Boolean, DataFileGroupInternal>> getGroupPairById(
+      GroupKey groupKey, long buildId, String variantId, Optional<Any> customPropertyOptional) {
+    return transformSequential(
+        fileGroupsMetadata.getAllFreshGroups(),
+        freshGroupPairList -> {
+          for (Pair<GroupKey, DataFileGroupInternal> freshGroupPair : freshGroupPairList) {
+            if (!verifyGroupPairMatchesIdentifiers(
+                freshGroupPair,
+                groupKey.getAccount(),
+                buildId,
+                variantId,
+                customPropertyOptional)) {
+              // Identifiers don't match, continue
+              continue;
+            }
+
+            // Group matches ID, but ensure that it also matches requested group name
+            if (!groupKey.getGroupName().equals(freshGroupPair.first.getGroupName())) {
+              LogUtil.e(
+                  "%s: getGroupPairById: Group %s matches the given buildId = %d and variantId ="
+                      + " %s, but does not match the given group name %s",
+                  TAG,
+                  freshGroupPair.first.getGroupName(),
+                  buildId,
+                  variantId,
+                  groupKey.getGroupName());
+              continue;
+            }
+
+            return Pair.create(freshGroupPair.first.getDownloaded(), freshGroupPair.second);
+          }
+
+          // No compatible group found, return null;
+          return null;
+        });
+  }
+
+  /**
+   * Set the activation status for the group.
+   *
+   * @param groupKey The key for which the activation is to be set.
+   * @param activation Whether the group should be activated or deactivated.
+   * @return future resolving to whether the activation was successful.
+   */
+  public ListenableFuture<Boolean> setGroupActivation(GroupKey groupKey, boolean activation) {
+    return transformSequentialAsync(
+        fileGroupsMetadata.readGroupKeyProperties(groupKey),
+        groupKeyProperties -> {
+          // It shouldn't make a difference if we found an existing value or not.
+          if (groupKeyProperties == null) {
+            groupKeyProperties = GroupKeyProperties.getDefaultInstance();
+          }
+
+          GroupKeyProperties.Builder groupKeyPropertiesBuilder = groupKeyProperties.toBuilder();
+          List<ListenableFuture<Void>> removeGroupFutures = new ArrayList<>();
+          if (activation) {
+            // The group will be added to MDD with the next run of AddFileGroupOperation.
+            groupKeyPropertiesBuilder.setActivatedOnDevice(true);
+          } else {
+            groupKeyPropertiesBuilder.setActivatedOnDevice(false);
+
+            // Remove the existing pending and downloaded groups from MDD in case of deactivation,
+            // if they required activation to be done on the device.
+            GroupKey pendingGroupKey = groupKey.toBuilder().setDownloaded(false).build();
+            removeGroupFutures.add(removeActivatedGroup(pendingGroupKey));
+
+            GroupKey downloadedGroupKey = groupKey.toBuilder().setDownloaded(true).build();
+            removeGroupFutures.add(removeActivatedGroup(downloadedGroupKey));
+          }
+
+          return PropagatedFutures.whenAllComplete(removeGroupFutures)
+              .callAsync(
+                  () ->
+                      fileGroupsMetadata.writeGroupKeyProperties(
+                          groupKey, groupKeyPropertiesBuilder.build()),
+                  sequentialControlExecutor);
+        });
+  }
+
+  private ListenableFuture<Void> removeActivatedGroup(GroupKey groupKey) {
+    return transformSequentialAsync(
+        fileGroupsMetadata.read(groupKey),
+        group -> {
+          if (group != null
+              && group.getDownloadConditions().getActivatingCondition()
+                  == ActivatingCondition.DEVICE_ACTIVATED) {
+            return transformSequentialAsync(
+                fileGroupsMetadata.remove(groupKey),
+                removeSuccess -> {
+                  if (!removeSuccess) {
+                    eventLogger.logEventSampled(0);
+                  }
+                  return immediateVoidFuture();
+                });
+          }
+          return immediateVoidFuture();
+        });
+  }
+
+  /**
+   * Import inline files into an existing DataFileGroup and update its metadata accordingly.
+   *
+   * <p>The given GroupKey will be used to check for an existing DataFileGroup to update and the
+   * given identifying information (buildId, variantId, customProperty) will be used to ensure an
+   * existing file group matches the caller expected version. An import will only take place if an
+   * existing file group of the same version is found.
+   *
+   * <p>Once a valid file group is found, the given updatedDataFileList will be merged into it. If a
+   * DataFile exists in both updatedDataFileList and the existing DataFileGroup (the fileId is the
+   * same), updatedDataFileList's version will be preferred. The resulting merged File Group will be
+   * used to determine which files need to be imported.
+   *
+   * <p>Only files in the updated File Group will be imported (the inlineFileMap may contain extra
+   * files, but they will not be imported).
+   *
+   * <p>This method is an atomic operation: all files must be successfully imported before the
+   * merged file group is written back to MDD metadata. A failure to import any file will result in
+   * no change to the existing metadata and a this failure will be returned.
+   *
+   * @param groupKey The key of the existing group to update
+   * @param buildId build id to identify the file group to update
+   * @param variantId variant id to identify the file group to update
+   * @param updatedDataFileList list of DataFiles to import into the file group
+   * @param inlineFileMap Map of inline file sources that will be imported, where the key is file id
+   *     and the values are {@link FileSource}s containing file content
+   * @param customPropertyOptional Optional custom property used to identify the file group to
+   *     update
+   * @param customFileGroupValidator Validation that runs after the file group is downloaded but
+   *     before the file group leaves the pending state.
+   * @return A ListenableFuture that resolves when inline files have successfully imported
+   */
+  ListenableFuture<Void> importFilesIntoFileGroup(
+      GroupKey groupKey,
+      long buildId,
+      String variantId,
+      ImmutableList<DataFile> updatedDataFileList,
+      ImmutableMap<String, FileSource> inlineFileMap,
+      Optional<Any> customPropertyOptional,
+      AsyncFunction<DataFileGroupInternal, Boolean> customFileGroupValidator) {
+    DownloadStateLogger downloadStateLogger = DownloadStateLogger.forImport(eventLogger);
+
+    // Get group that should be updated for import, or return group not found failure
+    ListenableFuture<Pair<Boolean, DataFileGroupInternal>> groupPairToUpdateFuture =
+        transformSequentialAsync(
+            getGroupPairById(groupKey, buildId, variantId, customPropertyOptional),
+            foundGroupPair -> {
+              if (foundGroupPair == null) {
+                // Group with identifiers could not be found, return failure.
+                LogUtil.e(
+                    "%s: importFiles for group name: %s, buildId: %d, variantId: %s, but no group"
+                        + " was found",
+                    TAG, groupKey.getGroupName(), buildId, variantId);
+                return immediateFailedFuture(
+                    DownloadException.builder()
+                        .setDownloadResultCode(DownloadResultCode.GROUP_NOT_FOUND_ERROR)
+                        .setMessage(
+                            "file group: "
+                                + groupKey.getGroupName()
+                                + " not found! Make sure addFileGroup has been called.")
+                        .build());
+              }
+
+              // wrap in checkNotNull to ensure type safety.
+              return immediateFuture(checkNotNull(foundGroupPair));
+            });
+
+    return PropagatedFluentFuture.from(groupPairToUpdateFuture)
+        .transformAsync(
+            groupPairToUpdate -> {
+              // Perform an in-memory merge of updatedDataFileList into the group, so we get the
+              // correct list of files to import.
+              DataFileGroupInternal mergedFileGroup =
+                  mergeFilesIntoFileGroup(updatedDataFileList, groupPairToUpdate.second);
+
+              // Log the start of the import now that we have the group.
+              downloadStateLogger.logStarted(mergedFileGroup);
+
+              // Reserve file entries in case any new DataFiles were included in the merge. This
+              // will be a no-op for existing DataFiles.
+              return transformSequentialAsync(
+                  subscribeGroup(mergedFileGroup),
+                  subscribed -> {
+                    if (!subscribed) {
+                      return immediateFailedFuture(
+                          DownloadException.builder()
+                              .setDownloadResultCode(
+                                  DownloadResultCode.UNABLE_TO_RESERVE_FILE_ENTRY)
+                              .setMessage(
+                                  "Failed to reserve new file entries for group: "
+                                      + mergedFileGroup.getGroupName())
+                              .build());
+                    }
+                    return immediateFuture(mergedFileGroup);
+                  });
+            },
+            sequentialControlExecutor)
+        .transformAsync(
+            mergedFileGroup -> {
+              boolean groupIsDownloaded = Futures.getDone(groupPairToUpdateFuture).first;
+
+              // If we are updating a pending group and the import is successful, the pending
+              // version should be removed from metadata.
+              boolean removePendingVersion = !groupIsDownloaded;
+
+              List<ListenableFuture<Void>> allImportFutures =
+                  startImportFutures(groupKey, mergedFileGroup, inlineFileMap);
+
+              // Combine Futures using whenAllComplete so all imports are attempted, even if some
+              // fail.
+              ListenableFuture<GroupDownloadStatus> combinedImportFuture =
+                  PropagatedFutures.whenAllComplete(allImportFutures)
+                      .callAsync(
+                          () ->
+                              verifyGroupDownloaded(
+                                  groupKey,
+                                  mergedFileGroup,
+                                  removePendingVersion,
+                                  customFileGroupValidator,
+                                  downloadStateLogger),
+                          sequentialControlExecutor);
+              return transformSequentialAsync(
+                  combinedImportFuture,
+                  groupDownloadStatus -> {
+                    // If the imports failed, we should return this immediately.
+                    AggregateException.throwIfFailed(
+                        allImportFutures,
+                        "Failed to import files, %d attempted",
+                        allImportFutures.size());
+
+                    // We log other results in verifyGroupDownloaded, so only check for
+                    // downloaded here.
+                    if (groupDownloadStatus == GroupDownloadStatus.DOWNLOADED) {
+                      eventLogger.logMddDownloadResult(0, null);
+                      // group downloaded, so it will be written in verifyGroupDownloaded, return
+                      // early.
+                      return immediateVoidFuture();
+                    }
+
+                    // Group to update is pending or failed. However, this state is not due to the
+                    // import futures (which all succeeded). Therefore, we are safe to write
+                    // merged file group to metadata using the original state (downloaded/pending)
+                    // as before.
+                    return transformSequentialAsync(
+                        fileGroupsMetadata.write(
+                            groupKey.toBuilder().setDownloaded(groupIsDownloaded).build(),
+                            mergedFileGroup),
+                        writeSuccess -> {
+                          if (!writeSuccess) {
+                            eventLogger.logEventSampled(0);
+                            return immediateFailedFuture(
+                                DownloadException.builder()
+                                    .setMessage(
+                                        "File Import(s) succeeded, but failed to save MDD state.")
+                                    .setDownloadResultCode(
+                                        DownloadResultCode.UNABLE_TO_UPDATE_GROUP_METADATA_ERROR)
+                                    .build());
+                          }
+                          return immediateVoidFuture();
+                        });
+                  });
+            },
+            sequentialControlExecutor)
+        .catchingAsync(
+            Exception.class,
+            exception -> {
+              // Log DownloadException (or multiple DownloadExceptions if wrapped in
+              // AggregateException) for debugging.
+              ListenableFuture<Void> resultFuture = immediateVoidFuture();
+              if (exception instanceof DownloadException) {
+                LogUtil.d("%s: Logging DownloadException", TAG);
+
+                DownloadException downloadException = (DownloadException) exception;
+                resultFuture =
+                    transformSequentialAsync(
+                        resultFuture,
+                        voidArg ->
+                            logDownloadFailure(groupKey, downloadException, buildId, variantId));
+              } else if (exception instanceof AggregateException) {
+                LogUtil.d("%s: Logging AggregateException", TAG);
+
+                AggregateException aggregateException = (AggregateException) exception;
+                for (Throwable throwable : aggregateException.getFailures()) {
+                  if (!(throwable instanceof DownloadException)) {
+                    LogUtil.e("%s: Expecting DownloadExceptions in AggregateException", TAG);
+                    continue;
+                  }
+
+                  DownloadException downloadException = (DownloadException) throwable;
+                  resultFuture =
+                      transformSequentialAsync(
+                          resultFuture,
+                          voidArg ->
+                              logDownloadFailure(groupKey, downloadException, buildId, variantId));
+                }
+              }
+
+              // Always return failure to upstream callers for further error handling.
+              return transformSequentialAsync(
+                  resultFuture, voidArg -> immediateFailedFuture(exception));
+            },
+            sequentialControlExecutor);
+  }
+
+  /**
+   * Verifies file group pair matches given identifiers.
+   *
+   * <p>The following properties are checked to ensure the same id of a file group:
+   *
+   * <ul>
+   *   <li>account
+   *   <li>build id
+   *   <li>variant id
+   *   <li>custom property
+   * </ul>
+   */
+  private static boolean verifyGroupPairMatchesIdentifiers(
+      Pair<GroupKey, DataFileGroupInternal> groupPair,
+      String serializedAccount,
+      long buildId,
+      String variantId,
+      Optional<Any> customPropertyOptional) {
+    DataFileGroupInternal fileGroup = groupPair.second;
+    if (!groupPair.first.getAccount().equals(serializedAccount)) {
+      LogUtil.v(
+          "%s: verifyGroupPairMatchesIdentifiers failed for group %s due to mismatched account",
+          TAG, fileGroup.getGroupName());
+      return false;
+    }
+    if (fileGroup.getBuildId() != buildId) {
+      LogUtil.v(
+          "%s: verifyGroupPairMatchesIdentifiers failed for group %s due to mismatched buildId:"
+              + " existing = %d, expected = %d",
+          TAG, fileGroup.getGroupName(), fileGroup.getBuildId(), buildId);
+      return false;
+    }
+    if (!variantId.equals(fileGroup.getVariantId())) {
+      LogUtil.v(
+          "%s: verifyGroupPairMatchesIdentifiers failed for group %s due to mismatched"
+              + " variantId: existing = %s, expected = %s",
+          TAG, fileGroup.getGroupName(), fileGroup.getVariantId(), variantId);
+      return false;
+    }
+
+    Optional<Any> existingCustomPropertyOptional =
+        fileGroup.hasCustomProperty()
+            ? Optional.of(fileGroup.getCustomProperty())
+            : Optional.absent();
+    if (!existingCustomPropertyOptional.equals(customPropertyOptional)) {
+      LogUtil.v(
+          "%s: verifyGroupPairMatchesIdentifiers failed for group %s due to mismatched custom"
+              + " property optional: existing = %s, expected = %s",
+          TAG, fileGroup.getGroupName(), existingCustomPropertyOptional, customPropertyOptional);
+      return false;
+    }
+    return true;
+  }
+
+  /**
+   * Merge files from a List of DataFiles into a File Group.
+   *
+   * <p>The merge operation will "override" DataFiles of {@code existingFileGroup} with DataFiles
+   * from {@code dataFileList} if they share the same fileIds. DataFiles that are in {@code
+   * existingFileGroup} but not in {@code dataFileList} will remain unchanged. DataFiles which are
+   * in {@code dataFileList} but not {@code existingFileGroup} will be appended to the file list.
+   *
+   * @param dataFileList file list to merge into existing file group
+   * @param existingFileGroup existing file group to contain file list
+   * @return LF of a "merged" file group with files from {@code dataFileList} and any non-updated
+   *     files from {@code existingFileGroup}
+   */
+  private static DataFileGroupInternal mergeFilesIntoFileGroup(
+      ImmutableList<DataFile> dataFileList, DataFileGroupInternal existingFileGroup) {
+    // Start with existingFileGroup's properties, but clear the file list
+    DataFileGroupInternal.Builder mergedGroupBuilder = existingFileGroup.toBuilder().clearFile();
+
+    // Use a map to track files by fileId
+    Map<String, DataFile> fileMap = new HashMap<>();
+
+    // Add all files from existing file group to map first
+    for (DataFile file : existingFileGroup.getFileList()) {
+      fileMap.put(file.getFileId(), file);
+    }
+
+    // Add all files from data file list to map second, ensuring new files update the existing
+    // entries
+    for (DataFile file : dataFileList) {
+      fileMap.put(file.getFileId(), file);
+    }
+
+    // Add all files from map to the group and build
+    return mergedGroupBuilder.addAllFile(fileMap.values()).build();
+  }
+
+  /** Starts imports of inline files in given group. */
+  private List<ListenableFuture<Void>> startImportFutures(
+      GroupKey groupKey,
+      DataFileGroupInternal pendingGroup,
+      Map<String, FileSource> inlineFileMap) {
+    List<ListenableFuture<Void>> allImportFutures = new ArrayList<>();
+    for (DataFile dataFile : pendingGroup.getFileList()) {
+      if (!dataFile.getUrlToDownload().startsWith(MddConstants.INLINE_FILE_URL_SCHEME)) {
+        // Skip non-inline files
+        continue;
+      }
+      NewFileKey newFileKey =
+          SharedFilesMetadata.createKeyFromDataFile(dataFile, pendingGroup.getAllowedReadersEnum());
+
+      allImportFutures.add(
+          transformSequentialAsync(
+              sharedFileManager.getFileStatus(newFileKey),
+              fileStatus -> {
+                if (fileStatus.equals(FileStatus.DOWNLOAD_COMPLETE)) {
+                  // file already downloaded, return immediately
+                  return immediateVoidFuture();
+                }
+
+                // File needs to be downloaded, check that inline file source is available
+                if (!inlineFileMap.containsKey(dataFile.getFileId())) {
+                  LogUtil.e(
+                      "%s:Attempt to import file without inline file source. Id = %s",
+                      TAG, dataFile.getFileId());
+                  return immediateFailedFuture(
+                      DownloadException.builder()
+                          .setDownloadResultCode(DownloadResultCode.MISSING_INLINE_FILE_SOURCE)
+                          .build());
+                }
+
+                // File source is provided, proceed with import.
+                // NOTE: the use of checkNotNull here is fine since we explicitly check that map
+                // contains the source above.
+                return sharedFileManager.startImport(
+                    groupKey,
+                    dataFile,
+                    newFileKey,
+                    pendingGroup.getDownloadConditions(),
+                    checkNotNull(inlineFileMap.get(dataFile.getFileId())));
+              }));
+    }
+
+    return allImportFutures;
+  }
+
+  /**
+   * Initiates download of the file group and returns a listenable future to track it. The
+   * ListenableFuture resolves to the non-null DataFileGroup if the group is successfully
+   * downloaded. Otherwise it returns a null.
+   *
+   * @param groupKey The key of the group to schedule for download.
+   * @param downloadConditions The download conditions that we should download the group under.
+   * @return the ListenableFuture of the download of all files in the file group.
+   */
+  // TODO(b/124072754): Change to package private once all code is refactored.
+  public ListenableFuture<DataFileGroupInternal> downloadFileGroup(
+      GroupKey groupKey,
+      @Nullable DownloadConditions downloadConditionsParam,
+      AsyncFunction<DataFileGroupInternal, Boolean> customFileGroupValidator) {
+
+    // Capture a reference to the DataFileGroup so we can include build id and variant id in our
+    // logs.
+    AtomicReference<@NullableType DataFileGroupInternal> fileGroupForLogging =
+        new AtomicReference<>();
+
+    ListenableFuture<DataFileGroupInternal> downloadFuture =
+        transformSequentialAsync(
+            getFileGroup(groupKey, false /* downloaded */),
+            pendingGroup -> {
+              if (pendingGroup == null) {
+                // There is no pending group. See if there is a downloaded version and return if it
+                // exists.
+                return transformSequentialAsync(
+                    getFileGroup(groupKey, true /* downloaded */),
+                    downloadedGroup -> {
+                      if (downloadedGroup == null) {
+                        return immediateFailedFuture(
+                            DownloadException.builder()
+                                .setDownloadResultCode(DownloadResultCode.GROUP_NOT_FOUND_ERROR)
+                                .setMessage(
+                                    "Nothing to download for file group: "
+                                        + groupKey.getGroupName())
+                                .build());
+                      }
+                      fileGroupForLogging.set(downloadedGroup);
+                      return immediateFuture(downloadedGroup);
+                    });
+              }
+              fileGroupForLogging.set(pendingGroup);
+
+              // Set the download started timestamp and log download started event.
+              return PropagatedFluentFuture.from(
+                      updateBookkeepingOnStartDownload(groupKey, pendingGroup))
+                  .catchingAsync(
+                      IOException.class,
+                      ex ->
+                          immediateFailedFuture(
+                              DownloadException.builder()
+                                  .setDownloadResultCode(
+                                      DownloadResultCode.UNABLE_TO_UPDATE_GROUP_METADATA_ERROR)
+                                  .setCause(ex)
+                                  .build()),
+                      sequentialControlExecutor)
+                  .transformAsync(
+                      updatedPendingGroup -> {
+                        List<ListenableFuture<Void>> allFileFutures =
+                            startDownloadFutures(
+                                downloadConditionsParam, updatedPendingGroup, groupKey);
+                        // Note: We use whenAllComplete instead of whenAllSucceed since we want to
+                        // continue to download all other files even if one or more fail. Verify the
+                        // file group.
+                        return PropagatedFutures.whenAllComplete(allFileFutures)
+                            .callAsync(
+                                () ->
+                                    transformSequentialAsync(
+                                        verifyPendingGroupDownloaded(
+                                            groupKey,
+                                            updatedPendingGroup,
+                                            customFileGroupValidator),
+                                        groupDownloadStatus ->
+                                            finalizeDownloadFileFutures(
+                                                allFileFutures,
+                                                groupDownloadStatus,
+                                                updatedPendingGroup,
+                                                groupKey)),
+                                sequentialControlExecutor);
+                      },
+                      sequentialControlExecutor);
+            });
+
+    return PropagatedFutures.catchingAsync(
+        downloadFuture,
+        Exception.class,
+        exception -> {
+          DataFileGroupInternal dfgInternal = fileGroupForLogging.get();
+
+          final DataFileGroupInternal finalDfgInternal =
+              (dfgInternal == null) ? DataFileGroupInternal.getDefaultInstance() : dfgInternal;
+
+          ListenableFuture<Void> resultFuture = immediateVoidFuture();
+          if (exception instanceof DownloadException) {
+            LogUtil.d("%s: Logging DownloadException", TAG);
+
+            DownloadException downloadException = (DownloadException) exception;
+            resultFuture =
+                transformSequentialAsync(
+                    resultFuture,
+                    voidArg ->
+                        logDownloadFailure(
+                            groupKey,
+                            downloadException,
+                            finalDfgInternal.getBuildId(),
+                            finalDfgInternal.getVariantId()));
+          } else if (exception instanceof AggregateException) {
+            LogUtil.d("%s: Logging AggregateException", TAG);
+
+            AggregateException aggregateException = (AggregateException) exception;
+            for (Throwable throwable : aggregateException.getFailures()) {
+              if (!(throwable instanceof DownloadException)) {
+                LogUtil.e("%s: Expecting DownloadException's in AggregateException", TAG);
+                continue;
+              }
+
+              DownloadException downloadException = (DownloadException) throwable;
+              resultFuture =
+                  transformSequentialAsync(
+                      resultFuture,
+                      voidArg ->
+                          logDownloadFailure(
+                              groupKey,
+                              downloadException,
+                              finalDfgInternal.getBuildId(),
+                              finalDfgInternal.getVariantId()));
+            }
+          }
+          return transformSequentialAsync(
+              resultFuture,
+              voidArg -> {
+                throw exception;
+              });
+        },
+        sequentialControlExecutor);
+  }
+
+  private List<ListenableFuture<Void>> startDownloadFutures(
+      @Nullable DownloadConditions downloadConditions,
+      DataFileGroupInternal pendingGroup,
+      GroupKey groupKey) {
+    // If absent, use the config from server.
+    DownloadConditions downloadConditionsFinal =
+        downloadConditions != null ? downloadConditions : pendingGroup.getDownloadConditions();
+
+    List<ListenableFuture<Void>> allFileFutures = new ArrayList<>();
+    for (DataFile dataFile : pendingGroup.getFileList()) {
+      // Skip sideloaded files -- they, by definition, can't be downloaded.
+      if (FileGroupUtil.isSideloadedFile(dataFile)) {
+        continue;
+      }
+      NewFileKey newFileKey =
+          SharedFilesMetadata.createKeyFromDataFile(dataFile, pendingGroup.getAllowedReadersEnum());
+      ListenableFuture<Void> fileFuture;
+      if (VERSION.SDK_INT >= VERSION_CODES.R) {
+        ListenableFuture<Void> tryToShareBeforeDownload =
+            tryToShareBeforeDownload(pendingGroup, dataFile, newFileKey);
+        fileFuture =
+            transformSequentialAsync(
+                tryToShareBeforeDownload,
+                (voidArg) -> {
+                  ListenableFuture<Void> startDownloadFuture;
+                  try {
+                    startDownloadFuture =
+                        sharedFileManager.startDownload(
+                            groupKey,
+                            dataFile,
+                            newFileKey,
+                            downloadConditionsFinal,
+                            pendingGroup.getTrafficTag(),
+                            pendingGroup.getGroupExtraHttpHeadersList());
+                  } catch (RuntimeException e) {
+                    // Catch any unchecked exceptions that prevented the download from starting.
+                    return immediateFailedFuture(
+                        DownloadException.builder()
+                            .setDownloadResultCode(DownloadResultCode.UNKNOWN_ERROR)
+                            .setCause(e)
+                            .build());
+                  }
+                  // After file as being downloaded locally
+                  return transformSequentialAsync(
+                      startDownloadFuture,
+                      (downloadResult) ->
+                          tryToShareAfterDownload(pendingGroup, dataFile, newFileKey));
+                });
+      } else {
+        try {
+          fileFuture =
+              sharedFileManager.startDownload(
+                  groupKey,
+                  dataFile,
+                  newFileKey,
+                  downloadConditionsFinal,
+                  pendingGroup.getTrafficTag(),
+                  pendingGroup.getGroupExtraHttpHeadersList());
+        } catch (RuntimeException e) {
+          // Catch any unchecked exceptions that prevented the download from starting.
+          fileFuture =
+              immediateFailedFuture(
+                  DownloadException.builder()
+                      .setDownloadResultCode(DownloadResultCode.UNKNOWN_ERROR)
+                      .setCause(e)
+                      .build());
+        }
+      }
+      allFileFutures.add(fileFuture);
+    }
+    return allFileFutures;
+  }
+
+  // Requires that all futures in allFileFutures are completed.
+  private ListenableFuture<DataFileGroupInternal> finalizeDownloadFileFutures(
+      List<ListenableFuture<Void>> allFileFutures,
+      GroupDownloadStatus groupDownloadStatus,
+      DataFileGroupInternal pendingGroup,
+      GroupKey groupKey)
+      throws AggregateException, DownloadException {
+    // TODO(b/136112848): When all fileFutures succeed, we don't need to verify them again. However
+    // we still need logic to remove pending and update stale group.
+    if (groupDownloadStatus != GroupDownloadStatus.DOWNLOADED) {
+      LogUtil.e(
+          "%s downloadFileGroup %s %s can't finish!",
+          TAG, groupKey.getGroupName(), groupKey.getOwnerPackage());
+
+      AggregateException.throwIfFailed(
+          allFileFutures, "Failed to download file group %s", groupKey.getGroupName());
+
+      // TODO(b/118137672): Investigate on the unknown error that we've missed. There is a download
+      // failure that we don't recognize.
+      LogUtil.e("%s: An unknown error has occurred during" + " download", TAG);
+      throw DownloadException.builder()
+          .setDownloadResultCode(DownloadResultCode.UNKNOWN_ERROR)
+          .build();
+    }
+
+    eventLogger.logMddDownloadResult(0, null);
+    return immediateFuture(pendingGroup);
+  }
+
+  /**
+   * If the file is available in the shared blob storage, it acquires the lease and updates the
+   * shared file metadata. The {@code FileStatus} will be set to DOWNLOAD_COMPLETE so that the file
+   * won't be downloaded again.
+   *
+   * <p>The file is available in the shared blob storage if:
+   *
+   * <ul>
+   *   <li>the file is already available in the shared storage, or
+   *   <li>the file can be copied from the local MDD storage to the shared storage
+   * </ul>
+   *
+   * NOTE: we copy the file only if the file is configured to be shared through the {@code
+   * android_sharing_type} field.
+   *
+   * <p>NOTE: android-sharing is a best effort feature, hence if an error occurs while trying to
+   * share a file, the download operation won't be stopped.
+   *
+   * @return ListenableFuture that may throw a SharedFileMissingException if the shared file
+   *     metadata is missing.
+   */
+  ListenableFuture<Void> tryToShareBeforeDownload(
+      DataFileGroupInternal fileGroup, DataFile dataFile, NewFileKey newFileKey) {
+    ListenableFuture<SharedFile> sharedFileFuture =
+        PropagatedFutures.catchingAsync(
+            sharedFileManager.getSharedFile(newFileKey),
+            SharedFileMissingException.class,
+            e -> {
+              // TODO(b/131166925): MDD dump should not use lite proto toString.
+              LogUtil.e("%s: Shared file not found, newFileKey = %s", TAG, newFileKey);
+              silentFeedback.send(e, "Shared file not found in downloadFileGroup");
+              logMddAndroidSharingLog(eventLogger, fileGroup, dataFile, 0);
+              return immediateFailedFuture(e);
+            },
+            sequentialControlExecutor);
+    return transformSequentialAsync(
+        sharedFileFuture,
+        sharedFile -> {
+          long fileExpirationDateSecs = fileGroup.getExpirationDateSecs();
+          try {
+            // case 1: the file is already shared in the blob storage.
+            if (sharedFile.getAndroidShared()) {
+              LogUtil.d(
+                  "%s: Android sharing CASE 1 for file %s, filegroup %s",
+                  TAG, dataFile.getFileId(), fileGroup.getGroupName());
+              return transformSequentialAsync(
+                  maybeUpdateLeaseAndSharedMetadata(
+                      fileGroup,
+                      dataFile,
+                      sharedFile,
+                      newFileKey,
+                      sharedFile.getAndroidSharingChecksum(),
+                      fileExpirationDateSecs,
+                      0),
+                  res -> immediateVoidFuture());
+            }
+
+            String androidSharingChecksum = dataFile.getAndroidSharingChecksum();
+            if (!TextUtils.isEmpty(androidSharingChecksum)) {
+              // case 2: the file is available in the blob storage.
+              if (AndroidSharingUtil.blobExists(
+                  context, androidSharingChecksum, fileGroup, dataFile, fileStorage)) {
+                LogUtil.d(
+                    "%s: Android sharing CASE 2 for file %s, filegroup %s",
+                    TAG, dataFile.getFileId(), fileGroup.getGroupName());
+                return transformSequentialAsync(
+                    maybeUpdateLeaseAndSharedMetadata(
+                        fileGroup,
+                        dataFile,
+                        sharedFile,
+                        newFileKey,
+                        androidSharingChecksum,
+                        fileExpirationDateSecs,
+                        0),
+                    res -> immediateVoidFuture());
+              }
+
+              // case 3: the to-be-shared file is available in the local storage.
+              if (dataFile.getAndroidSharingType()
+                      == DataFile.AndroidSharingType.ANDROID_BLOB_WHEN_AVAILABLE
+                  && sharedFile.getFileStatus() == FileStatus.DOWNLOAD_COMPLETE) {
+                LogUtil.d(
+                    "%s: Android sharing CASE 3 for file %s, filegroup %s",
+                    TAG, dataFile.getFileId(), fileGroup.getGroupName());
+                Uri downloadFileOnDeviceUri = getLocalUri(dataFile, newFileKey, sharedFile);
+                AndroidSharingUtil.copyFileToBlobStore(
+                    context,
+                    androidSharingChecksum,
+                    downloadFileOnDeviceUri,
+                    fileGroup,
+                    dataFile,
+                    fileStorage,
+                    /* afterDownload = */ false);
+                return transformSequentialAsync(
+                    maybeUpdateLeaseAndSharedMetadata(
+                        fileGroup,
+                        dataFile,
+                        sharedFile,
+                        newFileKey,
+                        androidSharingChecksum,
+                        fileExpirationDateSecs,
+                        0),
+                    res -> immediateVoidFuture());
+              }
+            }
+          } catch (AndroidSharingException e) {
+            logMddAndroidSharingLog(eventLogger, fileGroup, dataFile, e.getErrorCode());
+          }
+          LogUtil.d(
+              "%s: File couldn't be shared before download %s, filegroup %s",
+              TAG, dataFile.getFileId(), fileGroup.getGroupName());
+          return immediateVoidFuture();
+        });
+  }
+
+  /**
+   * If sharing the file succeeds, it acquires the lease, updates the file status and deletes the
+   * local copy.
+   *
+   * <p>Sharing the file succeeds if:
+   *
+   * <ul>
+   *   <li>the file is already available in the shared storage, or
+   *   <li>the file can be copied from the local MDD storage to the shared storage
+   * </ul>
+   *
+   * NOTE: we copy the file only if the file is configured to be shared through the {@code
+   * android_sharing_type} field.
+   *
+   * <p>NOTE: android-sharing is a best effort feature, hence if the file was downlaoded
+   * successfully and an error occurs while trying to share it, the file will be stored locally.
+   *
+   * @return ListenableFuture that may throw a SharedFileMissingException if the shared file
+   *     metadata is missing.
+   */
+  ListenableFuture<Void> tryToShareAfterDownload(
+      DataFileGroupInternal fileGroup, DataFile dataFile, NewFileKey newFileKey) {
+    ListenableFuture<SharedFile> sharedFileFuture =
+        PropagatedFutures.catchingAsync(
+            sharedFileManager.getSharedFile(newFileKey),
+            SharedFileMissingException.class,
+            e -> {
+              // TODO(b/131166925): MDD dump should not use lite proto toString.
+              LogUtil.e("%s: Shared file not found, newFileKey = %s", TAG, newFileKey);
+              silentFeedback.send(e, "Shared file not found in downloadFileGroup");
+              logMddAndroidSharingLog(eventLogger, fileGroup, dataFile, 0);
+              return immediateFailedFuture(e);
+            },
+            sequentialControlExecutor);
+    return transformSequentialAsync(
+        sharedFileFuture,
+        sharedFile -> {
+          String androidSharingChecksum = dataFile.getAndroidSharingChecksum();
+          long fileExpirationDateSecs = fileGroup.getExpirationDateSecs();
+          // NOTE: if the file wasn't downloaded this method should be no-op.
+          if (sharedFile.getFileStatus() != FileStatus.DOWNLOAD_COMPLETE) {
+            return immediateVoidFuture();
+          }
+
+          if (sharedFile.getAndroidShared()) {
+            // If the file had been android-shared in another file group while this file instance
+            // was being downloaded, update the lease if necessary.
+            if (shouldUpdateMaxExpiryDate(sharedFile, fileExpirationDateSecs)) {
+              LogUtil.d(
+                  "%s: File already shared after downloaded but lease has to be updated"
+                      + " for file %s, filegroup %s",
+                  TAG, dataFile.getFileId(), fileGroup.getGroupName());
+              return transformSequentialAsync(
+                  maybeUpdateLeaseAndSharedMetadata(
+                      fileGroup,
+                      dataFile,
+                      sharedFile,
+                      newFileKey,
+                      sharedFile.getAndroidSharingChecksum(),
+                      fileExpirationDateSecs,
+                      0),
+                  res -> {
+                    if (!res) {
+                      return updateMaxExpirationDateSecs(
+                          fileGroup, dataFile, newFileKey, fileExpirationDateSecs);
+                    }
+                    return immediateVoidFuture();
+                  });
+            }
+            return immediateVoidFuture();
+          }
+          try {
+            if (!TextUtils.isEmpty(androidSharingChecksum)) {
+              Uri downloadFileOnDeviceUri = getLocalUri(dataFile, newFileKey, sharedFile);
+              // case 1: the file is available in the blob storage.
+              if (AndroidSharingUtil.blobExists(
+                  context, androidSharingChecksum, fileGroup, dataFile, fileStorage)) {
+                LogUtil.d(
+                    "%s: Android sharing after downloaded, CASE 1 for file %s, filegroup %s",
+                    TAG, dataFile.getFileId(), fileGroup.getGroupName());
+                return transformSequentialAsync(
+                    maybeUpdateLeaseAndSharedMetadata(
+                        fileGroup,
+                        dataFile,
+                        sharedFile,
+                        newFileKey,
+                        androidSharingChecksum,
+                        fileExpirationDateSecs,
+                        0),
+                    res -> {
+                      if (res) {
+                        deleteLocalCopy(downloadFileOnDeviceUri, fileGroup, dataFile);
+                        return immediateVoidFuture();
+                      }
+                      return updateMaxExpirationDateSecs(
+                          fileGroup, dataFile, newFileKey, fileExpirationDateSecs);
+                    });
+              }
+
+              // case 2: the file is configured to be shared.
+              if (dataFile.getAndroidSharingType()
+                  == DataFile.AndroidSharingType.ANDROID_BLOB_WHEN_AVAILABLE) {
+                LogUtil.d(
+                    "%s: Android sharing after downloaded, CASE 2 for file %s, filegroup %s",
+                    TAG, dataFile.getFileId(), fileGroup.getGroupName());
+                AndroidSharingUtil.copyFileToBlobStore(
+                    context,
+                    androidSharingChecksum,
+                    downloadFileOnDeviceUri,
+                    fileGroup,
+                    dataFile,
+                    fileStorage,
+                    /* afterDownload = */ true);
+                return transformSequentialAsync(
+                    maybeUpdateLeaseAndSharedMetadata(
+                        fileGroup,
+                        dataFile,
+                        sharedFile,
+                        newFileKey,
+                        androidSharingChecksum,
+                        fileExpirationDateSecs,
+                        0),
+                    res -> {
+                      if (res) {
+                        deleteLocalCopy(downloadFileOnDeviceUri, fileGroup, dataFile);
+                        return immediateVoidFuture();
+                      }
+                      return updateMaxExpirationDateSecs(
+                          fileGroup, dataFile, newFileKey, fileExpirationDateSecs);
+                    });
+              }
+            }
+            // The file was supposed to be shared but it wasn't.
+            // NOTE: this scenario should never happened but we want to make sure of it with some
+            // logs.
+            if (dataFile.getAndroidSharingType()
+                == DataFile.AndroidSharingType.ANDROID_BLOB_WHEN_AVAILABLE) {
+              logMddAndroidSharingLog(eventLogger, fileGroup, dataFile, 0);
+            }
+          } catch (AndroidSharingException e) {
+            logMddAndroidSharingLog(eventLogger, fileGroup, dataFile, e.getErrorCode());
+          }
+          LogUtil.d(
+              "%s: File couldn't be shared after download %s, filegroup %s",
+              TAG, dataFile.getFileId(), fileGroup.getGroupName());
+          return updateMaxExpirationDateSecs(
+              fileGroup, dataFile, newFileKey, fileExpirationDateSecs);
+        });
+  }
+
+  /**
+   * Returns immediateVoidFuture even in case of error. This is because it is the last method to be
+   * called by {@code tryToShareAfterDownload}, which implements a best effort feature and is no-op
+   * in case of error.
+   */
+  private ListenableFuture<Void> updateMaxExpirationDateSecs(
+      DataFileGroupInternal fileGroup,
+      DataFile dataFile,
+      NewFileKey newFileKey,
+      long fileExpirationDateSecs) {
+    ListenableFuture<Boolean> updateFuture =
+        sharedFileManager.updateMaxExpirationDateSecs(newFileKey, fileExpirationDateSecs);
+    return transformSequentialAsync(
+        updateFuture,
+        res -> {
+          if (!res) {
+            LogUtil.e(
+                "%s: Failed to set new state for file %s, filegroup %s",
+                TAG, dataFile.getFileId(), fileGroup.getGroupName());
+            logMddAndroidSharingLog(eventLogger, fileGroup, dataFile, 0);
+          }
+          return immediateVoidFuture();
+        });
+  }
+
+  /**
+   * Acquires or updates the lease to the DataFile {@code dataFile} and updates the shared file
+   * metadata. The sharedFile's {@code FileStatus} will be set to DOWNLOAD_COMPLETE so that the file
+   * won't be downloaded again.
+   *
+   * <p>No-op operation if the lease had already been acquired and it shouldn't been updated.
+   *
+   * <p>This lease indicates to the system that the calling package wants the dataFile to be kept
+   * around.
+   */
+  ListenableFuture<Boolean> maybeUpdateLeaseAndSharedMetadata(
+      DataFileGroupInternal fileGroup,
+      DataFile dataFile,
+      SharedFile sharedFile,
+      NewFileKey newFileKey,
+      String androidSharingChecksum,
+      long fileExpirationDateSecs,
+      int evetTypeToLog)
+      throws AndroidSharingException {
+    if (sharedFile.getAndroidShared()
+        && !shouldUpdateMaxExpiryDate(sharedFile, fileExpirationDateSecs)) {
+      // The callingPackage has already a lease on the file which expires after the current
+      // expiration date.
+      logMddAndroidSharingLog(eventLogger, fileGroup, dataFile, evetTypeToLog);
+      return immediateFuture(true);
+    }
+
+    long maxExpiryDate = max(fileExpirationDateSecs, sharedFile.getMaxExpirationDateSecs());
+    AndroidSharingUtil.acquireLease(
+        context, androidSharingChecksum, maxExpiryDate, fileGroup, dataFile, fileStorage);
+    return transformSequentialAsync(
+        sharedFileManager.setAndroidSharedDownloadedFileEntry(
+            newFileKey, androidSharingChecksum, maxExpiryDate),
+        res -> {
+          if (!res) {
+            LogUtil.e(
+                "%s: Failed to set new state for file %s, filegroup %s",
+                TAG, dataFile.getFileId(), fileGroup.getGroupName());
+            logMddAndroidSharingLog(eventLogger, fileGroup, dataFile, 0);
+            return immediateFuture(false);
+          }
+          logMddAndroidSharingLog(
+              eventLogger, fileGroup, dataFile, evetTypeToLog, true, maxExpiryDate);
+          return immediateFuture(true);
+        });
+  }
+
+  /**
+   * Returns true if the file {@code expirationDateSecs} is greater than the current sharedFile
+   * {@code max_expiration_date}.
+   */
+  private static boolean shouldUpdateMaxExpiryDate(SharedFile sharedFile, long expirationDateSecs) {
+    return expirationDateSecs > sharedFile.getMaxExpirationDateSecs();
+  }
+
+  // TODO(b/118137672): remove this helper method once DirectoryUtil.getOnDeviceUri throws an
+  // exception instead of returning null.
+  private Uri getLocalUri(DataFile dataFile, NewFileKey newFileKey, SharedFile sharedFile)
+      throws AndroidSharingException {
+    Uri downloadFileOnDeviceUri =
+        DirectoryUtil.getOnDeviceUri(
+            context,
+            newFileKey.getAllowedReaders(),
+            sharedFile.getFileName(),
+            dataFile.getChecksum(),
+            silentFeedback,
+            instanceId,
+            /* androidShared = */ false);
+    if (downloadFileOnDeviceUri == null) {
+      LogUtil.e("%s: Failed to get file uri!", TAG);
+      throw new AndroidSharingException(0, "Failed to get local file uri");
+    }
+    return downloadFileOnDeviceUri;
+  }
+
+  private void deleteLocalCopy(
+      Uri downloadFileOnDeviceUri, DataFileGroupInternal fileGroup, DataFile dataFile) {
+    try {
+      fileStorage.deleteFile(downloadFileOnDeviceUri);
+    } catch (IOException e) {
+      LogUtil.e(
+          "%s: Failed to delete the local copy after android-sharing the file"
+              + " %s, file group %s",
+          TAG, dataFile.getFileId(), fileGroup.getGroupName());
+      logMddAndroidSharingLog(eventLogger, fileGroup, dataFile, 0);
+    }
+  }
+
+  /**
+   * Download and Verify all files present in any pending groups.
+   *
+   * @param onWifi whether the device is on wifi at the moment.
+   * @return A Combined Future of all file group downloads.
+   */
+  // TODO(b/124072754): Change to package private once all code is refactored.
+  // TODO: Change name to downloadAndVerifyAllPendingGroups.
+  public ListenableFuture<Void> scheduleAllPendingGroupsForDownload(
+      boolean onWifi, AsyncFunction<DataFileGroupInternal, Boolean> customFileGroupValidator) {
+    return transformSequentialAsync(
+        fileGroupsMetadata.getAllGroupKeys(),
+        propagateAsyncFunction(
+            groupKeyList ->
+                schedulePendingDownloads(groupKeyList, onWifi, customFileGroupValidator)));
+  }
+
+  @SuppressWarnings("nullness")
+  // Suppress nullness warnings because otherwise static analysis would require us to falsely label
+  // downloadFileGroup with @NullableType
+  private ListenableFuture<Void> schedulePendingDownloads(
+      List<GroupKey> groupKeyList,
+      boolean onWifi,
+      AsyncFunction<DataFileGroupInternal, Boolean> customFileGroupValidator) {
+    List<ListenableFuture<DataFileGroupInternal>> allGroupFutures = new ArrayList<>();
+    for (GroupKey key : groupKeyList) {
+      // We are only checking the non-downloaded groups
+      if (key.getDownloaded()) {
+        continue;
+      }
+
+      allGroupFutures.add(
+          transformSequentialAsync(
+              fileGroupsMetadata.read(key),
+              pendingGroup -> {
+                if (pendingGroup == null) {
+                  return Futures.immediateFuture(null);
+                }
+
+                boolean allowDownloadWithoutWifi = false;
+                if (pendingGroup.getDownloadConditions().getDeviceNetworkPolicy()
+                    == DeviceNetworkPolicy.DOWNLOAD_ON_ANY_NETWORK) {
+                  allowDownloadWithoutWifi = true;
+                } else if (pendingGroup.getDownloadConditions().getDeviceNetworkPolicy()
+                    == DeviceNetworkPolicy.DOWNLOAD_FIRST_ON_WIFI_THEN_ON_ANY_NETWORK) {
+                  long timeDownloadingWithWifiSecs =
+                      (timeSource.currentTimeMillis()
+                              - pendingGroup.getBookkeeping().getGroupNewFilesReceivedTimestamp())
+                          / 1000;
+                  if (timeDownloadingWithWifiSecs
+                      > pendingGroup.getDownloadConditions().getDownloadFirstOnWifiPeriodSecs()) {
+                    allowDownloadWithoutWifi = true;
+
+                    pendingGroup =
+                        pendingGroup.toBuilder()
+                            .setDownloadConditions(
+                                pendingGroup.getDownloadConditions().toBuilder()
+                                    .setDeviceNetworkPolicy(
+                                        DeviceNetworkPolicy.DOWNLOAD_ON_ANY_NETWORK))
+                            .build();
+                  }
+                }
+
+                LogUtil.d(
+                    "%s: Try to download pending file group: %s, download over cellular = %s",
+                    TAG, pendingGroup.getGroupName(), allowDownloadWithoutWifi);
+
+                if (onWifi || allowDownloadWithoutWifi) {
+                  return downloadFileGroup(
+                      key, pendingGroup.getDownloadConditions(), customFileGroupValidator);
+                }
+                return immediateFuture(null);
+              }));
+    }
+    // Note: We use whenAllComplete instead of whenAllSucceed since we want to continue to download
+    // all other file groups even if one or more fail.
+    return PropagatedFutures.whenAllComplete(allGroupFutures)
+        .call(() -> null, sequentialControlExecutor);
+  }
+
+  /**
+   * Verifies that the given pending group was downloaded, and updates the metadata if the download
+   * has completed.
+   *
+   * @param groupKey The key of the group to verify for download.
+   * @param pendingGroup The group to verify for download.
+   * @return A future that resolves to true if the given group was verify for download, false
+   *     otherwise.
+   */
+  // TODO(b/124072754): Change to package private once all code is refactored.
+  public ListenableFuture<GroupDownloadStatus> verifyPendingGroupDownloaded(
+      GroupKey groupKey,
+      DataFileGroupInternal pendingGroup,
+      AsyncFunction<DataFileGroupInternal, Boolean> customFileGroupValidator) {
+    return verifyGroupDownloaded(
+        groupKey,
+        pendingGroup,
+        /* removePendingVersion = */ true,
+        customFileGroupValidator,
+        /* downloadStateLogger = */ DownloadStateLogger.forDownload(eventLogger));
+  }
+
+  /**
+   * Verifies that the given pending group was downloaded, and updates the metadata if the download
+   * has completed.
+   *
+   * @param groupKey The key of the group to verify for download.
+   * @param fileGroup The group to verify for download.
+   * @param removePendingVersion boolean to tell whether or not the pending version should be
+   *     removed.
+   * @return A future that resolves to true if the given group was verify for download, false
+   *     otherwise.
+   */
+  private ListenableFuture<GroupDownloadStatus> verifyGroupDownloaded(
+      GroupKey groupKey,
+      DataFileGroupInternal fileGroup,
+      boolean removePendingVersion,
+      AsyncFunction<DataFileGroupInternal, Boolean> customFileGroupValidator,
+      DownloadStateLogger downloadStateLogger) {
+    LogUtil.d(
+        "%s: Verify group: %s, remove pending version: %s",
+        TAG, fileGroup.getGroupName(), removePendingVersion);
+
+    GroupKey downloadedGroupKey = groupKey.toBuilder().setDownloaded(true).build();
+    GroupKey pendingGroupKey = groupKey.toBuilder().setDownloaded(false).build();
+
+    DataFileGroupInternal downloadedFileGroupWithTimestamp =
+        FileGroupUtil.setDownloadedTimestampInMillis(fileGroup, timeSource.currentTimeMillis());
+
+    return PropagatedFluentFuture.from(getFileGroupDownloadStatus(fileGroup))
+        .transformAsync(
+            groupDownloadStatus -> {
+              // TODO(b/159828199) Use exceptions instead of nesting to exit early from transform
+              // chain.
+              if (groupDownloadStatus == GroupDownloadStatus.FAILED) {
+                downloadStateLogger.logFailed(fileGroup);
+                return Futures.immediateFuture(GroupDownloadStatus.FAILED);
+              }
+              if (groupDownloadStatus == GroupDownloadStatus.PENDING) {
+                downloadStateLogger.logPending(fileGroup);
+                return Futures.immediateFuture(GroupDownloadStatus.PENDING);
+              }
+
+              Preconditions.checkArgument(groupDownloadStatus == GroupDownloadStatus.DOWNLOADED);
+              return validateFileGroupAndMaybeRemoveIfFailed(
+                      pendingGroupKey,
+                      fileGroup,
+                      downloadStateLogger,
+                      removePendingVersion,
+                      customFileGroupValidator)
+                  .transformAsync(
+                      unused -> {
+                        // Create isolated file structure (using symlinks) if necessary and
+                        // supported
+                        if (FileGroupUtil.isIsolatedStructureAllowed(fileGroup)
+                            && VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
+                          return createIsolatedFilePaths(fileGroup);
+                        }
+                        return immediateVoidFuture();
+                      },
+                      sequentialControlExecutor)
+                  .transformAsync(
+                      unused ->
+                          writeNewGroupAndReturnOldGroup(
+                              downloadedGroupKey, downloadedFileGroupWithTimestamp),
+                      sequentialControlExecutor)
+                  .transformAsync(
+                      downloadedGroupOptional -> {
+                        if (removePendingVersion) {
+                          return removePendingGroup(pendingGroupKey, downloadedGroupOptional);
+                        }
+
+                        return immediateFuture(downloadedGroupOptional);
+                      },
+                      sequentialControlExecutor)
+                  .transformAsync(this::addGroupAsStaleIfPresent, sequentialControlExecutor)
+                  .transform(
+                      voidArg -> {
+                        downloadStateLogger.logComplete(downloadedFileGroupWithTimestamp);
+                        return GroupDownloadStatus.DOWNLOADED;
+                      },
+                      sequentialControlExecutor);
+            },
+            sequentialControlExecutor)
+        .transformAsync(
+            downloadStatus ->
+                transformSequential(
+                    downloadStageManager.updateExperimentIds(fileGroup.getGroupName()),
+                    success -> downloadStatus),
+            sequentialControlExecutor);
+  }
+
+  private ListenableFuture<Optional<DataFileGroupInternal>> writeNewGroupAndReturnOldGroup(
+      GroupKey downloadedGroupKey, DataFileGroupInternal newGroup) {
+    PropagatedFluentFuture<Optional<DataFileGroupInternal>> existingFileGroup =
+        PropagatedFluentFuture.from(fileGroupsMetadata.read(downloadedGroupKey))
+            .transform(Optional::fromNullable, sequentialControlExecutor);
+
+    return existingFileGroup
+        .transformAsync(
+            unused -> fileGroupsMetadata.write(downloadedGroupKey, newGroup),
+            sequentialControlExecutor)
+        .transformAsync(
+            writeSuccess -> {
+              if (!writeSuccess) {
+                eventLogger.logEventSampled(0);
+                return immediateFailedFuture(
+                    new IOException(
+                        "Failed to write updated group: " + downloadedGroupKey.getGroupName()));
+              }
+
+              return existingFileGroup;
+            },
+            sequentialControlExecutor);
+  }
+
+  private ListenableFuture<Optional<DataFileGroupInternal>> removePendingGroup(
+      GroupKey pendingGroupKey, Optional<DataFileGroupInternal> toReturn) {
+    // Remove the newly downloaded version from the pending groups list,
+    // if removing fails, we will verify it again the next time.
+    return transformSequential(
+        fileGroupsMetadata.remove(pendingGroupKey),
+        removeSuccess -> {
+          if (!removeSuccess) {
+            eventLogger.logEventSampled(0);
+          }
+          return toReturn;
+        });
+  }
+
+  private PropagatedFluentFuture<Void> validateFileGroupAndMaybeRemoveIfFailed(
+      GroupKey pendingGroupKey,
+      DataFileGroupInternal fileGroup,
+      DownloadStateLogger downloadStateLogger,
+      boolean removePendingVersion,
+      AsyncFunction<DataFileGroupInternal, Boolean> customFileGroupValidator)
+      throws Exception {
+    return PropagatedFluentFuture.from(customFileGroupValidator.apply(fileGroup))
+        .transformAsync(
+            validatedOk -> {
+              if (validatedOk) {
+                return immediateVoidFuture();
+              }
+
+              downloadStateLogger.logFailed(fileGroup);
+
+              ListenableFuture<Boolean> removePendingGroupFuture = immediateFuture(true);
+              if (removePendingVersion) {
+                removePendingGroupFuture = fileGroupsMetadata.remove(pendingGroupKey);
+              }
+              return transformSequentialAsync(
+                  removePendingGroupFuture,
+                  removeSuccess -> {
+                    if (!removeSuccess) {
+                      LogUtil.e(
+                          "%s: Failed to remove pending version for group: '%s';"
+                              + " account: '%s'",
+                          TAG, pendingGroupKey.getGroupName(), pendingGroupKey.getAccount());
+                      eventLogger.logEventSampled(0);
+                      return immediateFailedFuture(
+                          new IOException(
+                              "Failed to remove pending group: " + pendingGroupKey.getGroupName()));
+                    }
+                    return immediateFailedFuture(
+                        DownloadException.builder()
+                            .setDownloadResultCode(
+                                DownloadResultCode.CUSTOM_FILEGROUP_VALIDATION_FAILED)
+                            .setMessage(
+                                DownloadResultCode.CUSTOM_FILEGROUP_VALIDATION_FAILED.name())
+                            .build());
+                  });
+            },
+            sequentialControlExecutor);
+  }
+
+  private ListenableFuture<Void> addGroupAsStaleIfPresent(
+      Optional<DataFileGroupInternal> oldGroup) {
+    if (!oldGroup.isPresent()) {
+      return immediateVoidFuture();
+    }
+
+    return transformSequentialAsync(
+        fileGroupsMetadata.addStaleGroup(oldGroup.get()),
+        addSuccess -> {
+          if (!addSuccess) {
+            // If this fails, the stale file group will be
+            // unaccounted for, and the files will get deleted
+            // in the next daily maintenance, hence not
+            // enforcing its stale lifetime.
+            eventLogger.logEventSampled(0);
+          }
+          return immediateVoidFuture();
+        });
+  }
+
+  /**
+   * When a DataFileGroup has preserve_filenames_and_isolate_files set, this method will create an
+   * isolated file structure (using symlinks to the shared files).
+   *
+   * <p>This method will also respect a DataFiles relative_file_path field (if set), otherwise it
+   * will use the last segment of the download url.
+   *
+   * <p>If preserve_filenames_and_isolate_files is not set, this method is a noop and will
+   * immediately return
+   *
+   * @return Future that resolves once isolated paths are created, or failure with DownloadException
+   *     if unable to create isolated structure.
+   */
+  @RequiresApi(VERSION_CODES.LOLLIPOP)
+  private ListenableFuture<Void> createIsolatedFilePaths(DataFileGroupInternal dataFileGroup) {
+    // If no isolated structure is required, return early.
+    if (!dataFileGroup.getPreserveFilenamesAndIsolateFiles()) {
+      return immediateVoidFuture();
+    }
+
+    // Remove existing symlinks if they exist
+    try {
+      FileGroupUtil.removeIsolatedFileStructure(context, instanceId, dataFileGroup, fileStorage);
+    } catch (IOException e) {
+      return immediateFailedFuture(
+          DownloadException.builder()
+              .setDownloadResultCode(DownloadResultCode.UNABLE_TO_REMOVE_SYMLINK_STRUCTURE)
+              .setMessage("Unable to cleanup symlink structure")
+              .setCause(e)
+              .build());
+    }
+    List<ListenableFuture<Void>> createSymlinkFutures =
+        new ArrayList<>(dataFileGroup.getFileCount());
+
+    for (DataFile dataFile : dataFileGroup.getFileList()) {
+      if (dataFile.getAndroidSharingType() == AndroidSharingType.ANDROID_BLOB_WHEN_AVAILABLE) {
+        createSymlinkFutures.add(
+            immediateFailedFuture(
+                new UnsupportedOperationException(
+                    "Preserve File Paths is invalid with Android Blob Sharing")));
+        // break out of loop since we've already hit a failure.
+        break;
+      }
+
+      // Get the original path
+      ListenableFuture<Void> createSymlinkFuture =
+          transformSequentialAsync(
+              getOnDeviceUri(dataFile, dataFileGroup),
+              (Uri originalUri) -> {
+                Uri symlinkUri =
+                    FileGroupUtil.getIsolatedFileUri(context, instanceId, dataFile, dataFileGroup);
+
+                try {
+                  // Check/create parent dir of symlink.
+                  Uri symlinkParentDir =
+                      Uri.parse(
+                          symlinkUri
+                              .toString()
+                              .substring(0, symlinkUri.toString().lastIndexOf("/")));
+                  if (!fileStorage.exists(symlinkParentDir)) {
+                    fileStorage.createDirectory(symlinkParentDir);
+                  }
+                  SymlinkUtil.createSymlink(context, symlinkUri, checkNotNull(originalUri));
+                } catch (IOException e) {
+                  return immediateFailedFuture(
+                      DownloadException.builder()
+                          .setDownloadResultCode(
+                              DownloadResultCode.UNABLE_TO_CREATE_SYMLINK_STRUCTURE)
+                          .setMessage("Unable to create symlink")
+                          .setCause(e)
+                          .build());
+                }
+                return immediateVoidFuture();
+              });
+      createSymlinkFutures.add(createSymlinkFuture);
+    }
+    ListenableFuture<Void> combinedFuture =
+        Futures.whenAllSucceed(createSymlinkFutures).call(() -> null, sequentialControlExecutor);
+
+    PropagatedFutures.addCallback(
+        combinedFuture,
+        new FutureCallback<Void>() {
+          @Override
+          public void onSuccess(Void unused) {}
+
+          @Override
+          public void onFailure(Throwable t) {
+            // cleanup symlink structure on failure
+            LogUtil.d(t, "%s: Unable to create symlink structure, cleaning up symlinks...", TAG);
+            try {
+              FileGroupUtil.removeIsolatedFileStructure(
+                  context, instanceId, dataFileGroup, fileStorage);
+            } catch (IOException e) {
+              LogUtil.d(e, "%s: Unable to clean up symlink structure after failure", TAG);
+            }
+          }
+        },
+        sequentialControlExecutor);
+
+    return combinedFuture;
+  }
+
+  /**
+   * Gets the Isolated File Uri and verifies that it exists and points to the given uri.
+   *
+   * <p>Throws IOException when verifying the symlink fails.
+   */
+  @RequiresApi(VERSION_CODES.LOLLIPOP)
+  Uri getAndVerifyIsolatedFileUri(
+      Uri originalFileUri, DataFile dataFile, DataFileGroupInternal dataFileGroup)
+      throws IOException {
+    Uri isolatedFileUri =
+        FileGroupUtil.getIsolatedFileUri(context, instanceId, dataFile, dataFileGroup);
+
+    Uri targetFileUri = SymlinkUtil.readSymlink(context, isolatedFileUri);
+
+    if (!fileStorage.exists(isolatedFileUri)
+        || !targetFileUri.toString().equals(originalFileUri.toString())) {
+      throw new IOException("Isolated file uri does not exist or points to an unexpected target");
+    }
+
+    return isolatedFileUri;
+  }
+
+  /**
+   * Verifies a file group's isolated structure is correct.
+   *
+   * <p>This verification is only performed under the following conditions:
+   *
+   * <ul>
+   *   <li>MDD Flags enable this verification
+   *   <li>The group is not null
+   *   <li>The group is downloaded
+   *   <li>The group uses an isolated structure
+   * </ul>
+   *
+   * <p>If any of these conditions are not met, this method is a noop and returns true immediately.
+   *
+   * <p>If structure is correct, this method returns true.
+   *
+   * <p>If the isolated structure is corrupted (missing symlink or invalid symlink), this method
+   * will return false.
+   *
+   * <p>This method is annotated with @TargetApi(21) since symlink structure methods require API
+   * level 21 or later. The FileGroupUtil.isIsolatedStructureAllowed check will ensure this
+   * condition is met before calling getAndVerifyIsolatedFileUri and createIsolatedFilePaths.
+   *
+   * @return Future that resolves to true if the isolated structure is verified, or false if the
+   *     structure couldn't be verified
+   */
+  @TargetApi(21)
+  private ListenableFuture<Boolean> maybeVerifyIsolatedStructure(
+      @NullableType DataFileGroupInternal dataFileGroup, boolean isDownloaded) {
+    // Return early if conditions are not met
+    if (!flags.enableIsolatedStructureVerification()
+        || dataFileGroup == null
+        || !isDownloaded
+        || !FileGroupUtil.isIsolatedStructureAllowed(dataFileGroup)) {
+      return immediateFuture(true);
+    }
+
+    List<ListenableFuture<Void>> verifyIsolatedFileFutures =
+        new ArrayList<>(dataFileGroup.getFileCount());
+    for (DataFile dataFile : dataFileGroup.getFileList()) {
+      verifyIsolatedFileFutures.add(
+          transformSequentialAsync(
+              getOnDeviceUri(dataFile, dataFileGroup),
+              onDeviceUri -> {
+                if (onDeviceUri != null) {
+                  Uri unused = getAndVerifyIsolatedFileUri(onDeviceUri, dataFile, dataFileGroup);
+                }
+                return immediateVoidFuture();
+              }));
+    }
+
+    return PropagatedFutures.catching(
+        Futures.whenAllSucceed(verifyIsolatedFileFutures)
+            .call(() -> true, sequentialControlExecutor),
+        IOException.class,
+        ex -> {
+          // TODO(b/194688687): Log these events to clearcut along with their file group info so
+          // we can understand how often this is happening.
+          LogUtil.w(
+              ex,
+              "%s: Detected corruption of isolated structure for group %s",
+              TAG,
+              dataFileGroup.getGroupName());
+
+          return false;
+        },
+        sequentialControlExecutor);
+  }
+
+  /**
+   * Gets the on device uri of the given {@link DataFile}.
+   *
+   * <p>Checks for sideloading support. If file is sideloaded and sideloading is enabled, the
+   * sideload uri will be returned immediately. If sideloading is not enabled, returns failure.
+   *
+   * <p>If file is not sideloaded, delegates to {@link
+   * SharedFileManager#getOnDeviceUri(NewFileKey)}.
+   */
+  public ListenableFuture<@NullableType Uri> getOnDeviceUri(
+      DataFile dataFile, DataFileGroupInternal dataFileGroup) {
+    // If sideloaded file -- return url immediately
+    if (FileGroupUtil.isSideloadedFile(dataFile)) {
+      return immediateFuture(Uri.parse(dataFile.getUrlToDownload()));
+    }
+
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(dataFile, dataFileGroup.getAllowedReadersEnum());
+
+    return sharedFileManager.getOnDeviceUri(newFileKey);
+  }
+
+  /**
+   * Get the current status of the file group. Since the status of the group is not stored in the
+   * file group, this method iterates over all files and re-calculates the current status.
+   *
+   * <p>Note that this method doesn't modify the status of the file group on disk.
+   */
+  public ListenableFuture<GroupDownloadStatus> getFileGroupDownloadStatus(
+      DataFileGroupInternal dataFileGroup) {
+    return getFileGroupDownloadStatusIter(
+        dataFileGroup,
+        /* downloadFailed = */ false,
+        /* downloadPending = */ false,
+        /* index = */ 0,
+        dataFileGroup.getFileCount());
+  }
+
+  // Because the decision to continue iterating depends on the result of the asynchronous
+  // getFileStatus operation, we have to use recursion here instead of a loop construct.
+  private ListenableFuture<GroupDownloadStatus> getFileGroupDownloadStatusIter(
+      DataFileGroupInternal dataFileGroup,
+      boolean downloadFailed,
+      boolean downloadPending,
+      int index,
+      int fileCount) {
+    if (index < fileCount) {
+      DataFile dataFile = dataFileGroup.getFile(index);
+
+      // Skip sideloaded files -- they are always considered downloaded.
+      if (FileGroupUtil.isSideloadedFile(dataFile)) {
+        return getFileGroupDownloadStatusIter(
+            dataFileGroup, downloadFailed, downloadPending, index + 1, fileCount);
+      }
+
+      NewFileKey newFileKey =
+          SharedFilesMetadata.createKeyFromDataFile(
+              dataFile, dataFileGroup.getAllowedReadersEnum());
+      return PropagatedFluentFuture.from(sharedFileManager.getFileStatus(newFileKey))
+          .catchingAsync(
+              SharedFileMissingException.class,
+              e -> {
+                // TODO(b/118137672): reconsider on the swallowed exception.
+                LogUtil.e(
+                    "%s: Encountered SharedFileMissingException for group: %s",
+                    TAG, dataFileGroup.getGroupName());
+                silentFeedback.send(e, "Shared file not found in getFileGroupDownloadStatus");
+                return immediateFuture(FileStatus.NONE);
+              },
+              sequentialControlExecutor)
+          .transformAsync(
+              fileStatus -> {
+                if (fileStatus == FileStatus.DOWNLOAD_COMPLETE) {
+                  LogUtil.d(
+                      "%s: File %s downloaded for group: %s",
+                      TAG, dataFile.getFileId(), dataFileGroup.getGroupName());
+                  return getFileGroupDownloadStatusIter(
+                      dataFileGroup, downloadFailed, downloadPending, index + 1, fileCount);
+                } else if (fileStatus == FileStatus.SUBSCRIBED
+                    || fileStatus == FileStatus.DOWNLOAD_IN_PROGRESS) {
+                  LogUtil.d(
+                      "%s: File %s not downloaded for group: %s",
+                      TAG, dataFile.getFileId(), dataFileGroup.getGroupName());
+                  return getFileGroupDownloadStatusIter(
+                      dataFileGroup,
+                      downloadFailed,
+                      /* downloadPending = */ true,
+                      index + 1,
+                      fileCount);
+                } else {
+                  LogUtil.d(
+                      "%s: File %s not downloaded for group: %s",
+                      TAG, dataFile.getFileId(), dataFileGroup.getGroupName());
+                  return getFileGroupDownloadStatusIter(
+                      dataFileGroup,
+                      /* downloadFailed = */ true,
+                      downloadPending,
+                      index + 1,
+                      fileCount);
+                }
+              },
+              sequentialControlExecutor);
+    } else if (downloadFailed) { // index == fileCount
+      return immediateFuture(GroupDownloadStatus.FAILED);
+    } else if (downloadPending) {
+      return immediateFuture(GroupDownloadStatus.PENDING);
+    } else {
+      return immediateFuture(GroupDownloadStatus.DOWNLOADED);
+    }
+  }
+
+  /**
+   * Verify if any of the pending groups was downloaded.
+   *
+   * <p>If a group has been completely downloaded, it will be made available the next time a {@link
+   * #getFileGroup} is called.
+   */
+  // TODO(b/124072754): Change to package private once all code is refactored.
+  public ListenableFuture<Void> verifyAllPendingGroupsDownloaded(
+      AsyncFunction<DataFileGroupInternal, Boolean> customFileGroupValidator) {
+    return transformSequentialAsync(
+        fileGroupsMetadata.getAllGroupKeys(),
+        propagateAsyncFunction(
+            groupKeyList ->
+                verifyAllPendingGroupsDownloaded(groupKeyList, customFileGroupValidator)));
+  }
+
+  @SuppressWarnings("nullness")
+  // Suppress nullness warnings because otherwise static analysis would require us to falsely label
+  // verifyPendingGroupDownloaded with @NullableType
+  private ListenableFuture<Void> verifyAllPendingGroupsDownloaded(
+      List<GroupKey> groupKeyList,
+      AsyncFunction<DataFileGroupInternal, Boolean> customFileGroupValidator) {
+    List<ListenableFuture<GroupDownloadStatus>> allFileFutures = new ArrayList<>();
+    for (GroupKey groupKey : groupKeyList) {
+      if (groupKey.getDownloaded()) {
+        continue;
+      }
+      allFileFutures.add(
+          transformSequentialAsync(
+              fileGroupsMetadata.read(groupKey),
+              pendingGroup -> {
+                if (pendingGroup == null) {
+                  return immediateFuture(null);
+                }
+                return verifyPendingGroupDownloaded(
+                    groupKey, pendingGroup, customFileGroupValidator);
+              }));
+    }
+    return PropagatedFutures.whenAllComplete(allFileFutures)
+        .call(() -> null, sequentialControlExecutor);
+  }
+
+  // TODO(b/124072754): Change to package private once all code is refactored.
+  public ListenableFuture<Void> deleteUninstalledAppGroups() {
+    return transformSequentialAsync(
+        fileGroupsMetadata.getAllGroupKeys(),
+        groupKeyList -> {
+          List<ListenableFuture<Void>> removeGroupFutures = new ArrayList<>();
+          for (GroupKey key : groupKeyList) {
+            if (!isAppInstalled(key.getOwnerPackage())) {
+              removeGroupFutures.add(
+                  transformSequentialAsync(
+                      fileGroupsMetadata.read(key),
+                      group -> {
+                        if (group == null) {
+                          return immediateVoidFuture();
+                        }
+                        LogUtil.d(
+                            "%s: Deleting file group %s for uninstalled app %s",
+                            TAG, key.getGroupName(), key.getOwnerPackage());
+                        eventLogger.logEventSampled(0);
+                        return transformSequentialAsync(
+                            fileGroupsMetadata.remove(key),
+                            removeSuccess -> {
+                              if (!removeSuccess) {
+                                eventLogger.logEventSampled(0);
+                              }
+                              return immediateVoidFuture();
+                            });
+                      }));
+            }
+          }
+          return PropagatedFutures.whenAllComplete(removeGroupFutures)
+              .call(() -> null, sequentialControlExecutor);
+        });
+  }
+
+  ListenableFuture<Void> deleteRemovedAccountGroups() {
+    // In the library case, the account manager should be present. But in the GmsCore service case,
+    // the account manager is absent, and the removed-account check is skipped.
+    if (!accountSourceOptional.isPresent()) {
+      return immediateVoidFuture();
+    }
+
+    ImmutableSet<String> serializedAccounts;
+    try {
+      serializedAccounts = getSerializedGoogleAccounts(accountSourceOptional.get());
+    } catch (RuntimeException e) {
+      // getSerializedGoogleAccounts could throw a SecurityException, which will bubble up and
+      // prevent any other maintenance tasks from being performed. Instead, catch it and wrap it in
+      // an LF so other tasks are performed even if this fails.
+      return immediateFailedFuture(e);
+    }
+
+    return transformSequentialAsync(
+        fileGroupsMetadata.getAllGroupKeys(),
+        groupKeyList -> {
+          List<ListenableFuture<Void>> removeGroupFutures = new ArrayList<>();
+          for (GroupKey key : groupKeyList) {
+            if (key.getAccount().isEmpty() || serializedAccounts.contains(key.getAccount())) {
+              continue;
+            }
+
+            removeGroupFutures.add(
+                transformSequentialAsync(
+                    fileGroupsMetadata.read(key),
+                    group -> {
+                      if (group == null) {
+                        return immediateVoidFuture();
+                      }
+
+                      LogUtil.d(
+                          "%s: Deleting file group %s for removed account %s",
+                          TAG, key.getGroupName(), key.getOwnerPackage());
+                      logEventWithDataFileGroup(0, eventLogger, group);
+
+                      // Remove the group from fresh file groups if the account is removed.
+                      return transformSequentialAsync(
+                          fileGroupsMetadata.remove(key),
+                          removeSuccess -> {
+                            if (!removeSuccess) {
+                              logEventWithDataFileGroup(0, eventLogger, group);
+                            }
+                            return immediateVoidFuture();
+                          });
+                    }));
+          }
+
+          return PropagatedFutures.whenAllComplete(removeGroupFutures)
+              .call(() -> null, sequentialControlExecutor);
+        });
+  }
+
+  /**
+   * Accumulates download started count. Sets download started timestamp if it has not been set
+   * before. Writes the pending group back to metadata after the timestamp is set. Logs download
+   * started event.
+   */
+  private ListenableFuture<DataFileGroupInternal> updateBookkeepingOnStartDownload(
+      GroupKey groupKey, DataFileGroupInternal pendingGroup) {
+    // Accumulate download started count, since we're scheduling download for the file group.
+    DataFileGroupBookkeeping bookkeeping = pendingGroup.getBookkeeping();
+    int downloadStartedCount = bookkeeping.getDownloadStartedCount() + 1;
+    pendingGroup =
+        pendingGroup.toBuilder()
+            .setBookkeeping(bookkeeping.toBuilder().setDownloadStartedCount(downloadStartedCount))
+            .build();
+
+    // Only set the download started timestamp once.
+    boolean firstDownloadAttempt = !bookkeeping.hasGroupDownloadStartedTimestampInMillis();
+    if (firstDownloadAttempt) {
+      pendingGroup =
+          FileGroupUtil.setDownloadStartedTimestampInMillis(
+              pendingGroup, timeSource.currentTimeMillis());
+    }
+
+    // Variables captured in lambdas must be effectively final.
+    DataFileGroupInternal pendingGroupCapture = pendingGroup;
+    GroupKey pendingGroupKey = groupKey.toBuilder().setDownloaded(false).build();
+    return transformSequentialAsync(
+        fileGroupsMetadata.write(pendingGroupKey, pendingGroup),
+        writeSuccess -> {
+          if (!writeSuccess) {
+            eventLogger.logEventSampled(0);
+            return immediateFailedFuture(new IOException("Unable to update file group metadata"));
+          }
+
+          // Only log download stated event when bookkeping is successfully updated upon the first
+          // download attempt (for dedup purposes).
+          if (firstDownloadAttempt) {
+            DownloadStateLogger.forDownload(eventLogger).logStarted(pendingGroupCapture);
+          }
+
+          return immediateFuture(pendingGroupCapture);
+        });
+  }
+
+  /** Gets a set of {@link NewFileKey}'s which are referenced by some fresh group. */
+  private ListenableFuture<ImmutableSet<NewFileKey>> getFileKeysReferencedByFreshGroups() {
+    ImmutableSet.Builder<NewFileKey> referencedFileKeys = ImmutableSet.builder();
+    return transformSequential(
+        fileGroupsMetadata.getAllFreshGroups(),
+        pairs -> {
+          for (Pair<GroupKey, DataFileGroupInternal> pair : pairs) {
+            DataFileGroupInternal fileGroup = pair.second;
+            for (DataFile dataFile : fileGroup.getFileList()) {
+              NewFileKey newFileKey =
+                  SharedFilesMetadata.createKeyFromDataFile(
+                      dataFile, fileGroup.getAllowedReadersEnum());
+              referencedFileKeys.add(newFileKey);
+            }
+          }
+          return referencedFileKeys.build();
+        });
+  }
+
+  /** Logs download failure remotely via {@code eventLogger}. */
+  private ListenableFuture<Void> logDownloadFailure(
+      GroupKey groupKey, DownloadException downloadException, long buildId, String variantId) {
+    Void groupDetails = null;
+
+    return transformSequentialAsync(
+        fileGroupsMetadata.read(groupKey.toBuilder().setDownloaded(false).build()),
+        dataFileGroup -> {
+          eventLogger.logMddDownloadResult(0, groupDetails);
+          return immediateVoidFuture();
+        });
+  }
+
+  private ListenableFuture<Boolean> subscribeGroup(DataFileGroupInternal dataFileGroup) {
+    return subscribeGroup(dataFileGroup, /* index = */ 0, dataFileGroup.getFileCount());
+  }
+
+  // Because the decision to continue iterating or not depends on the result of the asynchronous
+  // reserveFileEntry operation, we have to use recursion instead of a loop construct.
+  private ListenableFuture<Boolean> subscribeGroup(
+      DataFileGroupInternal dataFileGroup, int index, int fileCount) {
+    if (index < fileCount) {
+      DataFile dataFile = dataFileGroup.getFile(index);
+
+      // Skip sideloaded files since they will not interact with SharedFileManager
+      if (FileGroupUtil.isSideloadedFile(dataFile)) {
+        return subscribeGroup(dataFileGroup, index + 1, fileCount);
+      }
+
+      NewFileKey newFileKey =
+          SharedFilesMetadata.createKeyFromDataFile(
+              dataFile, dataFileGroup.getAllowedReadersEnum());
+      return transformSequentialAsync(
+          sharedFileManager.reserveFileEntry(newFileKey),
+          success -> {
+            if (!success) {
+              // If we fail to reserve for one of the files, return immediately. Any files added
+              // already will be cleared by garbage collection.
+              LogUtil.e(
+                  "%s: Subscribing to file failed for group: %s",
+                  TAG, dataFileGroup.getGroupName());
+              return immediateFuture(false);
+            } else {
+              return subscribeGroup(dataFileGroup, index + 1, fileCount);
+            }
+          });
+    } else {
+      return immediateFuture(true);
+    }
+  }
+
+  private ListenableFuture<Boolean> isAddedGroupDuplicate(
+      GroupKey groupKey, DataFileGroupInternal dataFileGroup) {
+    // Search for a non-downloaded version of this group.
+    GroupKey pendingGroupKey = groupKey.toBuilder().setDownloaded(false).build();
+    return transformSequentialAsync(
+        fileGroupsMetadata.read(pendingGroupKey),
+        pendingGroup -> {
+          if (pendingGroup != null) {
+            return immediateFuture(areSameGroup(dataFileGroup, pendingGroup));
+          }
+
+          // Search for a downloaded version of this group.
+          GroupKey downloadedGroupKey = groupKey.toBuilder().setDownloaded(true).build();
+          return transformSequentialAsync(
+              fileGroupsMetadata.read(downloadedGroupKey),
+              downloadedGroup -> {
+                boolean result =
+                    (downloadedGroup == null)
+                        ? false
+                        : areSameGroup(dataFileGroup, downloadedGroup);
+                return immediateFuture(result);
+              });
+        });
+  }
+
+  /**
+   * Check if the new group is same as existing version. This just checks the fields that we expect
+   * to be set when we receive a new group. Other fields are ignored.
+   *
+   * @param newGroup The new config that we received for the client.
+   * @param prevGroup The old config that we already have for the client.
+   * @return true if the new config contains an upgrade to any file.
+   */
+  private static boolean areSameGroup(
+      DataFileGroupInternal newGroup, DataFileGroupInternal prevGroup) {
+    // We do not compare the protos directly and check individual fields because proto.equals
+    // also compares extensions (and unknown fields).
+    // TODO: Consider clearing extensions and then comparing protos.
+    if (prevGroup.getBuildId() != newGroup.getBuildId()) {
+      return false;
+    }
+    if (!prevGroup.getVariantId().equals(newGroup.getVariantId())) {
+      return false;
+    }
+    if (prevGroup.getFileGroupVersionNumber() != newGroup.getFileGroupVersionNumber()) {
+      return false;
+    }
+    if (!hasSameFiles(newGroup, prevGroup)) {
+      return false;
+    }
+    if (prevGroup.getStaleLifetimeSecs() != newGroup.getStaleLifetimeSecs()) {
+      return false;
+    }
+    if (prevGroup.getExpirationDateSecs() != newGroup.getExpirationDateSecs()) {
+      return false;
+    }
+    if (!prevGroup.getDownloadConditions().equals(newGroup.getDownloadConditions())) {
+      return false;
+    }
+    if (!prevGroup.getAllowedReadersEnum().equals(newGroup.getAllowedReadersEnum())) {
+      return false;
+    }
+    return true;
+  }
+
+  /**
+   * Check if the new group has the same set of files as prev groups.
+   *
+   * @param newGroup The new config that we received for the client.
+   * @param prevGroup The old config that we already have for the client.
+   * @return true iff - All urlToDownloads are the same - Their checksums are the same - Their sizes
+   *     are the same.
+   */
+  private static boolean hasSameFiles(
+      DataFileGroupInternal newGroup, DataFileGroupInternal prevGroup) {
+    return newGroup.getFileList().equals(prevGroup.getFileList());
+  }
+
+  private ListenableFuture<DataFileGroupInternal> maybeSetGroupNewFilesReceivedTimestamp(
+      GroupKey groupKey, DataFileGroupInternal receivedFileGroup) {
+    // Search for a non-downloaded version of this group.
+    GroupKey pendingGroupKey = groupKey.toBuilder().setDownloaded(false).build();
+    return transformSequentialAsync(
+        fileGroupsMetadata.read(pendingGroupKey),
+        pendingGroup -> {
+          // We will only set the GroupNewFilesReceivedTimestamp when either this is the first time
+          // we receive this File Group or the files are changed. In other cases, we will keep the
+          // existing timestamp. This will avoid reset timestamp when metadata of the File Group
+          // changes but the files stay the same.
+          long groupNewFilesReceivedTimestamp;
+          if (pendingGroup != null && hasSameFiles(receivedFileGroup, pendingGroup)) {
+            // The files are not changed, we will copy over the timestamp from the pending group.
+            groupNewFilesReceivedTimestamp =
+                pendingGroup.getBookkeeping().getGroupNewFilesReceivedTimestamp();
+          } else {
+            // First time we receive this FileGroup or the files are changed, set the timestamp to
+            // the current time.
+            groupNewFilesReceivedTimestamp = timeSource.currentTimeMillis();
+          }
+          DataFileGroupInternal receivedFileGroupWithTimestamp =
+              FileGroupUtil.setGroupNewFilesReceivedTimestamp(
+                  receivedFileGroup, groupNewFilesReceivedTimestamp);
+          return immediateFuture(receivedFileGroupWithTimestamp);
+        });
+  }
+
+  private boolean isAppInstalled(String packageName) {
+    try {
+      context.getPackageManager().getApplicationInfo(packageName, 0);
+      return true;
+    } catch (NameNotFoundException e) {
+      return false;
+    }
+  }
+
+  private ImmutableSet<String> getSerializedGoogleAccounts(AccountSource accountSource) {
+    ImmutableList<Account> accounts = accountSource.getAllAccounts();
+
+    ImmutableSet.Builder<String> serializedAccounts = new ImmutableSet.Builder<>();
+    for (Account account : accounts) {
+      if (account.name != null && account.type != null) {
+        serializedAccounts.add(AccountUtil.serialize(account));
+      }
+    }
+    return serializedAccounts.build();
+  }
+
+  // Logs and deletes file groups where a file is missing or corrupted, allowing the group and its
+  // files to be added again via phenotype.
+  //
+  // For detail, see b/119555756.
+  // TODO(b/124072754): Change to package private once all code is refactored.
+  public ListenableFuture<Void> logAndDeleteForMissingSharedFiles() {
+    return iterateOverAllFileGroups(
+        groupKeyAndGroup -> {
+          DataFileGroupInternal dataFileGroup = groupKeyAndGroup.dataFileGroup();
+
+          if (dataFileGroup == null) {
+            return immediateVoidFuture();
+          }
+
+          for (DataFile dataFile : dataFileGroup.getFileList()) {
+            NewFileKey newFileKey =
+                SharedFilesMetadata.createKeyFromDataFile(
+                    dataFile, dataFileGroup.getAllowedReadersEnum());
+            ListenableFuture<Void> unused =
+                PropagatedFutures.catchingAsync(
+                    sharedFileManager.reVerifyFile(newFileKey, dataFile),
+                    SharedFileMissingException.class,
+                    e -> {
+                      LogUtil.e("%s: Missing file. Logging and deleting file group.", TAG);
+                      logEventWithDataFileGroup(0, eventLogger, dataFileGroup);
+
+                      if (flags.deleteFileGroupsWithFilesMissing()) {
+                        return transformSequentialAsync(
+                            fileGroupsMetadata.remove(groupKeyAndGroup.groupKey()),
+                            ok -> immediateVoidFuture());
+                      }
+                      return immediateVoidFuture();
+                    },
+                    sequentialControlExecutor);
+          }
+          return immediateVoidFuture();
+        });
+  }
+
+  /**
+   * Verifies that any isolated files (symlinks) still exist for all file groups. If any are
+   * missing, it attempts to recreate them.
+   */
+  @TargetApi(VERSION_CODES.LOLLIPOP)
+  public ListenableFuture<Void> verifyAndAttemptToRepairIsolatedFiles() {
+    // No symlinks available on pre-Lollipop devices
+    if (VERSION.SDK_INT < VERSION_CODES.LOLLIPOP) {
+      return immediateVoidFuture();
+    }
+
+    return iterateOverAllFileGroups(
+        groupKeyAndGroup -> {
+          GroupKey groupKey = groupKeyAndGroup.groupKey();
+          DataFileGroupInternal dataFileGroup = groupKeyAndGroup.dataFileGroup();
+
+          if (dataFileGroup == null
+              || !groupKey.getDownloaded()
+              || !FileGroupUtil.isIsolatedStructureAllowed(dataFileGroup)) {
+            return immediateVoidFuture();
+          }
+
+          return transformSequentialAsync(
+              maybeVerifyIsolatedStructure(dataFileGroup, /*isDownloaded=*/ true),
+              verified -> {
+                if (!verified) {
+                  return PropagatedFluentFuture.from(createIsolatedFilePaths(dataFileGroup))
+                      .catchingAsync(
+                          DownloadException.class,
+                          exception -> {
+                            LogUtil.w(
+                                exception,
+                                "%s: Unable to correct isolated structure, returning null"
+                                    + " instead of group %s",
+                                TAG,
+                                dataFileGroup.getGroupName());
+                            return immediateVoidFuture();
+                          },
+                          sequentialControlExecutor);
+                }
+                return immediateVoidFuture();
+              });
+        });
+  }
+
+  @AutoValue
+  abstract static class GroupKeyAndGroup {
+    static GroupKeyAndGroup create(
+        GroupKey groupKey, @Nullable DataFileGroupInternal dataFileGroup) {
+      return new AutoValue_FileGroupManager_GroupKeyAndGroup(groupKey, dataFileGroup);
+    }
+
+    abstract GroupKey groupKey();
+
+    @Nullable
+    abstract DataFileGroupInternal dataFileGroup();
+  }
+
+  private ListenableFuture<Void> iterateOverAllFileGroups(
+      AsyncFunction<GroupKeyAndGroup, Void> processGroup) {
+
+    List<ListenableFuture<Void>> allGroupsProcessed = new ArrayList<>();
+
+    return transformSequentialAsync(
+        fileGroupsMetadata.getAllGroupKeys(),
+        groupKeyList -> {
+          for (GroupKey groupKey : groupKeyList) {
+            allGroupsProcessed.add(
+                transformSequentialAsync(
+                    fileGroupsMetadata.read(groupKey),
+                    dataFileGroup ->
+                        processGroup.apply(GroupKeyAndGroup.create(groupKey, dataFileGroup))));
+          }
+          return PropagatedFutures.whenAllComplete(allGroupsProcessed)
+              .call(() -> null, sequentialControlExecutor);
+        });
+  }
+
+  /** Dumps the current internal state of the FileGroupManager. */
+  public ListenableFuture<Void> dump(final PrintWriter writer) {
+    writer.println("==== MDD_FILE_GROUP_MANAGER ====");
+    writer.println("MDD_FRESH_FILE_GROUPS:");
+    ListenableFuture<Void> writeDataFileGroupsFuture =
+        transformSequentialAsync(
+            fileGroupsMetadata.getAllFreshGroups(),
+            dataFileGroups -> {
+              ArrayList<Pair<GroupKey, DataFileGroupInternal>> sortedFileGroups =
+                  new ArrayList<>(dataFileGroups);
+              Collections.sort(
+                  sortedFileGroups,
+                  (pairA, pairB) ->
+                      ComparisonChain.start()
+                          .compare(pairA.first.getGroupName(), pairB.first.getGroupName())
+                          .compare(pairA.first.getAccount(), pairB.first.getAccount())
+                          .result());
+              for (Pair<GroupKey, DataFileGroupInternal> dataFileGroupPair : sortedFileGroups) {
+                // TODO(b/131166925): MDD dump should not use lite proto toString.
+                writer.format(
+                    "GroupName: %s\nAccount: %s\nDataFileGroup:\n %s\n\n",
+                    dataFileGroupPair.first.getGroupName(),
+                    dataFileGroupPair.first.getAccount(),
+                    dataFileGroupPair.second.toString());
+              }
+              return immediateVoidFuture();
+            });
+    return transformSequentialAsync(
+        writeDataFileGroupsFuture,
+        voidParam -> {
+          writer.println("MDD_STALE_FILE_GROUPS:");
+          return transformSequentialAsync(
+              fileGroupsMetadata.getAllStaleGroups(),
+              staleGroups -> {
+                for (DataFileGroupInternal fileGroup : staleGroups) {
+                  // TODO(b/131166925): MDD dump should not use lite proto toString.
+                  writer.format(
+                      "GroupName: %s\nDataFileGroup:\n%s\n",
+                      fileGroup.getGroupName(), fileGroup.toString());
+                }
+                return immediateVoidFuture();
+              });
+        });
+  }
+
+  /**
+   * TriggerSync for all pending groups. This is a catch-all effort in case triggerSync was not
+   * triggered before.
+   */
+  // TODO(b/160770792): Change to package private once all code is refactored.
+  public ListenableFuture<Void> triggerSyncAllPendingGroups() {
+    return immediateVoidFuture();
+  }
+
+  private static void logMddAndroidSharingLog(
+      EventLogger eventLogger, DataFileGroupInternal fileGroup, DataFile dataFile, int code) {
+    Void androidSharingEvent = null;
+    eventLogger.logMddAndroidSharingLog(androidSharingEvent);
+  }
+
+  private static void logMddAndroidSharingLog(
+      EventLogger eventLogger,
+      DataFileGroupInternal fileGroup,
+      DataFile dataFile,
+      int code,
+      boolean leaseAcquired,
+      long expiryDate) {
+    Void androidSharingEvent = null;
+    eventLogger.logMddAndroidSharingLog(androidSharingEvent);
+  }
+
+  private static void logEventWithDataFileGroup(
+      int code, EventLogger eventLogger, DataFileGroupInternal fileGroup) {
+    eventLogger.logEventSampled(
+        code,
+        fileGroup.getGroupName(),
+        fileGroup.getFileGroupVersionNumber(),
+        fileGroup.getBuildId(),
+        fileGroup.getVariantId());
+  }
+
+  private <I, O> ListenableFuture<O> transformSequential(
+      ListenableFuture<I> input, Function<? super I, ? extends O> function) {
+    return PropagatedFutures.transform(input, function, sequentialControlExecutor);
+  }
+
+  private <I, O> ListenableFuture<O> transformSequentialAsync(
+      ListenableFuture<I> input, AsyncFunction<? super I, ? extends O> function) {
+    return PropagatedFutures.transformAsync(input, function, sequentialControlExecutor);
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/FileGroupsMetadata.java b/java/com/google/android/libraries/mobiledatadownload/internal/FileGroupsMetadata.java
new file mode 100644
index 0000000..af555b0
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/FileGroupsMetadata.java
@@ -0,0 +1,118 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal;
+
+import android.util.Pair;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal;
+import com.google.mobiledatadownload.internal.MetadataProto.GroupKey;
+import com.google.mobiledatadownload.internal.MetadataProto.GroupKeyProperties;
+import java.util.List;
+import org.checkerframework.checker.nullness.compatqual.NullableType;
+
+/** Stores and provides access to file group metadata. */
+public interface FileGroupsMetadata {
+
+  /**
+   * Makes any changes that should be made before accessing the internal state of this store.
+   *
+   * <p>Other methods in this class do not call or check if this method was already called before
+   * trying to access internal state. It is expected from the caller to call this before anything
+   * else.
+   */
+  ListenableFuture<Void> init();
+
+  /** Returns a future resolving to the DataFileGroupInternal associated with key "groupKey". */
+  ListenableFuture<@NullableType DataFileGroupInternal> read(GroupKey groupKey);
+
+  /**
+   * Maps the key "groupKey" to the value "fileGroup" in the store. Returns future resolving to true
+   * if the operation succeeds, and false if not.
+   */
+  // TODO(b/159828199): This method should return a Void future and signal failure using exceptions
+  // instead of a Boolean.
+  ListenableFuture<Boolean> write(GroupKey groupKey, DataFileGroupInternal fileGroup);
+
+  /**
+   * Removes the DataFileGroupInternal associated with the key "groupKey" in the store. Returns
+   * future resolving to true if the operation succeeds, and false if not.
+   */
+  ListenableFuture<Boolean> remove(GroupKey groupKey);
+
+  /** Returns a future resolving to the GroupKeyProperties associated with the key "groupKey". */
+  ListenableFuture<@NullableType GroupKeyProperties> readGroupKeyProperties(GroupKey groupKey);
+
+  /**
+   * Maps the key "groupKey" to the value "groupKeyProperties" in the store. Returns future
+   * resolving to true if the operation succeeds, and false if not.
+   */
+  ListenableFuture<Boolean> writeGroupKeyProperties(
+      GroupKey groupKey, GroupKeyProperties groupKeyProperties);
+
+  /** Returns all keys in the store. */
+  ListenableFuture<List<GroupKey>> getAllGroupKeys();
+
+  /**
+   * Retrieves all groups in metadata. Will ignore groups that are unable to parse.
+   *
+   * @return A future resolving to a list containing pairs of serialized GroupKeys and the
+   *     corresponding DataFileGroups.
+   */
+  ListenableFuture<List<Pair<GroupKey, DataFileGroupInternal>>> getAllFreshGroups();
+
+  /**
+   * Removes all entries with a key in keys from the SharedPreferencesFileGroupsMetadata's storage.
+   * This method doesn't take care of garbage collecting any files used by this group, and that is
+   * left for the caller to do.
+   *
+   * @param keys - the list of keys to remove entries for
+   * @return - true if the removals were successfully persisted to disk.
+   */
+  ListenableFuture<Boolean> removeAllGroupsWithKeys(List<GroupKey> keys);
+
+  /**
+   * Retrieves all file groups on device that are marked as stale.
+   *
+   * @return Future resolving to a list of DataFileGroupInternal's stored in the garbage collection
+   *     file or an empty list if an IO error occurs or if the file doesn't exist (because all
+   *     previous groups had been deleted and no new groups had been added).
+   */
+  ListenableFuture<List<DataFileGroupInternal>> getAllStaleGroups();
+
+  /**
+   * Adds file group to list of file groups to unsubscribe from when their TTLs expire. This method
+   * will set the staleExpirationDate field on the file group.
+   *
+   * @param fileGroup - a file group that is no longer needed (and should release all of its files
+   *     once its TTL expires). The staleLifetimeSecs field must be set.
+   * @return future resolving to false if an IO error occurs
+   */
+  ListenableFuture<Boolean> addStaleGroup(DataFileGroupInternal fileGroup);
+
+  /**
+   * Write an array of stale file groups into garbage collector file.
+   *
+   * @param fileGroups - an array of file groups to write to garbage collection file
+   * @return future resolving to false if an IO error occurs
+   */
+  ListenableFuture<Boolean> writeStaleGroups(List<DataFileGroupInternal> fileGroups);
+
+  /** Deletes all storage for stale file groups. */
+  ListenableFuture<Void> removeAllStaleGroups();
+
+  /** Clears all storage used by the SharedPreferencesFileGroupsMetadata class. */
+  ListenableFuture<Void> clear();
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/MddConstants.java b/java/com/google/android/libraries/mobiledatadownload/internal/MddConstants.java
new file mode 100644
index 0000000..fae84b2
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/MddConstants.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal;
+
+import android.content.Context;
+
+/** Common MDD Constants */
+public class MddConstants {
+
+  // The gms app package name.
+  public static final String GMS_PACKAGE = "com.google.android.gms";
+
+  // MDD Phenotype base package name.
+  private static final String BASE_CONFIG_PACKAGE_NAME = "com.google.android.gms.icing.mdd";
+
+  // The mdd package name. This is also the mdd phenotype package name.
+  // TODO: Replace usage with getPhenotypeConfigPackageName.
+  public static final String CONFIG_PACKAGE_NAME = "com.google.android.gms.icing.mdd";
+
+  /** Returns the ph package name that the host app should register with on behalf of MDD. */
+  public static String getPhenotypeConfigPackageName(Context context) {
+    if (context.getPackageName().equals(GMS_PACKAGE)) {
+      return BASE_CONFIG_PACKAGE_NAME;
+    } else {
+      return BASE_CONFIG_PACKAGE_NAME + "#" + context.getPackageName();
+    }
+  }
+
+  /** Icing-specific constants. Source of truth is the corresponding Icing files. * */
+  // The Icing log source name from here:
+  // <internal>
+  // LINT.IfChange
+  public static final String ICING_LOG_SOURCE_NAME = "ICING";
+  // LINT.ThenChange(<internal>)
+
+  public static final String MDD_GCM_TASK_SERVICE_PROXY_CLASS_NAME =
+      "com.google.android.gms.mdi.download.service.MddGcmTaskService";
+
+  public static final String SPLIT_CHAR = "|";
+
+  /** The custom URL scheme used by MDD to identify inline files. */
+  public static final String INLINE_FILE_URL_SCHEME = "inlinefile";
+
+  /** URL schemes used for sideloaded files. */
+  public static final String SIDELOAD_FILE_URL_SCHEME = "file";
+
+  public static final String EMBEDDED_ASSET_URL_SCHEME = "asset";
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/Migrations.java b/java/com/google/android/libraries/mobiledatadownload/internal/Migrations.java
new file mode 100644
index 0000000..ec1c925
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/Migrations.java
@@ -0,0 +1,108 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import com.google.android.libraries.mobiledatadownload.SilentFeedback;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil;
+
+/** This class holds the migrations status of any migrations currently going on in MDD. */
+public class Migrations {
+
+  private static final String TAG = "Migrations";
+
+  static final String MDD_MIGRATIONS = "gms_icing_mdd_migrations";
+
+  static final String PREFS_KEY_MIGRATED_TO_NEW_FILE_KEY = "migrated_to_new_file_key";
+
+  static final String MDD_FILE_KEY_VERSION = "mdd_file_key_version";
+
+  static boolean isMigratedToNewFileKey(Context context) {
+    SharedPreferences migrationPrefs =
+        context.getSharedPreferences(MDD_MIGRATIONS, Context.MODE_PRIVATE);
+    return migrationPrefs.getBoolean(PREFS_KEY_MIGRATED_TO_NEW_FILE_KEY, false);
+  }
+
+  // TODO(b/124072754): Change to package private once all code is refactored.
+  public static void setMigratedToNewFileKey(Context context, boolean value) {
+    LogUtil.d("%s: Setting migration to new file key to %s", TAG, value);
+    SharedPreferences migrationPrefs =
+        context.getSharedPreferences(MDD_MIGRATIONS, Context.MODE_PRIVATE);
+    migrationPrefs.edit().putBoolean(PREFS_KEY_MIGRATED_TO_NEW_FILE_KEY, value).commit();
+  }
+
+  /** Enum for FileKey Migration, NEW_FILE_KEY is the base version. */
+  public enum FileKeyVersion {
+    NEW_FILE_KEY(0),
+    ADD_DOWNLOAD_TRANSFORM(1),
+
+    // Remove byte size and url from the key, and only de-dup files on checksum of the file.
+    USE_CHECKSUM_ONLY(2);
+
+    public final int value;
+
+    FileKeyVersion(int value) {
+      this.value = value;
+    }
+
+    static FileKeyVersion getVersion(int ver) {
+      switch (ver) {
+        case 0:
+          return NEW_FILE_KEY;
+        case 1:
+          return ADD_DOWNLOAD_TRANSFORM;
+        case 2:
+          return USE_CHECKSUM_ONLY;
+        default:
+          throw new IllegalArgumentException("Unknown MDD FileKey version:" + ver);
+      }
+    }
+  }
+
+  // TODO(b/124072754): Change to package private once all code is refactored.
+  public static FileKeyVersion getCurrentVersion(Context context, SilentFeedback silentFeedback) {
+    SharedPreferences migrationPrefs =
+        context.getSharedPreferences(MDD_MIGRATIONS, Context.MODE_PRIVATE);
+    // Make NEW_FILE_KEY the default, it is the base version.  Without NEW_FILE_KEY migration, the
+    // version migration won't happen
+    int fileKeyVersion =
+        migrationPrefs.getInt(MDD_FILE_KEY_VERSION, FileKeyVersion.NEW_FILE_KEY.value);
+    try {
+      return FileKeyVersion.getVersion(fileKeyVersion);
+    } catch (IllegalArgumentException ex) {
+      // Clear the corrupted file key metadata, return the default file key version.
+      silentFeedback.send(
+          ex, "FileKey version metadata corrupted with unknown version: %d", fileKeyVersion);
+      clear(context);
+      return FileKeyVersion.USE_CHECKSUM_ONLY;
+    }
+  }
+
+  public static boolean setCurrentVersion(Context context, FileKeyVersion newVersion) {
+    LogUtil.d("%s: Setting FileKeyVersion to %s", TAG, newVersion.name());
+    SharedPreferences migrationPrefs =
+        context.getSharedPreferences(MDD_MIGRATIONS, Context.MODE_PRIVATE);
+    return migrationPrefs.edit().putInt(MDD_FILE_KEY_VERSION, newVersion.value).commit();
+  }
+
+  // TODO(b/124072754): Change to package private once all code is refactored.
+  public static void clear(Context context) {
+    SharedPreferences migrationPrefs =
+        context.getSharedPreferences(MDD_MIGRATIONS, Context.MODE_PRIVATE);
+    migrationPrefs.edit().clear().commit();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/MobileDataDownloadManager.java b/java/com/google/android/libraries/mobiledatadownload/internal/MobileDataDownloadManager.java
new file mode 100644
index 0000000..7285ebe
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/MobileDataDownloadManager.java
@@ -0,0 +1,799 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal;
+
+import static com.google.common.util.concurrent.Futures.immediateFuture;
+import static com.google.common.util.concurrent.Futures.immediateVoidFuture;
+import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.net.Uri;
+import android.os.Build.VERSION;
+import android.os.Build.VERSION_CODES;
+import android.util.Pair;
+import androidx.annotation.VisibleForTesting;
+import com.google.android.libraries.mobiledatadownload.FileSource;
+import com.google.android.libraries.mobiledatadownload.Flags;
+import com.google.android.libraries.mobiledatadownload.SilentFeedback;
+import com.google.android.libraries.mobiledatadownload.annotations.InstanceId;
+import com.google.android.libraries.mobiledatadownload.file.transforms.TransformProtos;
+import com.google.android.libraries.mobiledatadownload.internal.FileGroupManager.GroupDownloadStatus;
+import com.google.android.libraries.mobiledatadownload.internal.annotations.SequentialControlExecutor;
+import com.google.android.libraries.mobiledatadownload.internal.downloader.FileValidator;
+import com.google.android.libraries.mobiledatadownload.internal.experimentation.DownloadStageManager;
+import com.google.android.libraries.mobiledatadownload.internal.logging.EventLogger;
+import com.google.android.libraries.mobiledatadownload.internal.logging.FileGroupStatsLogger;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LoggingStateStore;
+import com.google.android.libraries.mobiledatadownload.internal.logging.NetworkLogger;
+import com.google.android.libraries.mobiledatadownload.internal.logging.StorageLogger;
+import com.google.android.libraries.mobiledatadownload.internal.util.FileGroupUtil;
+import com.google.android.libraries.mobiledatadownload.internal.util.SharedPreferencesUtil;
+import com.google.android.libraries.mobiledatadownload.tracing.PropagatedFluentFuture;
+import com.google.android.libraries.mobiledatadownload.tracing.PropagatedFutures;
+import com.google.common.base.Optional;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.util.concurrent.AsyncFunction;
+import com.google.common.util.concurrent.FluentFuture;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.errorprone.annotations.CheckReturnValue;
+import com.google.mobiledatadownload.TransformProto.Transforms;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFile;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFile.ChecksumType;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal;
+import com.google.mobiledatadownload.internal.MetadataProto.DownloadConditions;
+import com.google.mobiledatadownload.internal.MetadataProto.GroupKey;
+import com.google.protobuf.Any;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Executor;
+import javax.annotation.concurrent.NotThreadSafe;
+import javax.inject.Inject;
+import org.checkerframework.checker.nullness.compatqual.NullableType;
+
+/**
+ * Mobile Data Download Manager is a wrapper over all MDD functions and provides methods for the
+ * public API of MDD as well as internal periodic tasks that handle things like downloading and
+ * garbage collection of data.
+ *
+ * <p>This class is not thread safe, and all calls to it are currently channeled through {@link
+ * com.google.android.gms.mdi.download.service.DataDownloadChimeraService}, running operations in a
+ * single thread.
+ */
+@NotThreadSafe
+@CheckReturnValue
+public class MobileDataDownloadManager {
+
+  private static final String TAG = "MDDManager";
+
+  @VisibleForTesting static final String MDD_MANAGER_METADATA = "gms_icing_mdd_manager_metadata";
+
+  private static final String MDD_PH_CONFIG_VERSION = "gms_icing_mdd_manager_ph_config_version";
+
+  private static final String MDD_PH_CONFIG_VERSION_TS =
+      "gms_icing_mdd_manager_ph_config_version_timestamp";
+
+  @VisibleForTesting static final String MDD_MIGRATED_TO_OFFROAD = "mdd_migrated_to_offroad";
+
+  @VisibleForTesting static final String RESET_TRIGGER = "gms_icing_mdd_reset_trigger";
+
+  private static final int DEFAULT_DAYS_SINCE_LAST_MAINTENANCE = -1;
+
+  private static volatile boolean isInitialized = false;
+
+  private final Context context;
+  private final EventLogger eventLogger;
+  private final FileGroupManager fileGroupManager;
+  private final FileGroupsMetadata fileGroupsMetadata;
+  private final SharedFileManager sharedFileManager;
+  private final SharedFilesMetadata sharedFilesMetadata;
+  private final ExpirationHandler expirationHandler;
+  private final SilentFeedback silentFeedback;
+  private final StorageLogger storageLogger;
+  private final FileGroupStatsLogger fileGroupStatsLogger;
+  private final NetworkLogger networkLogger;
+  private final Optional<String> instanceId;
+  private final Executor sequentialControlExecutor;
+  private final Flags flags;
+  private final LoggingStateStore loggingStateStore;
+  private final DownloadStageManager downloadStageManager;
+
+  @Inject
+  // TODO: Create a delegateLogger for all logging instead of adding separate logger for
+  // each type.
+  public MobileDataDownloadManager(
+      @ApplicationContext Context context,
+      EventLogger eventLogger,
+      SharedFileManager sharedFileManager,
+      SharedFilesMetadata sharedFilesMetadata,
+      FileGroupManager fileGroupManager,
+      FileGroupsMetadata fileGroupsMetadata,
+      ExpirationHandler expirationHandler,
+      SilentFeedback silentFeedback,
+      StorageLogger storageLogger,
+      FileGroupStatsLogger fileGroupStatsLogger,
+      NetworkLogger networkLogger,
+      @InstanceId Optional<String> instanceId,
+      @SequentialControlExecutor Executor sequentialControlExecutor,
+      Flags flags,
+      LoggingStateStore loggingStateStore,
+      DownloadStageManager downloadStageManager) {
+    this.context = context;
+    this.eventLogger = eventLogger;
+    this.sharedFileManager = sharedFileManager;
+    this.sharedFilesMetadata = sharedFilesMetadata;
+    this.fileGroupManager = fileGroupManager;
+    this.fileGroupsMetadata = fileGroupsMetadata;
+    this.expirationHandler = expirationHandler;
+    this.silentFeedback = silentFeedback;
+    this.storageLogger = storageLogger;
+    this.fileGroupStatsLogger = fileGroupStatsLogger;
+    this.networkLogger = networkLogger;
+    this.instanceId = instanceId;
+    this.sequentialControlExecutor = sequentialControlExecutor;
+    this.flags = flags;
+    this.loggingStateStore = loggingStateStore;
+    this.downloadStageManager = downloadStageManager;
+  }
+
+  /**
+   * Makes the MDDManager ready for use by performing any upgrades that should be done before using
+   * MDDManager. It is also responsible for initializing all classes underneath, and clears MDD
+   * internal storage if any class init fails.
+   *
+   * <p>This should be the first call in any public method in this class, other than {@link
+   * #clear()}.
+   */
+  @SuppressWarnings("nullness")
+  public ListenableFuture<Void> init() {
+    if (isInitialized) {
+      return immediateVoidFuture();
+    }
+    SharedPreferences prefs =
+        SharedPreferencesUtil.getSharedPreferences(context, MDD_MANAGER_METADATA, instanceId);
+    return PropagatedFluentFuture.from(Futures.immediateFuture(null))
+        .transformAsync(
+            voidArg -> {
+              // Offroad downloader migration. Since the migration has been enabled in gms
+              // v18, most devices have migrated. For the remaining, we will clear MDD
+              // storage.
+              if (!prefs.getBoolean(MDD_MIGRATED_TO_OFFROAD, false)) {
+                LogUtil.d("%s Clearing MDD as device isn't migrated to offroad.", TAG);
+                return PropagatedFutures.transform(
+                    clearForInit(),
+                    voidArg1 -> {
+                      prefs.edit().putBoolean(MDD_MIGRATED_TO_OFFROAD, true).commit();
+                      return null;
+                    },
+                    sequentialControlExecutor);
+              }
+              return Futures.immediateFuture(null);
+            },
+            sequentialControlExecutor)
+        .transformAsync(
+            voidArg ->
+                PropagatedFutures.transformAsync(
+                    sharedFileManager.init(),
+                    initSuccess -> {
+                      if (!initSuccess) {
+                        // This should be init before the shared file metadata.
+                        LogUtil.w("%s Failed to init shared file manager.", TAG);
+                        return clearForInit();
+                      }
+                      return Futures.immediateVoidFuture();
+                    },
+                    sequentialControlExecutor),
+            sequentialControlExecutor)
+        .transformAsync(
+            voidArg ->
+                PropagatedFutures.transformAsync(
+                    sharedFilesMetadata.init(),
+                    initSuccess -> {
+                      if (!initSuccess) {
+                        LogUtil.w("%s Failed to init shared file metadata.", TAG);
+                        return clearForInit();
+                      }
+                      return Futures.immediateVoidFuture();
+                    },
+                    sequentialControlExecutor),
+            sequentialControlExecutor)
+        .transformAsync(voidArg -> fileGroupsMetadata.init(), sequentialControlExecutor)
+        .transform(
+            voidArg -> {
+              isInitialized = true;
+              return null;
+            },
+            sequentialControlExecutor);
+  }
+
+  /**
+   * Adds the given data file group for download, after doing some sanity testing on the group.
+   *
+   * <p>This doesn't start the download right away. The data is downloaded later when the device has
+   * wifi available, by calling {@link #downloadAllPendingGroups}.
+   *
+   * <p>Calling this api with the exact same file group multiple times is a no op.
+   *
+   * @param groupKey The key for the data to be returned. This is a combination of many parameters
+   *     like group name, user account.
+   * @param dataFileGroup The File group that needs to be downloaded.
+   * @return A future that resolves to true if the group was successfully added for download, or the
+   *     exact group was already added earlier; false if the group being added was invalid or an I/O
+   *     error occurs.
+   */
+  // TODO(b/143572409): addGroupForDownload() call-chain should return void and use exceptions
+  // instead of boolean for failure
+  public ListenableFuture<Boolean> addGroupForDownload(
+      GroupKey groupKey, DataFileGroupInternal dataFileGroup) {
+    return addGroupForDownloadInternal(
+        groupKey, dataFileGroup, unused -> Futures.immediateFuture(true));
+  }
+
+  public ListenableFuture<Boolean> addGroupForDownloadInternal(
+      GroupKey groupKey,
+      DataFileGroupInternal dataFileGroup,
+      AsyncFunction<DataFileGroupInternal, Boolean> customFileGroupValidator) {
+    LogUtil.d("%s addGroupForDownload %s", TAG, groupKey.getGroupName());
+    return PropagatedFutures.transformAsync(
+        init(),
+        voidArg -> {
+          // Check if the group we received is a valid group.
+          if (!DataFileGroupValidator.isValidGroup(dataFileGroup, context, flags)) {
+            eventLogger.logEventSampled(
+                0,
+                dataFileGroup.getGroupName(),
+                dataFileGroup.getFileGroupVersionNumber(),
+                dataFileGroup.getBuildId(),
+                dataFileGroup.getVariantId());
+            return Futures.immediateFuture(false);
+          }
+
+          DataFileGroupInternal populatedDataFileGroup = mayPopulateChecksum(dataFileGroup);
+          try {
+            return PropagatedFutures.transformAsync(
+                fileGroupManager.addGroupForDownload(groupKey, populatedDataFileGroup),
+                addGroupForDownloadResult -> {
+                  if (addGroupForDownloadResult) {
+                    return PropagatedFutures.transform(
+                        fileGroupManager.verifyPendingGroupDownloaded(
+                            groupKey, populatedDataFileGroup, customFileGroupValidator),
+                        verifyPendingGroupDownloadedResult -> {
+                          if (verifyPendingGroupDownloadedResult
+                              == GroupDownloadStatus.DOWNLOADED) {
+                            eventLogger.logEventSampled(
+                                0,
+                                populatedDataFileGroup.getGroupName(),
+                                populatedDataFileGroup.getFileGroupVersionNumber(),
+                                populatedDataFileGroup.getBuildId(),
+                                populatedDataFileGroup.getVariantId());
+                          }
+                          return true;
+                        },
+                        sequentialControlExecutor);
+                  }
+                  return Futures.immediateFuture(true);
+                },
+                sequentialControlExecutor);
+          } catch (ExpiredFileGroupException
+              | UninstalledAppException
+              | ActivationRequiredForGroupException e) {
+            LogUtil.w("%s %s", TAG, e.getClass());
+            return Futures.immediateFailedFuture(e);
+          } catch (IOException e) {
+            LogUtil.e("%s %s", TAG, e.getClass());
+            silentFeedback.send(e, "Failed to add group to MDD");
+            return Futures.immediateFailedFuture(e);
+          }
+        },
+        sequentialControlExecutor);
+  }
+
+  /**
+   * Removes the file group from MDD with the given group key. This will cancel any ongoing download
+   * of the file group.
+   *
+   * @param groupKey The key for the file group to be removed from MDD. This is a combination of
+   *     many parameters like group name, user account.
+   * @param pendingOnly When true, only remove the pending version of this file group.
+   * @return ListenableFuture that may throw an IOException if some error is encountered when
+   *     removing from metadata or a SharedFileMissingException if some of the shared file metadata
+   *     is missing.
+   */
+  public ListenableFuture<Void> removeFileGroup(GroupKey groupKey, boolean pendingOnly)
+      throws SharedFileMissingException, IOException {
+    LogUtil.d("%s removeFileGroup %s", TAG, groupKey.getGroupName());
+
+    return Futures.transformAsync(
+        init(),
+        voidArg -> fileGroupManager.removeFileGroup(groupKey, pendingOnly),
+        sequentialControlExecutor);
+  }
+
+  /**
+   * Removes the file groups from MDD with the given group keys.
+   *
+   * <p>This will cancel any ongoing downloads of file groups that should be removed.
+   *
+   * @param groupKeys The keys of file groups that should be removed from MDD.
+   * @return ListenableFuture that resolves when file groups have been deleted, or fails if some
+   *     error is encountered when removing metadata.
+   */
+  public ListenableFuture<Void> removeFileGroups(List<GroupKey> groupKeys) {
+    LogUtil.d("%s removeFileGroups for %d groups", TAG, groupKeys.size());
+
+    return Futures.transformAsync(
+        init(), voidArg -> fileGroupManager.removeFileGroups(groupKeys), sequentialControlExecutor);
+  }
+
+  /**
+   * Returns the latest data that we have for the given client key.
+   *
+   * @param groupKey The key for the data to be returned. This is a combination of many parameters
+   *     like group name, user account.
+   * @param downloaded Whether to return a downloaded version or a pending version of the group.
+   * @return A ListenableFuture that resolves to the requested data file group for the given group
+   *     name, if it exists, null otherwise.
+   */
+  public ListenableFuture<@NullableType DataFileGroupInternal> getFileGroup(
+      GroupKey groupKey, boolean downloaded) {
+    LogUtil.d("%s getFileGroup %s %s", TAG, groupKey.getGroupName(), groupKey.getOwnerPackage());
+
+    return Futures.transformAsync(
+        init(),
+        voidArg -> fileGroupManager.getFileGroup(groupKey, downloaded),
+        sequentialControlExecutor);
+  }
+
+  /** Returns a future resolving to a list of all pending and downloaded groups in MDD. */
+  public ListenableFuture<List<Pair<GroupKey, DataFileGroupInternal>>> getAllFreshGroups() {
+    LogUtil.d("%s getAllFreshGroups", TAG);
+
+    return Futures.transformAsync(
+        init(), voidArg -> fileGroupsMetadata.getAllFreshGroups(), sequentialControlExecutor);
+  }
+
+  /**
+   * Returns a future resolving to the URI at which the given data file is located on the disc.
+   * Returns null if there was error in generating the URI.
+   */
+  public ListenableFuture<@NullableType Uri> getDataFileUri(
+      DataFile dataFile, DataFileGroupInternal dataFileGroup) {
+    LogUtil.d("%s getDataFileUri %s %s", TAG, dataFile.getFileId(), dataFileGroup.getGroupName());
+    return Futures.transformAsync(
+        init(),
+        voidArg -> {
+          ListenableFuture<@NullableType Uri> onDeviceUriFuture =
+              fileGroupManager.getOnDeviceUri(dataFile, dataFileGroup);
+          return Futures.transform(
+              onDeviceUriFuture,
+              onDeviceUri -> {
+                Uri finalOnDeviceUri = onDeviceUri;
+                // Check if file group should use isolated uri
+                if (finalOnDeviceUri != null
+                    && FileGroupUtil.isIsolatedStructureAllowed(dataFileGroup)
+                    && VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
+                  try {
+                    finalOnDeviceUri =
+                        fileGroupManager.getAndVerifyIsolatedFileUri(
+                            finalOnDeviceUri, dataFile, dataFileGroup);
+                  } catch (IOException e) {
+                    LogUtil.e(
+                        e,
+                        "%s getDataFileUri %s %s unable to get isolated file uri!",
+                        TAG,
+                        dataFile.getFileId(),
+                        dataFileGroup.getGroupName());
+                    finalOnDeviceUri = null;
+                  }
+                }
+
+                if (finalOnDeviceUri != null && dataFile.hasReadTransforms()) {
+                  finalOnDeviceUri =
+                      applyTransformsToFileUri(finalOnDeviceUri, dataFile.getReadTransforms());
+                }
+
+                return finalOnDeviceUri;
+              },
+              sequentialControlExecutor);
+        },
+        sequentialControlExecutor);
+  }
+
+  private Uri applyTransformsToFileUri(Uri fileUri, Transforms transforms) {
+    if (!flags.enableCompressedFile() || transforms.getTransformCount() == 0) {
+      return fileUri;
+    }
+    return fileUri
+        .buildUpon()
+        .encodedFragment(TransformProtos.toEncodedFragment(transforms))
+        .build();
+  }
+
+  /**
+   * Import inline files into an exising DataFileGroup and update its metadata accordingly.
+   *
+   * @param groupKey The key of file group to update
+   * @param buildId build id to identify the file group to update
+   * @param variantId variant id to identify the file group to update
+   * @param updatedDataFileList list of DataFiles to import into the file group
+   * @param inlineFileMap Map of inline file sources to import
+   * @param customPropertyOptional Optional custom property used to identify the file group to
+   *     update
+   * @return A ListenableFuture that resolves when inline files have successfully imported
+   */
+  public ListenableFuture<Void> importFiles(
+      GroupKey groupKey,
+      long buildId,
+      String variantId,
+      ImmutableList<DataFile> updatedDataFileList,
+      ImmutableMap<String, FileSource> inlineFileMap,
+      Optional<Any> customPropertyOptional,
+      AsyncFunction<DataFileGroupInternal, Boolean> customFileGroupValidator) {
+    LogUtil.d("%s: importFiles %s %s", TAG, groupKey.getGroupName(), groupKey.getOwnerPackage());
+    return Futures.transformAsync(
+        init(),
+        voidArg ->
+            fileGroupManager.importFilesIntoFileGroup(
+                groupKey,
+                buildId,
+                variantId,
+                mayPopulateChecksum(updatedDataFileList),
+                inlineFileMap,
+                customPropertyOptional,
+                customFileGroupValidator),
+        sequentialControlExecutor);
+  }
+
+  /**
+   * Download the pending group that we have for the given group key.
+   *
+   * @param groupKey The key of file group to be downloaded.
+   * @param downloadConditionsOptional The conditions for the download. If absent, MDD will use the
+   *     config from server.
+   * @return The ListenableFuture that download the file group.
+   */
+  public ListenableFuture<DataFileGroupInternal> downloadFileGroup(
+      GroupKey groupKey,
+      Optional<DownloadConditions> downloadConditionsOptional,
+      AsyncFunction<DataFileGroupInternal, Boolean> customFileGroupValidator) {
+    LogUtil.d(
+        "%s downloadFileGroup %s %s", TAG, groupKey.getGroupName(), groupKey.getOwnerPackage());
+    return Futures.transformAsync(
+        init(),
+        voidArg ->
+            fileGroupManager.downloadFileGroup(
+                groupKey, downloadConditionsOptional.orNull(), customFileGroupValidator),
+        sequentialControlExecutor);
+  }
+
+  /**
+   * Set the activation status for the group.
+   *
+   * @param groupKey The key for which the activation is to be set.
+   * @param activation Whether the group should be activated or deactivated.
+   * @return future resolving to whether the activation was successful.
+   */
+  public ListenableFuture<Boolean> setGroupActivation(GroupKey groupKey, boolean activation) {
+    LogUtil.d(
+        "%s setGroupActivation %s %s", TAG, groupKey.getGroupName(), groupKey.getOwnerPackage());
+    return Futures.transformAsync(
+        init(),
+        voidArg -> fileGroupManager.setGroupActivation(groupKey, activation),
+        sequentialControlExecutor);
+  }
+
+  /**
+   * Tries to download all pending file groups, which contains at least one file that isn't yet
+   * downloaded.
+   *
+   * @param onWifi whether the device is on wifi at the moment.
+   */
+  public ListenableFuture<Void> downloadAllPendingGroups(
+      boolean onWifi, AsyncFunction<DataFileGroupInternal, Boolean> customFileGroupValidator) {
+    LogUtil.d("%s downloadAllPendingGroups on wifi = %s", TAG, onWifi);
+    return Futures.transformAsync(
+        init(),
+        voidArg -> {
+          if (flags.mddEnableDownloadPendingGroups()) {
+            eventLogger.logEventSampled(0);
+            return fileGroupManager.scheduleAllPendingGroupsForDownload(
+                onWifi, customFileGroupValidator);
+          }
+          return immediateVoidFuture();
+        },
+        sequentialControlExecutor);
+  }
+
+  /**
+   * Tries to verify all pending file groups, which contains at least one file that isn't yet
+   * downloaded.
+   */
+  public ListenableFuture<Void> verifyAllPendingGroups(
+      AsyncFunction<DataFileGroupInternal, Boolean> customFileGroupValidator) {
+    LogUtil.d("%s verifyAllPendingGroups", TAG);
+    return Futures.transformAsync(
+        init(),
+        voidArg -> {
+          if (flags.mddEnableVerifyPendingGroups()) {
+            eventLogger.logEventSampled(0);
+            return fileGroupManager.verifyAllPendingGroupsDownloaded(customFileGroupValidator);
+          }
+          return immediateVoidFuture();
+        },
+        sequentialControlExecutor);
+  }
+
+  /**
+   * Performs periodic maintenance. This includes:
+   *
+   * <ol>
+   *   <li>Check if any of the pending groups were downloaded.
+   *   <li>Garbage collect all old data mdd has.
+   * </ol>
+   */
+  public ListenableFuture<Void> maintenance() {
+    LogUtil.d("%s Running maintenance", TAG);
+
+    return FluentFuture.from(init())
+        .transformAsync(voidArg -> getAndResetDaysSinceLastMaintenance(), directExecutor())
+        .transformAsync(
+            daysSinceLastLog -> {
+              List<ListenableFuture<Void>> maintenanceFutures = new ArrayList<>();
+
+              // It's possible that we missed the flag change notification for mdd reset before.
+              // Check now to be sure.
+              maintenanceFutures.add(checkResetTrigger());
+
+              if (flags.logFileGroupsWithFilesMissing()) {
+                maintenanceFutures.add(fileGroupManager.logAndDeleteForMissingSharedFiles());
+              }
+
+              // Remove all groups belonging to apps that were uninstalled.
+              if (flags.mddDeleteUninstalledApps()) {
+                maintenanceFutures.add(fileGroupManager.deleteUninstalledAppGroups());
+              }
+
+              // Remove all groups belonging to accounts that were removed.
+              if (flags.mddDeleteGroupsRemovedAccounts()) {
+                maintenanceFutures.add(fileGroupManager.deleteRemovedAccountGroups());
+              }
+
+              if (flags.enableIsolatedStructureVerification()) {
+                maintenanceFutures.add(fileGroupManager.verifyAndAttemptToRepairIsolatedFiles());
+              }
+
+              if (flags.mddEnableGarbageCollection()) {
+                maintenanceFutures.add(expirationHandler.updateExpiration());
+                eventLogger.logEventSampled(0);
+              }
+
+              // Log daily file group stats.
+              maintenanceFutures.add(fileGroupStatsLogger.log(daysSinceLastLog));
+
+              // Log storage stats.
+              maintenanceFutures.add(storageLogger.logStorageStats(daysSinceLastLog));
+
+              // Log network usage stats.
+              maintenanceFutures.add(networkLogger.log());
+
+              // Clear checkPhenotypeFreshness settings from Shared Prefs as the feature was
+              // deleted.
+              SharedPreferences prefs =
+                  SharedPreferencesUtil.getSharedPreferences(
+                      context, MDD_MANAGER_METADATA, instanceId);
+              prefs.edit().remove(MDD_PH_CONFIG_VERSION).remove(MDD_PH_CONFIG_VERSION_TS).commit();
+
+              return Futures.whenAllComplete(maintenanceFutures)
+                  .call(() -> null, sequentialControlExecutor);
+            },
+            sequentialControlExecutor);
+  }
+
+  /** Dumps the current internal state of the MDD manager. */
+  public ListenableFuture<Void> dump(final PrintWriter writer) {
+    return Futures.transformAsync(
+        init(),
+        voidArg ->
+            Futures.transformAsync(
+                fileGroupManager.dump(writer),
+                voidParam -> sharedFileManager.dump(writer),
+                sequentialControlExecutor),
+        sequentialControlExecutor);
+  }
+
+  /** Checks to see if a flag change requires MDD to clear its data. */
+  public ListenableFuture<Void> checkResetTrigger() {
+    LogUtil.d("%s checkResetTrigger", TAG);
+    return Futures.transformAsync(
+        init(),
+        voidArg -> {
+          SharedPreferences prefs =
+              SharedPreferencesUtil.getSharedPreferences(context, MDD_MANAGER_METADATA, instanceId);
+          if (!prefs.contains(RESET_TRIGGER)) {
+            prefs.edit().putInt(RESET_TRIGGER, flags.mddResetTrigger()).commit();
+          }
+          int savedResetValue = prefs.getInt(RESET_TRIGGER, 0);
+          int currentResetValue = flags.mddResetTrigger();
+          // If the flag has changed since we last saw it, save the new value in shared prefs and
+          // clear.
+          if (savedResetValue < currentResetValue) {
+            prefs.edit().putInt(RESET_TRIGGER, currentResetValue).commit();
+            LogUtil.d("%s Received reset trigger. Clearing all Mdd data.", TAG);
+            eventLogger.logEventSampled(0);
+            return clearAllFilesAndMetadata();
+          }
+          return immediateVoidFuture();
+        },
+        sequentialControlExecutor);
+  }
+
+  /** Clears the internal state of MDD and deletes all downloaded files. */
+  @SuppressWarnings("ApplySharedPref")
+  public ListenableFuture<Void> clear() {
+    LogUtil.d("%s Clearing MDD internal storage", TAG);
+
+    // Delete all of the bookkeeping files used by MDD Manager's internal classes.
+    // Clear downloadStageManager first since it needs to know which builds to delete from
+    // SharedFilesMetadata.
+    return PropagatedFluentFuture.from(downloadStageManager.clearAll())
+        .transformAsync(voidArg -> clearAllFilesAndMetadata(), sequentialControlExecutor)
+        .transformAsync(
+            voidArg -> {
+              // Clear all migration status.
+              Migrations.clear(context);
+              SharedPreferencesUtil.getSharedPreferences(context, MDD_MANAGER_METADATA, instanceId)
+                  .edit()
+                  .clear()
+                  .commit();
+
+              isInitialized = false;
+              return immediateVoidFuture();
+            },
+            sequentialControlExecutor)
+        .transformAsync(voidArg -> loggingStateStore.clear(), sequentialControlExecutor);
+  }
+
+  @VisibleForTesting
+  public static void resetForTest() {
+    isInitialized = false;
+  }
+
+  /** Clear during MDD init */
+  private ListenableFuture<Void> clearForInit() {
+    return PropagatedFutures.transformAsync(
+        // Clear only, no need to cancel download.
+        sharedFileManager.clear(),
+        voidArg0 ->
+            // The metadata files should be cleared after the classes have been cleared.
+            PropagatedFutures.transformAsync(
+                sharedFilesMetadata.clear(),
+                voidArg1 -> fileGroupsMetadata.clear(),
+                sequentialControlExecutor),
+        sequentialControlExecutor);
+  }
+
+  /* Clear all metadata and files, also cancel pending download. */
+  private ListenableFuture<Void> clearAllFilesAndMetadata() {
+    return Futures.transformAsync(
+        // Need to cancel download after MDD is already initialized.
+        sharedFileManager.cancelDownloadAndClear(),
+        voidArg1 ->
+            // The metadata files should be cleared after the classes have been cleared.
+            Futures.transformAsync(
+                sharedFilesMetadata.clear(),
+                voidArg2 -> fileGroupsMetadata.clear(),
+                sequentialControlExecutor),
+        sequentialControlExecutor);
+  }
+
+  // Convenience method to populate checksums for a DataFileGroup
+  private static DataFileGroupInternal mayPopulateChecksum(DataFileGroupInternal dataFileGroup) {
+    List<DataFile> dataFileList = dataFileGroup.getFileList();
+    ImmutableList<DataFile> updatedDataFileList = mayPopulateChecksum(dataFileList);
+    return dataFileGroup.toBuilder().clearFile().addAllFile(updatedDataFileList).build();
+  }
+
+  private static ImmutableList<DataFile> mayPopulateChecksum(List<DataFile> dataFileList) {
+    boolean hasChecksumTypeNone = false;
+
+    for (DataFile dataFile : dataFileList) {
+      if (dataFile.getChecksumType() == ChecksumType.NONE) {
+        hasChecksumTypeNone = true;
+        break;
+      }
+    }
+
+    if (!hasChecksumTypeNone) {
+      return ImmutableList.copyOf(dataFileList);
+    }
+
+    // Check if any file does not have checksum, replace the checksum with the checksum of
+    // download url.
+    ImmutableList.Builder<DataFile> dataFileListBuilder =
+        ImmutableList.builderWithExpectedSize(dataFileList.size());
+    for (DataFile dataFile : dataFileList) {
+      switch (dataFile.getChecksumType()) {
+          // Default stands for SHA1.
+        case DEFAULT:
+          dataFileListBuilder.add(dataFile);
+          break;
+        case NONE:
+          // Since internally we use checksum as a key, it can't be empty. We will generate the
+          // checksum using the urlToDownload if it's not set.
+          DataFile.Builder dataFileBuilder = dataFile.toBuilder();
+          String checksum = FileValidator.computeSha1Digest(dataFile.getUrlToDownload());
+          // When a data file has zip transforms, downloaded file checksum is used for identifying
+          // the data file; otherwise, checksum is used.
+          if (FileGroupUtil.hasZipDownloadTransform(dataFile)) {
+            dataFileBuilder.setDownloadedFileChecksum(checksum);
+          } else {
+            dataFileBuilder.setChecksum(checksum);
+          }
+          LogUtil.d(
+              "FileId %s does not have checksum. Generated checksum from url %s",
+              dataFileBuilder.getFileId(), dataFileBuilder.getChecksum());
+
+          dataFileListBuilder.add(dataFileBuilder.build());
+          break;
+          // continue below.
+      }
+    }
+
+    return dataFileListBuilder.build();
+  }
+
+  /**
+   * Gets and resets the number of days since last maintenance from {@link loggingStateStore}. If
+   * loggingStateStore fails to provide a value (if it throws an exception or the value was not set)
+   * this handles that by returning -1. clear
+   *
+   * <p>If {@link Flags.enableDaysSinceLastMaintenanceTracking} is not enabled, this returns -1.
+   */
+  private ListenableFuture<Integer> getAndResetDaysSinceLastMaintenance() {
+    if (!flags.enableDaysSinceLastMaintenanceTracking()) {
+      return immediateFuture(DEFAULT_DAYS_SINCE_LAST_MAINTENANCE);
+    }
+
+    return FluentFuture.from(loggingStateStore.getAndResetDaysSinceLastMaintenance())
+        .catching(
+            IOException.class,
+            exception -> {
+              LogUtil.d(exception, "Failed to update days since last maintenance");
+              // If we failed to read or update the days since last maintenance, just set the value
+              // to -1.
+              return Optional.of(DEFAULT_DAYS_SINCE_LAST_MAINTENANCE);
+            },
+            directExecutor())
+        .transform(
+            daysSinceLastMaintenanceOptional -> {
+              if (!daysSinceLastMaintenanceOptional.isPresent()) {
+                return DEFAULT_DAYS_SINCE_LAST_MAINTENANCE;
+              }
+              Integer daysSinceLastMaintenance = daysSinceLastMaintenanceOptional.get();
+              if (daysSinceLastMaintenance < 0) {
+                return DEFAULT_DAYS_SINCE_LAST_MAINTENANCE;
+              }
+              // TODO(b/191042900): should we add an upper bound here?
+              return daysSinceLastMaintenance;
+            },
+            directExecutor());
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/SharedFileManager.java b/java/com/google/android/libraries/mobiledatadownload/internal/SharedFileManager.java
new file mode 100644
index 0000000..c0804b7
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/SharedFileManager.java
@@ -0,0 +1,1036 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal;
+
+import static com.google.common.util.concurrent.Futures.immediateVoidFuture;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.net.Uri;
+import android.os.Build.VERSION;
+import android.os.Build.VERSION_CODES;
+import androidx.annotation.VisibleForTesting;
+import com.google.android.libraries.mobiledatadownload.DownloadException;
+import com.google.android.libraries.mobiledatadownload.DownloadException.DownloadResultCode;
+import com.google.android.libraries.mobiledatadownload.FileSource;
+import com.google.android.libraries.mobiledatadownload.Flags;
+import com.google.android.libraries.mobiledatadownload.SilentFeedback;
+import com.google.android.libraries.mobiledatadownload.annotations.InstanceId;
+import com.google.android.libraries.mobiledatadownload.delta.DeltaDecoder;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.common.UnsupportedFileStorageOperation;
+import com.google.android.libraries.mobiledatadownload.internal.Migrations.FileKeyVersion;
+import com.google.android.libraries.mobiledatadownload.internal.annotations.SequentialControlExecutor;
+import com.google.android.libraries.mobiledatadownload.internal.downloader.DeltaFileDownloaderCallbackImpl;
+import com.google.android.libraries.mobiledatadownload.internal.downloader.DownloaderCallbackImpl;
+import com.google.android.libraries.mobiledatadownload.internal.downloader.FileNameUtil;
+import com.google.android.libraries.mobiledatadownload.internal.downloader.FileValidator;
+import com.google.android.libraries.mobiledatadownload.internal.downloader.MddFileDownloader;
+import com.google.android.libraries.mobiledatadownload.internal.downloader.MddFileDownloader.DownloaderCallback;
+import com.google.android.libraries.mobiledatadownload.internal.logging.EventLogger;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil;
+import com.google.android.libraries.mobiledatadownload.internal.util.DirectoryUtil;
+import com.google.android.libraries.mobiledatadownload.internal.util.SharedPreferencesUtil;
+import com.google.android.libraries.mobiledatadownload.monitor.DownloadProgressMonitor;
+import com.google.common.base.Optional;
+import com.google.common.util.concurrent.FluentFuture;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.errorprone.annotations.CheckReturnValue;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFile;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal.AllowedReaders;
+import com.google.mobiledatadownload.internal.MetadataProto.DeltaFile;
+import com.google.mobiledatadownload.internal.MetadataProto.DeltaFile.DiffDecoder;
+import com.google.mobiledatadownload.internal.MetadataProto.DownloadConditions;
+import com.google.mobiledatadownload.internal.MetadataProto.ExtraHttpHeader;
+import com.google.mobiledatadownload.internal.MetadataProto.FileStatus;
+import com.google.mobiledatadownload.internal.MetadataProto.GroupKey;
+import com.google.mobiledatadownload.internal.MetadataProto.NewFileKey;
+import com.google.mobiledatadownload.internal.MetadataProto.SharedFile;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Executor;
+import javax.annotation.Nullable;
+import javax.inject.Inject;
+import org.checkerframework.checker.nullness.compatqual.NullableType;
+
+/**
+ * Manages the life cycle of files used by MDD. For each file group in MDD, the file group will
+ * subscribe for the files that it needs. The SharedFileManager will maintain a reference count to
+ * ensure that it only retains files that are being used by MDD and that multiple file groups will
+ * share a single common file.
+ *
+ * <p>Whenever MDD receives a new filegroup, it will call {@link SharedFileManager#reserveFileEntry}
+ * for each file within the group.
+ *
+ * <p>When MDD discards a file group (because a new one has been received, downloaded), it will call
+ * {@link SharedFileManager#removeFileEntry} for each file within the group.
+ *
+ * <p>Note: SharedFileManager is considered thread-compatible. Calls to methods that modify the
+ * state of SharedFileManager {@link SharedFileManager#reserveFileEntry}, {@link
+ * SharedFileManager#startDownload}, {@link SharedFileManager#getFileStatus}, and {@link
+ * SharedFileManager#removeFileEntry} require exclusive access.
+ */
+@CheckReturnValue
+public class SharedFileManager {
+
+  private static final String TAG = "SharedFileManager";
+
+  public static final String MDD_SHARED_FILE_MANAGER_METADATA =
+      "gms_icing_mdd_shared_file_manager_metadata";
+
+  @VisibleForTesting static final String PREFS_KEY_NEXT_FILE_NAME = "next_file_name_v2";
+  @VisibleForTesting static final String FILE_NAME_PREFIX = "datadownloadfile_";
+
+  @VisibleForTesting
+  static final String PREFS_KEY_MIGRATED_TO_NEW_FILE_KEY = "migrated_to_new_file_key";
+
+  private final Context context;
+  private final SilentFeedback silentFeedback;
+  private final SharedFilesMetadata sharedFilesMetadata;
+  private final MddFileDownloader fileDownloader;
+  private final SynchronousFileStorage fileStorage;
+  private final Optional<DeltaDecoder> deltaDecoderOptional;
+  private final Optional<DownloadProgressMonitor> downloadMonitorOptional;
+  private final EventLogger eventLogger;
+  private final Flags flags;
+  private final FileGroupsMetadata fileGroupsMetadata;
+  private final Optional<String> instanceId;
+  private final Executor sequentialControlExecutor;
+
+  @Inject
+  public SharedFileManager(
+      @ApplicationContext Context context,
+      SilentFeedback silentFeedback,
+      SharedFilesMetadata sharedFilesMetadata,
+      SynchronousFileStorage fileStorage,
+      MddFileDownloader fileDownloader,
+      Optional<DeltaDecoder> deltaDecoderOptional,
+      Optional<DownloadProgressMonitor> downloadMonitorOptional,
+      EventLogger eventLogger,
+      Flags flags,
+      FileGroupsMetadata fileGroupsMetadata,
+      @InstanceId Optional<String> instanceId,
+      @SequentialControlExecutor Executor sequentialControlExecutor) {
+    this.context = context;
+    this.silentFeedback = silentFeedback;
+    this.sharedFilesMetadata = sharedFilesMetadata;
+    this.fileStorage = fileStorage;
+    this.fileDownloader = fileDownloader;
+    this.deltaDecoderOptional = deltaDecoderOptional;
+    this.downloadMonitorOptional = downloadMonitorOptional;
+    this.eventLogger = eventLogger;
+    this.flags = flags;
+    this.fileGroupsMetadata = fileGroupsMetadata;
+    this.instanceId = instanceId;
+    this.sequentialControlExecutor = sequentialControlExecutor;
+  }
+
+  /**
+   * Makes any changes that should be made before accessing the internal state of this class.
+   *
+   * <p>Other methods in this class do not call or check if this method was already called before
+   * trying to access internal state. It is expected from the caller to call this before anything
+   * else.
+   *
+   * @return false if init failed, signalling caller to clear internal storage.
+   */
+  // TODO(b/124072754): Change to package private once all code is refactored.
+  public ListenableFuture<Boolean> init() {
+    SharedPreferences sharedFileManagerMetadata =
+        SharedPreferencesUtil.getSharedPreferences(
+            context, MDD_SHARED_FILE_MANAGER_METADATA, instanceId);
+
+    // Migrations class was added in v24, whereas new file key migration done in v23. If we already
+    // migrated, check and set it in Migrations.
+    if (sharedFileManagerMetadata.contains(PREFS_KEY_MIGRATED_TO_NEW_FILE_KEY)) {
+      if (sharedFileManagerMetadata.getBoolean(PREFS_KEY_MIGRATED_TO_NEW_FILE_KEY, false)) {
+        Migrations.setMigratedToNewFileKey(context, true);
+      }
+      sharedFileManagerMetadata.edit().remove(PREFS_KEY_MIGRATED_TO_NEW_FILE_KEY).commit();
+    }
+
+    return Futures.immediateFuture(true);
+  }
+
+  /**
+   * Adds a subscribed file entry if there is no existing entry for newFileKey. Does nothing if such
+   * an entry already exists.
+   *
+   * @param newFileKey - the file key for the enry that you wish to reserve.
+   * @return - Future resolving to false if unable to commit the reservation
+   */
+  // TODO - refactor to throw Exception when write to SharedPreferences fails
+  public ListenableFuture<Boolean> reserveFileEntry(NewFileKey newFileKey) {
+    return Futures.transformAsync(
+        sharedFilesMetadata.read(newFileKey),
+        sharedFile -> {
+          if (sharedFile != null) {
+            // There's already an entry for this file. Nothing to do here.
+            return Futures.immediateFuture(true);
+          }
+          // Set the file name and update the metadata file.
+          SharedPreferences sharedFileManagerMetadata =
+              SharedPreferencesUtil.getSharedPreferences(
+                  context, MDD_SHARED_FILE_MANAGER_METADATA, instanceId);
+          long nextFileName =
+              sharedFileManagerMetadata.getLong(
+                  PREFS_KEY_NEXT_FILE_NAME, System.currentTimeMillis());
+          if (!sharedFileManagerMetadata
+              .edit()
+              .putLong(PREFS_KEY_NEXT_FILE_NAME, nextFileName + 1)
+              .commit()) {
+            // TODO(b/131166925): MDD dump should not use lite proto toString.
+            LogUtil.e("%s: Unable to update file name %s", TAG, newFileKey);
+            return Futures.immediateFuture(false);
+          }
+
+          String fileName = FILE_NAME_PREFIX + nextFileName;
+          sharedFile =
+              SharedFile.newBuilder()
+                  .setFileStatus(FileStatus.SUBSCRIBED)
+                  .setFileName(fileName)
+                  .build();
+          return Futures.transformAsync(
+              sharedFilesMetadata.write(newFileKey, sharedFile),
+              writeSuccess -> {
+                if (!writeSuccess) {
+                  // TODO(b/131166925): MDD dump should not use lite proto toString.
+                  LogUtil.e(
+                      "%s: Unable to write back subscription for file entry with %s",
+                      TAG, newFileKey);
+                  return Futures.immediateFuture(false);
+                }
+                return Futures.immediateFuture(true);
+              },
+              sequentialControlExecutor);
+        },
+        sequentialControlExecutor);
+  }
+
+  /**
+   * Start importing a given file source if the file has not yet been downloaded/imported.
+   *
+   * <p>This method expects {@code dataFile} to have an "inlinefile:" scheme url. A
+   * DownloadException will be returned if a non-inlinefile scheme url is given.
+   *
+   * <p>If the file has already been downloaded/imported, this method is a no-op.
+   */
+  ListenableFuture<Void> startImport(
+      GroupKey groupKey,
+      DataFile dataFile,
+      NewFileKey newFileKey,
+      @Nullable DownloadConditions downloadConditions,
+      FileSource inlineFileSource) {
+    if (!dataFile.getUrlToDownload().startsWith(MddConstants.INLINE_FILE_URL_SCHEME)) {
+      return Futures.immediateFailedFuture(
+          DownloadException.builder()
+              .setDownloadResultCode(DownloadResultCode.INVALID_INLINE_FILE_URL_SCHEME)
+              .setMessage("Importing an inline file requires inlinefile scheme")
+              .build());
+    }
+    return Futures.transformAsync(
+        sharedFilesMetadata.read(newFileKey),
+        sharedFile -> {
+          if (sharedFile == null) {
+            LogUtil.e(
+                "%s: Start import called on file that doesn't exist. Id = %s",
+                TAG, dataFile.getFileId());
+            SharedFileMissingException cause = new SharedFileMissingException();
+            // TODO(b/167582815): Log to Clearcut
+            return Futures.immediateFailedFuture(
+                DownloadException.builder()
+                    .setDownloadResultCode(DownloadResultCode.SHARED_FILE_NOT_FOUND_ERROR)
+                    .setCause(cause)
+                    .build());
+          }
+
+          // If we have already downloaded the file, then return.
+          if (sharedFile.getFileStatus() == FileStatus.DOWNLOAD_COMPLETE) {
+            return immediateVoidFuture();
+          }
+
+          // Delta files are not supported, so only check for download transforms
+          SharedFile.Builder sharedFileBuilder = sharedFile.toBuilder();
+          String downloadFileName =
+              dataFile.hasDownloadTransforms()
+                  ? FileNameUtil.getTempFileNameWithDownloadedFileChecksum(
+                      sharedFile.getFileName(), dataFile.getDownloadedFileChecksum())
+                  : sharedFile.getFileName();
+
+          return Futures.transformAsync(
+              getDataFileGroupOrDefault(groupKey),
+              dataFileGroup ->
+                  getImportFuture(
+                      sharedFileBuilder,
+                      newFileKey,
+                      downloadFileName,
+                      dataFileGroup.getFileGroupVersionNumber(),
+                      dataFileGroup.getBuildId(),
+                      dataFileGroup.getVariantId(),
+                      groupKey,
+                      dataFile,
+                      downloadConditions,
+                      inlineFileSource),
+              sequentialControlExecutor);
+        },
+        sequentialControlExecutor);
+  }
+
+  /**
+   * Gets a future that will perform the import.
+   *
+   * <p>Updates the sharedFile status to in-progress and attaches a callback to the import to handle
+   * post import actions.
+   */
+  private ListenableFuture<Void> getImportFuture(
+      SharedFile.Builder sharedFileBuilder,
+      NewFileKey newFileKey,
+      String downloadFileName,
+      int fileGroupVersionNumber,
+      long buildId,
+      String variantId,
+      GroupKey groupKey,
+      DataFile dataFile,
+      @Nullable DownloadConditions downloadConditions,
+      FileSource inlineFileSource) {
+    ListenableFuture<Uri> downloadFileOnDeviceUriFuture =
+        getDownloadFileOnDeviceUri(
+            newFileKey.getAllowedReaders(), downloadFileName, dataFile.getChecksum());
+    return FluentFuture.from(downloadFileOnDeviceUriFuture)
+        .transformAsync(
+            unused -> {
+              sharedFileBuilder.setFileStatus(FileStatus.DOWNLOAD_IN_PROGRESS);
+
+              // Write returns a boolean indicating if the operation was successful or not. We can
+              // ignore this because a failure to write here won't impact the import operation. We
+              // will attempt to write the final state (completed or failed) after the import
+              // operation.
+              return sharedFilesMetadata.write(newFileKey, sharedFileBuilder.build());
+            },
+            sequentialControlExecutor)
+        .transformAsync(
+            unused -> {
+              Uri downloadFileOnDeviceUri = Futures.getDone(downloadFileOnDeviceUriFuture);
+              DownloaderCallback downloaderCallback =
+                  new DownloaderCallbackImpl(
+                      sharedFilesMetadata,
+                      fileStorage,
+                      dataFile,
+                      newFileKey.getAllowedReaders(),
+                      eventLogger,
+                      groupKey,
+                      fileGroupVersionNumber,
+                      buildId,
+                      variantId,
+                      flags,
+                      sequentialControlExecutor);
+              // TODO: when partial import files are supported, notify monitor of partial
+              // progress here.
+
+              return fileDownloader.startCopying(
+                  downloadFileOnDeviceUri,
+                  dataFile.getUrlToDownload(),
+                  dataFile.getByteSize(),
+                  downloadConditions,
+                  downloaderCallback,
+                  inlineFileSource);
+            },
+            sequentialControlExecutor);
+  }
+
+  /**
+   * Start downloading the file if the file has not yet been downloaded. If the file has been
+   * downloaded, this method is a no-op.
+   *
+   * @param groupKey - a Key that uniquely identify a file group.
+   * @param dataFile - the data file proto provided by client
+   * @param newFileKey - the file key to get the SharedFile.
+   * @param downloadConditions - conditions under which this file should be downloaded.
+   * @param trafficTag - Tag for the network traffic to download this dataFile.
+   * @param extraHttpHeaders - Extra Headers for this request.
+   * @return - ListenableFuture representing the download the file. The ListenableFuture fails with
+   *     {@link DownloadException} if the download is unsuccessful.
+   */
+  ListenableFuture<Void> startDownload(
+      GroupKey groupKey,
+      DataFile dataFile,
+      NewFileKey newFileKey,
+      @Nullable DownloadConditions downloadConditions,
+      int trafficTag,
+      List<ExtraHttpHeader> extraHttpHeaders) {
+    if (dataFile.getUrlToDownload().startsWith(MddConstants.INLINE_FILE_URL_SCHEME)) {
+      return Futures.immediateFailedFuture(
+          DownloadException.builder()
+              .setDownloadResultCode(DownloadResultCode.INVALID_INLINE_FILE_URL_SCHEME)
+              .setMessage(
+                  "downloading a file with an inlinefile scheme is not supported, use importFiles"
+                      + " instead.")
+              .build());
+    }
+    return Futures.transformAsync(
+        sharedFilesMetadata.read(newFileKey),
+        sharedFile -> {
+          if (sharedFile == null) {
+            // TODO(b/131166925): MDD dump should not use lite proto toString.
+            LogUtil.e(
+                "%s: Start download called on file that doesn't exists. Key = %s!",
+                TAG, newFileKey);
+            SharedFileMissingException cause = new SharedFileMissingException();
+            silentFeedback.send(cause, "Shared file not found in downloadFileGroup");
+            return Futures.immediateFailedFuture(
+                DownloadException.builder()
+                    .setDownloadResultCode(DownloadResultCode.SHARED_FILE_NOT_FOUND_ERROR)
+                    .setCause(cause)
+                    .build());
+          }
+
+          // If we have already downloaded the file, then return.
+          if (sharedFile.getFileStatus() == FileStatus.DOWNLOAD_COMPLETE) {
+            if (downloadMonitorOptional.isPresent()) {
+              // For the downloaded file, we don't need to monitor the file change. We just need to
+              // inform the monitor about its current size.
+              downloadMonitorOptional
+                  .get()
+                  .notifyCurrentFileSize(groupKey.getGroupName(), dataFile.getByteSize());
+            }
+            return immediateVoidFuture();
+          }
+
+          return Futures.transformAsync(
+              findFirstDeltaFileWithBaseFileDownloaded(dataFile, newFileKey.getAllowedReaders()),
+              deltaFile -> {
+                SharedFile.Builder sharedFileBuilder = sharedFile.toBuilder();
+                String downloadFileName = sharedFile.getFileName();
+                if (deltaFile != null) {
+                  downloadFileName =
+                      FileNameUtil.getTempFileNameWithDownloadedFileChecksum(
+                          downloadFileName, deltaFile.getChecksum());
+                } else if (dataFile.hasDownloadTransforms()) {
+                  downloadFileName =
+                      FileNameUtil.getTempFileNameWithDownloadedFileChecksum(
+                          downloadFileName, dataFile.getDownloadedFileChecksum());
+                }
+
+                // Variables captured in lambdas must be effectively final.
+                String downloadFileNameCapture = downloadFileName;
+                return Futures.transformAsync(
+                    getDataFileGroupOrDefault(groupKey),
+                    dataFileGroup ->
+                        getDownloadFuture(
+                            sharedFileBuilder,
+                            newFileKey,
+                            downloadFileNameCapture,
+                            dataFileGroup.getFileGroupVersionNumber(),
+                            dataFileGroup.getBuildId(),
+                            dataFileGroup.getVariantId(),
+                            groupKey,
+                            dataFile,
+                            deltaFile,
+                            downloadConditions,
+                            trafficTag,
+                            extraHttpHeaders),
+                    sequentialControlExecutor);
+              },
+              sequentialControlExecutor);
+        },
+        sequentialControlExecutor);
+  }
+
+  private ListenableFuture<Void> getDownloadFuture(
+      SharedFile.Builder sharedFileBuilder,
+      NewFileKey newFileKey,
+      String downloadFileName,
+      int fileGroupVersionNumber,
+      long buildId,
+      String variantId,
+      GroupKey groupKey,
+      DataFile dataFile,
+      @Nullable DeltaFile deltaFile,
+      @Nullable DownloadConditions downloadConditions,
+      int trafficTag,
+      List<ExtraHttpHeader> extraHttpHeaders) {
+    ListenableFuture<Uri> downloadFileOnDeviceUriFuture =
+        getDownloadFileOnDeviceUri(
+            newFileKey.getAllowedReaders(), downloadFileName, dataFile.getChecksum());
+    return FluentFuture.from(downloadFileOnDeviceUriFuture)
+        .transformAsync(
+            unused -> {
+              sharedFileBuilder.setFileStatus(FileStatus.DOWNLOAD_IN_PROGRESS);
+
+              // Ignoring failure to write back here, as it will just result in one extra try to
+              // download the file.
+              return sharedFilesMetadata.write(newFileKey, sharedFileBuilder.build());
+            },
+            sequentialControlExecutor)
+        .transformAsync(
+            unused -> {
+              Uri downloadFileOnDeviceUri = Futures.getDone(downloadFileOnDeviceUriFuture);
+              ListenableFuture<Void> fileDownloadFuture;
+              if (!deltaDecoderOptional.isPresent() || deltaFile == null) {
+                // Download full file when delta file is null
+                DownloaderCallback downloaderCallback =
+                    new DownloaderCallbackImpl(
+                        sharedFilesMetadata,
+                        fileStorage,
+                        dataFile,
+                        newFileKey.getAllowedReaders(),
+                        eventLogger,
+                        groupKey,
+                        fileGroupVersionNumber,
+                        buildId,
+                        variantId,
+                        flags,
+                        sequentialControlExecutor);
+
+                mayNotifyCurrentSizeOfPartiallyDownloadedFile(groupKey, downloadFileOnDeviceUri);
+
+                fileDownloadFuture =
+                    fileDownloader.startDownloading(
+                        groupKey,
+                        fileGroupVersionNumber,
+                        buildId,
+                        downloadFileOnDeviceUri,
+                        dataFile.getUrlToDownload(),
+                        dataFile.getByteSize(),
+                        downloadConditions,
+                        downloaderCallback,
+                        trafficTag,
+                        extraHttpHeaders);
+              } else {
+                DownloaderCallback downloaderCallback =
+                    new DeltaFileDownloaderCallbackImpl(
+                        context,
+                        sharedFilesMetadata,
+                        fileStorage,
+                        silentFeedback,
+                        dataFile,
+                        newFileKey.getAllowedReaders(),
+                        deltaDecoderOptional.get(),
+                        deltaFile,
+                        eventLogger,
+                        groupKey,
+                        fileGroupVersionNumber,
+                        buildId,
+                        variantId,
+                        instanceId,
+                        flags,
+                        sequentialControlExecutor);
+
+                mayNotifyCurrentSizeOfPartiallyDownloadedFile(groupKey, downloadFileOnDeviceUri);
+
+                fileDownloadFuture =
+                    fileDownloader.startDownloading(
+                        groupKey,
+                        fileGroupVersionNumber,
+                        buildId,
+                        downloadFileOnDeviceUri,
+                        deltaFile.getUrlToDownload(),
+                        deltaFile.getByteSize(),
+                        downloadConditions,
+                        downloaderCallback,
+                        trafficTag,
+                        extraHttpHeaders);
+              }
+              return fileDownloadFuture;
+            },
+            sequentialControlExecutor);
+  }
+
+  /**
+   * Gets the URI where the given file should be located on-device.
+   *
+   * @param allowedReaders the allowed readers of the file
+   * @param downloadFileName the name of the file
+   * @param checksum the checksum of the file
+   */
+  private ListenableFuture<Uri> getDownloadFileOnDeviceUri(
+      AllowedReaders allowedReaders, String downloadFileName, String checksum) {
+    Uri downloadFileOnDeviceUri =
+        DirectoryUtil.getOnDeviceUri(
+            context,
+            allowedReaders,
+            downloadFileName,
+            checksum,
+            silentFeedback,
+            instanceId,
+            /* androidShared = */ false);
+    if (downloadFileOnDeviceUri == null) {
+      LogUtil.e("%s: Failed to get file uri!", TAG);
+      return Futures.immediateFailedFuture(
+          DownloadException.builder()
+              .setDownloadResultCode(DownloadResultCode.UNABLE_TO_CREATE_FILE_URI_ERROR)
+              .build());
+    }
+    return Futures.immediateFuture(downloadFileOnDeviceUri);
+  }
+
+  private void mayNotifyCurrentSizeOfPartiallyDownloadedFile(
+      GroupKey groupKey, Uri downloadFileOnDeviceUri) {
+    if (downloadMonitorOptional.isPresent()) {
+      // Inform the monitor about the current size of the partially downloaded file.
+      try {
+        long currentFileSize = fileStorage.fileSize(downloadFileOnDeviceUri);
+        if (currentFileSize > 0) {
+          downloadMonitorOptional
+              .get()
+              .notifyCurrentFileSize(groupKey.getGroupName(), currentFileSize);
+        }
+      } catch (IOException e) {
+        // Ignore any fileSize error.
+      }
+    }
+  }
+
+  private ListenableFuture<DataFileGroupInternal> getDataFileGroupOrDefault(GroupKey groupKey) {
+    return Futures.transformAsync(
+        fileGroupsMetadata.read(groupKey),
+        fileGroup ->
+            Futures.immediateFuture(
+                (fileGroup == null) ? DataFileGroupInternal.getDefaultInstance() : fileGroup),
+        sequentialControlExecutor);
+  }
+
+  /**
+   * @param dataFile - a DataFile proto object
+   * @param allowedReaders - allowed readers of the file group, assuming the base file has the same
+   *     readers set
+   * @return - the first Delta file which its base file is on device and its file status is download
+   *     completed
+   */
+  @VisibleForTesting
+  ListenableFuture<@NullableType DeltaFile> findFirstDeltaFileWithBaseFileDownloaded(
+      DataFile dataFile, AllowedReaders allowedReaders) {
+    if (Migrations.getCurrentVersion(context, silentFeedback).value
+            < FileKeyVersion.USE_CHECKSUM_ONLY.value
+        || !deltaDecoderOptional.isPresent()
+        || deltaDecoderOptional.get().getDecoderName() == DiffDecoder.UNSPECIFIED) {
+      return Futures.immediateFuture(null);
+    }
+    return findFirstDeltaFileWithBaseFileDownloaded(
+        dataFile.getDeltaFileList(), /* index = */ 0, allowedReaders);
+  }
+
+  // We must use recursion here since the decision to continue iterating is dependent on the result
+  // of the asynchronous SharedFilesMetadata.read() operation
+  private ListenableFuture<@NullableType DeltaFile> findFirstDeltaFileWithBaseFileDownloaded(
+      List<DeltaFile> deltaFiles, int index, AllowedReaders allowedReaders) {
+    if (index == deltaFiles.size()) {
+      return Futures.immediateFuture(null);
+    }
+    DeltaFile deltaFile = deltaFiles.get(index);
+    if (deltaFile.getDiffDecoder() != deltaDecoderOptional.get().getDecoderName()) {
+      return findFirstDeltaFileWithBaseFileDownloaded(deltaFiles, index + 1, allowedReaders);
+    }
+    NewFileKey baseFileKey =
+        NewFileKey.newBuilder()
+            .setChecksum(deltaFile.getBaseFile().getChecksum())
+            .setAllowedReaders(allowedReaders)
+            .build();
+    return Futures.transformAsync(
+        sharedFilesMetadata.read(baseFileKey),
+        baseFileMetadata -> {
+          if (baseFileMetadata != null
+              && baseFileMetadata.getFileStatus() == FileStatus.DOWNLOAD_COMPLETE) {
+            Uri baseFileUri =
+                DirectoryUtil.getOnDeviceUri(
+                    context,
+                    baseFileKey.getAllowedReaders(),
+                    baseFileMetadata.getFileName(),
+                    baseFileKey.getChecksum(),
+                    silentFeedback,
+                    instanceId,
+                    /* androidShared = */ false);
+            if (baseFileUri != null) {
+              return Futures.immediateFuture(deltaFile);
+            }
+          }
+          return findFirstDeltaFileWithBaseFileDownloaded(deltaFiles, index + 1, allowedReaders);
+        },
+        sequentialControlExecutor);
+  }
+  /**
+   * Returns the current status of the file.
+   *
+   * @param newFileKey - the file key to get the SharedFile.
+   * @return - FileStatus representing the current state of the file. The ListenableFuture may throw
+   *     a SharedFileMissingException if the shared file metadata is missing.
+   */
+  ListenableFuture<FileStatus> getFileStatus(NewFileKey newFileKey) {
+    return Futures.transformAsync(
+        getSharedFile(newFileKey),
+        existingSharedFile -> Futures.immediateFuture(existingSharedFile.getFileStatus()),
+        sequentialControlExecutor);
+  }
+
+  /**
+   * Verifies that the file exists in metadata and on disk. Also performs the same validation check
+   * that's performed after download to ensure the file hasn't been deleted or corrupted.
+   *
+   * @param newFileKey - the file key to get the SharedFile.
+   * @return - The ListenableFuture may throw a SharedFileMissingException if the shared file
+   *     metadata is missing or the on disk file is corrupted.
+   */
+  ListenableFuture<Void> reVerifyFile(NewFileKey newFileKey, DataFile dataFile) {
+    return FluentFuture.from(getSharedFile(newFileKey))
+        .transformAsync(
+            existingSharedFile -> {
+              if (existingSharedFile.getFileStatus() != FileStatus.DOWNLOAD_COMPLETE) {
+                return Futures.immediateVoidFuture();
+              }
+              // Double check that it's really complete, and update status if it's not.
+              return FluentFuture.from(getOnDeviceUri(newFileKey))
+                  .transformAsync(
+                      uri -> {
+                        if (uri == null) {
+                          throw DownloadException.builder()
+                              .setDownloadResultCode(
+                                  DownloadResultCode.DOWNLOADED_FILE_NOT_FOUND_ERROR)
+                              .build();
+                        }
+                        if (existingSharedFile.getAndroidShared()) {
+                          // Just check for presence. BlobStoreManager is responsible for
+                          // integrity.
+                          if (!fileStorage.exists(uri)) {
+                            throw DownloadException.builder()
+                                .setDownloadResultCode(
+                                    DownloadResultCode.DOWNLOADED_FILE_NOT_FOUND_ERROR)
+                                .build();
+                          }
+                        } else {
+                          FileValidator.validateDownloadedFile(
+                              fileStorage, dataFile, uri, dataFile.getChecksum());
+                        }
+                        return Futures.immediateVoidFuture();
+                      },
+                      sequentialControlExecutor)
+                  .catchingAsync(
+                      DownloadException.class,
+                      e -> {
+                        LogUtil.e(
+                            "%s: reVerifyFile lost or corrupted code %s",
+                            TAG, e.getDownloadResultCode());
+                        SharedFile updatedSharedFile =
+                            existingSharedFile.toBuilder()
+                                .setFileStatus(FileStatus.CORRUPTED)
+                                .build();
+                        return FluentFuture.from(
+                                sharedFilesMetadata.write(newFileKey, updatedSharedFile))
+                            .transformAsync(
+                                ok -> {
+                                  SharedFileMissingException ex = new SharedFileMissingException();
+                                  if (!ok) {
+                                    throw new IOException("failed to save sharedFilesMetadata", ex);
+                                  }
+                                  throw ex;
+                                },
+                                sequentialControlExecutor);
+                      },
+                      sequentialControlExecutor);
+            },
+            sequentialControlExecutor);
+  }
+
+  /**
+   * Returns the {@code SharedFile}.
+   *
+   * @param newFileKey - the file key to get the SharedFile.
+   * @return - the SharedFile representing the current metadata of the file. The ListenableFuture
+   *     may throw a SharedFileMissingException if the shared file metadata is missing.
+   */
+  ListenableFuture<SharedFile> getSharedFile(NewFileKey newFileKey) {
+    return Futures.transformAsync(
+        sharedFilesMetadata.read(newFileKey),
+        existingSharedFile -> {
+          if (existingSharedFile == null) {
+            // TODO(b/131166925): MDD dump should not use lite proto toString.
+            LogUtil.e(
+                "%s: getSharedFile called on file that doesn't exists! Key = %s", TAG, newFileKey);
+            return Futures.immediateFailedFuture(new SharedFileMissingException());
+          }
+          return Futures.immediateFuture(existingSharedFile);
+        },
+        sequentialControlExecutor);
+  }
+
+  /**
+   * Sets a file entry as downloaded and android-shared. If there is an existing entry for {@code
+   * newFileKey}, overwrites it.
+   *
+   * @param newFileKey - the file key for the enry that you wish to store.
+   * @param androidSharingChecksum - the file checksum that represents a blob in the Android Sharing
+   *     Service.
+   * @param maxExpirationDateSecs - the new maximum expiration date
+   * @return - false if unable to commit the write operation
+   */
+  ListenableFuture<Boolean> setAndroidSharedDownloadedFileEntry(
+      NewFileKey newFileKey, String androidSharingChecksum, long maxExpirationDateSecs) {
+    SharedFile newSharedFile =
+        SharedFile.newBuilder()
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .setFileName("android_shared_" + androidSharingChecksum)
+            .setAndroidShared(true)
+            .setMaxExpirationDateSecs(maxExpirationDateSecs)
+            .setAndroidSharingChecksum(androidSharingChecksum)
+            .build();
+    return sharedFilesMetadata.write(newFileKey, newSharedFile);
+  }
+
+  /**
+   * If necessary, updates the {@code max_expiration_date} date stored in the shared file metadata
+   * associated to {@code newFileKey}. No-op otherwise.
+   *
+   * @param newFileKey - the file key for the enry that you wish to update.
+   * @param fileExpirationDateSecs - the expiration date of the current file.
+   * @return - false if unable to commit the write operation. The ListenableFuture may throw a
+   *     SharedFileMissingException if the shared file metadata is missing.
+   */
+  ListenableFuture<Boolean> updateMaxExpirationDateSecs(
+      NewFileKey newFileKey, long fileExpirationDateSecs) {
+    return Futures.transformAsync(
+        getSharedFile(newFileKey),
+        existingSharedFile -> {
+          if (fileExpirationDateSecs > existingSharedFile.getMaxExpirationDateSecs()) {
+            SharedFile updatedSharedFile =
+                existingSharedFile.toBuilder()
+                    .setMaxExpirationDateSecs(fileExpirationDateSecs)
+                    .build();
+            return sharedFilesMetadata.write(newFileKey, updatedSharedFile);
+          }
+          return Futures.immediateFuture(true);
+        },
+        sequentialControlExecutor);
+  }
+
+  /**
+   * Returns future resolving to uri for the file.
+   *
+   * @param newFileKey - the file key to get the SharedFile.
+   * @return - a future resolving to the MobStore android Uri that is associated with the file. The
+   *     uri will be null if the SharedFileManager doesn't have an entry matching that file or there
+   *     is an error populating the uri of the file.
+   */
+  public ListenableFuture<@NullableType Uri> getOnDeviceUri(NewFileKey newFileKey) {
+    return Futures.transformAsync(
+        sharedFilesMetadata.read(newFileKey),
+        sharedFile -> {
+          if (sharedFile == null) {
+            // TODO(b/131166925): MDD dump should not use lite proto toString.
+            LogUtil.e(
+                "%s: getOnDeviceUri called on file that doesn't exists. Key = %s!",
+                TAG, newFileKey);
+            return Futures.immediateFailedFuture(new SharedFileMissingException());
+          }
+
+          Uri onDeviceUri =
+              DirectoryUtil.getOnDeviceUri(
+                  context,
+                  newFileKey.getAllowedReaders(),
+                  sharedFile.getFileName(),
+                  sharedFile.getAndroidSharingChecksum(),
+                  silentFeedback,
+                  instanceId,
+                  sharedFile.getAndroidShared());
+          return Futures.immediateFuture(onDeviceUri);
+        },
+        sequentialControlExecutor);
+  }
+
+  /**
+   * Removes the file entry corresponding to newFileKey. If the file hasn't been fully downloaded,
+   * the partial file is deleted from the device and the download is cancelled.
+   *
+   * @param newFileKey - the key of the file entry to remove.
+   * @return - false if there is no entry with this key or unable to remove the entry
+   */
+  // TODO - refactor to throw Exception when write to SharedPreferences fails
+  ListenableFuture<Boolean> removeFileEntry(NewFileKey newFileKey) {
+    return Futures.transformAsync(
+        sharedFilesMetadata.read(newFileKey),
+        sharedFile -> {
+          if (sharedFile == null) {
+            // TODO(b/131166925): MDD dump should not use lite proto toString.
+            LogUtil.e("%s: No file entry with key %s", TAG, newFileKey);
+            return Futures.immediateFuture(false);
+          }
+
+          Uri onDeviceUri =
+              DirectoryUtil.getOnDeviceUri(
+                  context,
+                  newFileKey.getAllowedReaders(),
+                  sharedFile.getFileName(),
+                  newFileKey.getChecksum(),
+                  silentFeedback,
+                  instanceId,
+                  /* androidShared = */ false);
+          if (onDeviceUri != null) {
+            fileDownloader.stopDownloading(onDeviceUri);
+          }
+          return Futures.transformAsync(
+              sharedFilesMetadata.remove(newFileKey),
+              removeSuccess -> {
+                if (!removeSuccess) {
+                  // TODO(b/131166925): MDD dump should not use lite proto toString.
+                  LogUtil.e("%s: Unable to modify file subscription for key %s", TAG, newFileKey);
+                  return Futures.immediateFuture(false);
+                }
+                return Futures.immediateFuture(true);
+              },
+              sequentialControlExecutor);
+        },
+        sequentialControlExecutor);
+  }
+
+  /**
+   * Clears all storage used by the SharedFileManager and deletes all files that have been
+   * downloaded to MDD's directory.
+   */
+  // TODO(b/124072754): Change to package private once all code is refactored.
+  public ListenableFuture<Void> clear() {
+    // If sdk is R+, try release all leases that the MDD Client may have acquired. This
+    // prevents from leaving zombie files in the blob storage.
+    if (VERSION.SDK_INT >= VERSION_CODES.R) {
+      releaseAllAndroidSharedFiles();
+    }
+    try {
+      fileStorage.deleteRecursively(DirectoryUtil.getBaseDownloadDirectory(context, instanceId));
+    } catch (IOException e) {
+      silentFeedback.send(e, "Failure while deleting mdd storage during clear");
+    }
+    return Futures.immediateVoidFuture();
+  }
+
+  private void releaseAllAndroidSharedFiles() {
+    try {
+      Uri allLeasesUri = DirectoryUtil.getBlobStoreAllLeasesUri(context);
+      fileStorage.deleteFile(allLeasesUri);
+      eventLogger.logEventSampled(0);
+    } catch (UnsupportedFileStorageOperation e) {
+      LogUtil.v(
+          "%s: Failed to release the leases in the android shared storage."
+              + " UnsupportedFileStorageOperation was thrown",
+          TAG);
+    } catch (IOException e) {
+      LogUtil.e(e, "%s: Failed to release the leases in the android shared storage", TAG);
+      eventLogger.logEventSampled(0);
+    }
+  }
+
+  public ListenableFuture<Void> cancelDownloadAndClear() {
+    return Futures.transformAsync(
+        sharedFilesMetadata.getAllFileKeys(),
+        newFileKeyList -> {
+          List<ListenableFuture<Void>> cancelDownloadFutures = new ArrayList<>();
+          try {
+            // Clear is called in case something fails and we want to clear all of MDD internal
+            // storage. Catching any exception in cancelling downloads is better than clear failing,
+            // as it can leave the system in a non-recoverable state.
+            for (NewFileKey newFileKey : newFileKeyList) {
+              cancelDownloadFutures.add(cancelDownload(newFileKey));
+            }
+          } catch (Exception e) {
+            silentFeedback.send(e, "Failed to cancel all downloads during clear");
+          }
+          return Futures.whenAllComplete(cancelDownloadFutures)
+              .callAsync(this::clear, sequentialControlExecutor);
+        },
+        sequentialControlExecutor);
+  }
+
+  public ListenableFuture<Void> cancelDownload(NewFileKey newFileKey) {
+    return Futures.transformAsync(
+        sharedFilesMetadata.read(newFileKey),
+        sharedFile -> {
+          if (sharedFile == null) {
+            LogUtil.e("%s: Unable to read sharedFile from shared preferences.", TAG);
+            return Futures.immediateFailedFuture(new SharedFileMissingException());
+          }
+          if (sharedFile.getFileStatus() != FileStatus.DOWNLOAD_COMPLETE) {
+            Uri onDeviceUri =
+                DirectoryUtil.getOnDeviceUri(
+                    context,
+                    newFileKey.getAllowedReaders(),
+                    sharedFile.getFileName(),
+                    newFileKey.getChecksum(),
+                    silentFeedback,
+                    instanceId,
+                    /* androidShared = */ false); // while downloading androidShared is always false
+            if (onDeviceUri != null) {
+              fileDownloader.stopDownloading(onDeviceUri);
+            }
+          }
+          return immediateVoidFuture();
+        },
+        sequentialControlExecutor);
+  }
+
+  /** Dumps the current internal state of the SharedFileManager. */
+  public ListenableFuture<Void> dump(final PrintWriter writer) {
+    writer.println("==== MDD_SHARED_FILES ====");
+    return Futures.transformAsync(
+        sharedFilesMetadata.getAllFileKeys(),
+        allFileKeys -> {
+          ListenableFuture<Void> writeFilesFuture = immediateVoidFuture();
+          for (NewFileKey newFileKey : allFileKeys) {
+            writeFilesFuture =
+                Futures.transformAsync(
+                    writeFilesFuture,
+                    voidArg ->
+                        Futures.transformAsync(
+                            sharedFilesMetadata.read(newFileKey),
+                            sharedFile -> {
+                              if (sharedFile == null) {
+                                LogUtil.e(
+                                    "%s: Unable to read sharedFile from shared preferences.", TAG);
+                                return Futures.immediateVoidFuture();
+                              }
+                              // TODO(b/131166925): MDD dump should not use lite proto toString.
+                              writer.format(
+                                  "FileKey: %s\nFileName: %s\nSharedFile: %s\n",
+                                  newFileKey, sharedFile.getFileName(), sharedFile.toString());
+                              if (sharedFile.getAndroidShared()) {
+                                writer.format(
+                                    "Checksum Android-shared file: %s\n",
+                                    sharedFile.getAndroidSharingChecksum());
+                              } else {
+                                Uri serializedUri =
+                                    DirectoryUtil.getOnDeviceUri(
+                                        context,
+                                        newFileKey.getAllowedReaders(),
+                                        sharedFile.getFileName(),
+                                        newFileKey.getChecksum(),
+                                        silentFeedback,
+                                        instanceId,
+                                        /* androidShared = */ false);
+                                if (serializedUri != null) {
+                                  writer.format(
+                                      "Checksum downloaded file: %s\n",
+                                      FileValidator.computeSha1Digest(fileStorage, serializedUri));
+                                }
+                              }
+                              return Futures.immediateVoidFuture();
+                            },
+                            sequentialControlExecutor),
+                    sequentialControlExecutor);
+          }
+          return writeFilesFuture;
+        },
+        sequentialControlExecutor);
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/SharedFileMissingException.java b/java/com/google/android/libraries/mobiledatadownload/internal/SharedFileMissingException.java
new file mode 100644
index 0000000..ea4bb7c
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/SharedFileMissingException.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal;
+
+/** Thrown when a file is read from sharedfiles that is not present. */
+public class SharedFileMissingException extends Exception {}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/SharedFilesMetadata.java b/java/com/google/android/libraries/mobiledatadownload/internal/SharedFilesMetadata.java
new file mode 100644
index 0000000..c5e6019
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/SharedFilesMetadata.java
@@ -0,0 +1,145 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal;
+
+import android.content.Context;
+import com.google.android.libraries.mobiledatadownload.SilentFeedback;
+import com.google.android.libraries.mobiledatadownload.internal.util.FileGroupUtil;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFile;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal.AllowedReaders;
+import com.google.mobiledatadownload.internal.MetadataProto.DeltaFile;
+import com.google.mobiledatadownload.internal.MetadataProto.NewFileKey;
+import com.google.mobiledatadownload.internal.MetadataProto.SharedFile;
+import java.util.List;
+
+/** Stores and provides access to shared file metadata. */
+public interface SharedFilesMetadata {
+
+  /**
+   * Creates a NewFileKey object from the given DataFile, based on the current FileKeyVersion.
+   *
+   * @param file - a DataFile whose key you wish to construct.
+   * @param allowedReadersEnum - {@link AllowedReaders} signifies who has access to the file.
+   */
+  // TODO(b/127490978): Replace all usage of {@code #createKeyFromDataFile} once all users have
+  // been migrated to use only the non-deprecated fields from the returned value.
+  public static NewFileKey createKeyFromDataFileForCurrentVersion(
+      Context context,
+      DataFile file,
+      AllowedReaders allowedReadersEnum,
+      SilentFeedback silentFeedback) {
+    NewFileKey.Builder newFileKeyBuilder = NewFileKey.newBuilder();
+    String checksum = FileGroupUtil.getFileChecksum(file);
+
+    switch (Migrations.getCurrentVersion(context, silentFeedback)) {
+      case NEW_FILE_KEY:
+        newFileKeyBuilder
+            .setUrlToDownload(file.getUrlToDownload())
+            .setByteSize(file.getByteSize())
+            .setChecksum(checksum)
+            .setAllowedReaders(allowedReadersEnum);
+        break;
+      case ADD_DOWNLOAD_TRANSFORM:
+        newFileKeyBuilder
+            .setUrlToDownload(file.getUrlToDownload())
+            .setByteSize(file.getByteSize())
+            .setChecksum(checksum)
+            .setAllowedReaders(allowedReadersEnum);
+        if (file.hasDownloadTransforms()) {
+          newFileKeyBuilder.setDownloadTransforms(file.getDownloadTransforms());
+        }
+        break;
+      case USE_CHECKSUM_ONLY:
+        newFileKeyBuilder.setChecksum(checksum).setAllowedReaders(allowedReadersEnum);
+    }
+
+    return newFileKeyBuilder.build();
+  }
+
+  /**
+   * Creates a NewFileKey object from the given DataFile.
+   *
+   * @param file - a DataFile whose key you wish to construct.
+   * @param allowedReadersEnum - {@link AllowedReaders} signifies who has access to the file.
+   */
+  public static NewFileKey createKeyFromDataFile(DataFile file, AllowedReaders allowedReadersEnum) {
+    NewFileKey.Builder newFileKeyBuilder =
+        NewFileKey.newBuilder()
+            .setUrlToDownload(file.getUrlToDownload())
+            .setByteSize(file.getByteSize())
+            .setChecksum(FileGroupUtil.getFileChecksum(file))
+            .setAllowedReaders(allowedReadersEnum);
+    if (file.hasDownloadTransforms()) {
+      newFileKeyBuilder.setDownloadTransforms(file.getDownloadTransforms());
+    }
+    return newFileKeyBuilder.build();
+  }
+
+  /**
+   * Returns a temporary FileKey that can be used to interact with the MddFileDownloader to download
+   * a delta file.
+   *
+   * @param deltaFile - a DeltaFile whose key you wish to construct.
+   * @param allowedReadersEnum - {@link AllowedReaders} signifies who has access to the file.
+   */
+  public static NewFileKey createTempKeyForDeltaFile(
+      DeltaFile deltaFile, AllowedReaders allowedReadersEnum) {
+    NewFileKey newFileKey =
+        NewFileKey.newBuilder()
+            .setUrlToDownload(deltaFile.getUrlToDownload())
+            .setByteSize(deltaFile.getByteSize())
+            .setChecksum(deltaFile.getChecksum())
+            .setAllowedReaders(allowedReadersEnum)
+            .build();
+
+    return newFileKey;
+  }
+
+  /**
+   * Makes any changes that should be made before accessing the internal state of this store.
+   *
+   * <p>Other methods in this class do not call or check if this method was already called before
+   * trying to access internal state. It is expected from the caller to call this before anything
+   * else.
+   *
+   * @return a future that resolves to false if init failed, signalling caller to clear internal
+   *     storage.
+   */
+  // TODO(b/124072754): Change to package private once all code is refactored.
+  public ListenableFuture<Boolean> init();
+
+  /** Return {@link SharedFile} associated with the given key. */
+  public ListenableFuture<SharedFile> read(NewFileKey newFileKey);
+
+  /**
+   * Map the key "newFileKey" to the value "sharedFile". Returns a future resolving to true if the
+   * operation succeeds, false if it fails.
+   */
+  public ListenableFuture<Boolean> write(NewFileKey newFileKey, SharedFile sharedFile);
+
+  /**
+   * Remove the value stored at "newFileKey". Returns a future resolving to true if the operation
+   * succeeds, false if it fails.
+   */
+  public ListenableFuture<Boolean> remove(NewFileKey newFileKey);
+
+  /** Return all keys in the store. */
+  public ListenableFuture<List<NewFileKey>> getAllFileKeys();
+
+  /** Clear the store. */
+  public ListenableFuture<Void> clear();
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/SharedPreferencesFileGroupsMetadata.java b/java/com/google/android/libraries/mobiledatadownload/internal/SharedPreferencesFileGroupsMetadata.java
new file mode 100644
index 0000000..bc407fd
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/SharedPreferencesFileGroupsMetadata.java
@@ -0,0 +1,288 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.util.Pair;
+import androidx.annotation.VisibleForTesting;
+import com.google.android.libraries.mobiledatadownload.SilentFeedback;
+import com.google.android.libraries.mobiledatadownload.TimeSource;
+import com.google.android.libraries.mobiledatadownload.annotations.InstanceId;
+import com.google.android.libraries.mobiledatadownload.internal.annotations.SequentialControlExecutor;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil;
+import com.google.android.libraries.mobiledatadownload.internal.util.FileGroupUtil;
+import com.google.android.libraries.mobiledatadownload.internal.util.FileGroupsMetadataUtil;
+import com.google.android.libraries.mobiledatadownload.internal.util.FileGroupsMetadataUtil.GroupKeyDeserializationException;
+import com.google.android.libraries.mobiledatadownload.internal.util.ProtoLiteUtil;
+import com.google.android.libraries.mobiledatadownload.internal.util.SharedPreferencesUtil;
+import com.google.common.base.Optional;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.errorprone.annotations.CheckReturnValue;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal;
+import com.google.mobiledatadownload.internal.MetadataProto.GroupKey;
+import com.google.mobiledatadownload.internal.MetadataProto.GroupKeyProperties;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Executor;
+import javax.inject.Inject;
+import org.checkerframework.checker.nullness.compatqual.NullableType;
+
+/** Stores and provides access to file group metadata using SharedPreferences. */
+@CheckReturnValue
+public final class SharedPreferencesFileGroupsMetadata implements FileGroupsMetadata {
+
+  private static final String TAG = "SharedPreferencesFileGroupsMetadata";
+  private static final String MDD_FILE_GROUPS = FileGroupsMetadataUtil.MDD_FILE_GROUPS;
+  private static final String MDD_FILE_GROUP_KEY_PROPERTIES =
+      FileGroupsMetadataUtil.MDD_FILE_GROUP_KEY_PROPERTIES;
+
+  // TODO(b/144033163): Migrate the Garbage Collector File to PDS.
+  @VisibleForTesting static final String MDD_GARBAGE_COLLECTION_FILE = "gms_icing_mdd_garbage_file";
+
+  private final Context context;
+  private final TimeSource timeSource;
+  private final SilentFeedback silentFeedback;
+  private final Optional<String> instanceId;
+  private final Executor sequentialControlExecutor;
+
+  @Inject
+  SharedPreferencesFileGroupsMetadata(
+      @ApplicationContext Context context,
+      TimeSource timeSource,
+      SilentFeedback silentFeedback,
+      @InstanceId Optional<String> instanceId,
+      @SequentialControlExecutor Executor sequentialControlExecutor) {
+    this.context = context;
+    this.timeSource = timeSource;
+    this.silentFeedback = silentFeedback;
+    this.instanceId = instanceId;
+    this.sequentialControlExecutor = sequentialControlExecutor;
+  }
+
+  @Override
+  public ListenableFuture<Void> init() {
+    return Futures.immediateVoidFuture();
+  }
+
+  @Override
+  public ListenableFuture<@NullableType DataFileGroupInternal> read(GroupKey groupKey) {
+    String serializedGroupKey = FileGroupsMetadataUtil.getSerializedGroupKey(groupKey, context);
+
+    SharedPreferences prefs =
+        SharedPreferencesUtil.getSharedPreferences(context, MDD_FILE_GROUPS, instanceId);
+    DataFileGroupInternal fileGroup =
+        SharedPreferencesUtil.readProto(prefs, serializedGroupKey, DataFileGroupInternal.parser());
+
+    return Futures.immediateFuture(fileGroup);
+  }
+
+  @Override
+  public ListenableFuture<Boolean> write(GroupKey groupKey, DataFileGroupInternal fileGroup) {
+    String serializedGroupKey = FileGroupsMetadataUtil.getSerializedGroupKey(groupKey, context);
+
+    SharedPreferences prefs =
+        SharedPreferencesUtil.getSharedPreferences(context, MDD_FILE_GROUPS, instanceId);
+    return Futures.immediateFuture(
+        SharedPreferencesUtil.writeProto(prefs, serializedGroupKey, fileGroup));
+  }
+
+  @Override
+  public ListenableFuture<Boolean> remove(GroupKey groupKey) {
+    String serializedGroupKey = FileGroupsMetadataUtil.getSerializedGroupKey(groupKey, context);
+
+    SharedPreferences prefs =
+        SharedPreferencesUtil.getSharedPreferences(context, MDD_FILE_GROUPS, instanceId);
+    return Futures.immediateFuture(SharedPreferencesUtil.removeProto(prefs, serializedGroupKey));
+  }
+
+  @Override
+  public ListenableFuture<@NullableType GroupKeyProperties> readGroupKeyProperties(
+      GroupKey groupKey) {
+    String serializedGroupKey = FileGroupsMetadataUtil.getSerializedGroupKey(groupKey, context);
+
+    SharedPreferences prefs =
+        SharedPreferencesUtil.getSharedPreferences(
+            context, MDD_FILE_GROUP_KEY_PROPERTIES, instanceId);
+    GroupKeyProperties groupKeyProperties =
+        SharedPreferencesUtil.readProto(prefs, serializedGroupKey, GroupKeyProperties.parser());
+
+    return Futures.immediateFuture(groupKeyProperties);
+  }
+
+  @Override
+  public ListenableFuture<Boolean> writeGroupKeyProperties(
+      GroupKey groupKey, GroupKeyProperties groupKeyProperties) {
+    String serializedGroupKey = FileGroupsMetadataUtil.getSerializedGroupKey(groupKey, context);
+
+    SharedPreferences prefs =
+        SharedPreferencesUtil.getSharedPreferences(
+            context, MDD_FILE_GROUP_KEY_PROPERTIES, instanceId);
+    return Futures.immediateFuture(
+        SharedPreferencesUtil.writeProto(prefs, serializedGroupKey, groupKeyProperties));
+  }
+
+  @Override
+  public ListenableFuture<List<GroupKey>> getAllGroupKeys() {
+    List<GroupKey> groupKeyList = new ArrayList<>();
+    SharedPreferences prefs =
+        SharedPreferencesUtil.getSharedPreferences(context, MDD_FILE_GROUPS, instanceId);
+    SharedPreferences.Editor editor = null;
+    for (String serializedGroupKey : prefs.getAll().keySet()) {
+      try {
+        GroupKey newFileKey = FileGroupsMetadataUtil.deserializeGroupKey(serializedGroupKey);
+        groupKeyList.add(newFileKey);
+      } catch (GroupKeyDeserializationException e) {
+        LogUtil.e(e, "Failed to deserialize groupKey:" + serializedGroupKey);
+        silentFeedback.send(e, "Failed to deserialize groupKey");
+        // TODO(b/128850000): Refactor this code to a single corruption handling task during
+        // maintenance.
+        // Remove the corrupted file metadata and the related SharedFile metadata will be deleted
+        // in next maintenance task.
+        if (editor == null) {
+          editor = prefs.edit();
+        }
+        editor.remove(serializedGroupKey);
+        LogUtil.d("%s: Deleting null file group ", TAG);
+        continue;
+      }
+    }
+    if (editor != null) {
+      editor.commit();
+    }
+    return Futures.immediateFuture(groupKeyList);
+  }
+
+  @Override
+  public ListenableFuture<List<Pair<GroupKey, DataFileGroupInternal>>> getAllFreshGroups() {
+    return Futures.transformAsync(
+        getAllGroupKeys(),
+        groupKeyList -> {
+          List<ListenableFuture<@NullableType DataFileGroupInternal>> groupReadFutures =
+              new ArrayList<>();
+          for (GroupKey key : groupKeyList) {
+            groupReadFutures.add(read(key));
+          }
+          return Futures.whenAllComplete(groupReadFutures)
+              .callAsync(
+                  () -> {
+                    List<Pair<GroupKey, DataFileGroupInternal>> retrievedGroups = new ArrayList<>();
+                    for (int i = 0; i < groupKeyList.size(); i++) {
+                      GroupKey key = groupKeyList.get(i);
+                      DataFileGroupInternal group = Futures.getDone(groupReadFutures.get(i));
+                      if (group == null) {
+                        continue;
+                      }
+                      retrievedGroups.add(Pair.create(key, group));
+                    }
+                    return Futures.immediateFuture(retrievedGroups);
+                  },
+                  sequentialControlExecutor);
+        },
+        sequentialControlExecutor);
+  }
+
+  @Override
+  public ListenableFuture<Boolean> removeAllGroupsWithKeys(List<GroupKey> keys) {
+    SharedPreferences prefs =
+        SharedPreferencesUtil.getSharedPreferences(context, MDD_FILE_GROUPS, instanceId);
+    SharedPreferences.Editor editor = prefs.edit();
+    for (GroupKey key : keys) {
+      LogUtil.d("%s: Removing group %s %s", TAG, key.getGroupName(), key.getOwnerPackage());
+      SharedPreferencesUtil.removeProto(editor, key);
+    }
+    return Futures.immediateFuture(editor.commit());
+  }
+
+  @Override
+  public ListenableFuture<List<DataFileGroupInternal>> getAllStaleGroups() {
+    return Futures.immediateFuture(
+        FileGroupsMetadataUtil.getAllStaleGroups(
+            FileGroupsMetadataUtil.getGarbageCollectorFile(context, instanceId)));
+  }
+
+  @Override
+  public ListenableFuture<Boolean> addStaleGroup(DataFileGroupInternal fileGroup) {
+    LogUtil.d("%s: Adding file group %s", TAG, fileGroup.getGroupName());
+
+    long currentTimeSeconds = timeSource.currentTimeMillis() / 1000;
+    fileGroup =
+        FileGroupUtil.setStaleExpirationDate(
+            fileGroup, currentTimeSeconds + fileGroup.getStaleLifetimeSecs());
+
+    List<DataFileGroupInternal> fileGroups = new ArrayList<>();
+    fileGroups.add(fileGroup);
+
+    return writeStaleGroups(fileGroups);
+  }
+
+  @Override
+  public ListenableFuture<Boolean> writeStaleGroups(List<DataFileGroupInternal> fileGroups) {
+    File garbageCollectorFile = getGarbageCollectorFile();
+    FileOutputStream outputStream;
+    try {
+      outputStream = new FileOutputStream(garbageCollectorFile, /* append */ true);
+    } catch (FileNotFoundException e) {
+      LogUtil.e("File %s not found while writing.", garbageCollectorFile.getAbsolutePath());
+      return Futures.immediateFuture(false);
+    }
+
+    try {
+      // tail_crc == false, means that each message has its own crc
+      ByteBuffer buf = ProtoLiteUtil.dumpIntoBuffer(fileGroups, false /*tail crc*/);
+      if (buf != null) {
+        outputStream.getChannel().write(buf);
+      }
+      outputStream.close();
+    } catch (IOException e) {
+      LogUtil.e("IOException occurred while writing file groups.");
+      return Futures.immediateFuture(false);
+    }
+    return Futures.immediateFuture(true);
+  }
+
+  @VisibleForTesting
+  File getGarbageCollectorFile() {
+    return FileGroupsMetadataUtil.getGarbageCollectorFile(context, instanceId);
+  }
+
+  // TODO(b/124072754): Change to package private once all code is refactored.
+  @Override
+  public ListenableFuture<Void> removeAllStaleGroups() {
+    getGarbageCollectorFile().delete();
+    return Futures.immediateVoidFuture();
+  }
+
+  @Override
+  public ListenableFuture<Void> clear() {
+    SharedPreferences prefs =
+        SharedPreferencesUtil.getSharedPreferences(context, MDD_FILE_GROUPS, instanceId);
+    prefs.edit().clear().commit();
+
+    SharedPreferences activatedGroupPrefs =
+        SharedPreferencesUtil.getSharedPreferences(
+            context, MDD_FILE_GROUP_KEY_PROPERTIES, instanceId);
+    activatedGroupPrefs.edit().clear().commit();
+
+    return removeAllStaleGroups();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/SharedPreferencesSharedFilesMetadata.java b/java/com/google/android/libraries/mobiledatadownload/internal/SharedPreferencesSharedFilesMetadata.java
new file mode 100644
index 0000000..662ac5b
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/SharedPreferencesSharedFilesMetadata.java
@@ -0,0 +1,329 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal;
+
+import static com.google.android.libraries.mobiledatadownload.internal.MddConstants.SPLIT_CHAR;
+import static com.google.android.libraries.mobiledatadownload.internal.util.SharedFilesMetadataUtil.MDD_SHARED_FILES;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import androidx.annotation.VisibleForTesting;
+import com.google.android.libraries.mobiledatadownload.Flags;
+import com.google.android.libraries.mobiledatadownload.SilentFeedback;
+import com.google.android.libraries.mobiledatadownload.annotations.InstanceId;
+import com.google.android.libraries.mobiledatadownload.internal.Migrations.FileKeyVersion;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil;
+import com.google.android.libraries.mobiledatadownload.internal.util.SharedFilesMetadataUtil;
+import com.google.android.libraries.mobiledatadownload.internal.util.SharedFilesMetadataUtil.FileKeyDeserializationException;
+import com.google.android.libraries.mobiledatadownload.internal.util.SharedPreferencesUtil;
+import com.google.common.base.Optional;
+import com.google.common.base.Splitter;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.errorprone.annotations.CheckReturnValue;
+import com.google.mobiledatadownload.internal.MetadataProto.NewFileKey;
+import com.google.mobiledatadownload.internal.MetadataProto.SharedFile;
+import java.util.ArrayList;
+import java.util.List;
+import javax.inject.Inject;
+
+/**
+ * Stores and provides access to shared file metadata using SharedPreferences.
+ *
+ * <p>Synchronization on this class depends on the fact that MDD Control Flow are executed on a
+ * SequentialExecutor.
+ */
+@CheckReturnValue
+public final class SharedPreferencesSharedFilesMetadata implements SharedFilesMetadata {
+
+  private static final String TAG = "SharedFilesMetadata";
+
+  @VisibleForTesting static final String PREFS_KEY_NEXT_FILE_NAME_OLD = "next_file_name";
+  @VisibleForTesting static final String PREFS_KEY_NEXT_FILE_NAME = "next_file_name_v2";
+
+  private final Context context;
+  private final SilentFeedback silentFeedback;
+  private final Optional<String> instanceId;
+  private final Flags flags;
+
+  @Inject
+  public SharedPreferencesSharedFilesMetadata(
+      @ApplicationContext Context context,
+      SilentFeedback silentFeedback,
+      @InstanceId Optional<String> instanceId,
+      Flags flags) {
+    this.context = context;
+    this.silentFeedback = silentFeedback;
+    this.instanceId = instanceId;
+    this.flags = flags;
+  }
+
+  @Override
+  public ListenableFuture<Boolean> init() {
+    // Migrate to the new file key.
+    if (!Migrations.isMigratedToNewFileKey(context)) {
+      LogUtil.d("%s Device isn't migrated to new file key, clear and set migration.", TAG);
+      Migrations.setMigratedToNewFileKey(context, true);
+      Migrations.setCurrentVersion(context, FileKeyVersion.getVersion(flags.fileKeyVersion()));
+      return Futures.immediateFuture(false);
+    }
+    return Futures.immediateFuture(upgradeToNewVersion());
+  }
+
+  /**
+   * Sequentially upgrade FileKey version to FeatureFlags.fileKeyVersion
+   *
+   * @return false if any upgrade fails which will result in clearing of all meta data, true on
+   *     successful upgrade.
+   */
+  private boolean upgradeToNewVersion() {
+    final FileKeyVersion targetVersion = FileKeyVersion.getVersion(flags.fileKeyVersion());
+    final FileKeyVersion currentVersion = Migrations.getCurrentVersion(context, silentFeedback);
+
+    if (targetVersion.value == currentVersion.value) {
+      return true;
+    }
+
+    if (targetVersion.value < currentVersion.value) {
+      // We don't support downgrading file key version. Clear everything.
+      LogUtil.e(
+          "%s Cannot migrate back from value %s to %s. Clear everything!",
+          TAG, currentVersion, targetVersion);
+      silentFeedback.send(
+          new Exception(
+              "Downgraded file key from " + currentVersion + " to " + targetVersion + "."),
+          "FileKey migrations unexpected downgrade.");
+      Migrations.setCurrentVersion(context, targetVersion);
+      return false;
+    }
+
+    // Migrate one version at a time one by one
+    try {
+      for (int nextVersion = currentVersion.value + 1;
+          nextVersion <= targetVersion.value;
+          nextVersion++) {
+        if (upgradeTo(FileKeyVersion.getVersion(nextVersion))) {
+          Migrations.setCurrentVersion(context, FileKeyVersion.getVersion(nextVersion));
+        } else {
+          // If migration to next version fail, we will clear all data and set the currentVersion
+          // to targetVersion (phFileKeyVersion)
+          return false;
+        }
+      }
+    } finally {
+      if (Migrations.getCurrentVersion(context, silentFeedback).value != targetVersion.value) {
+        if (!Migrations.setCurrentVersion(context, targetVersion)) {
+          LogUtil.e(
+              "Failed to commit migration version to disk. Fail to set target version to "
+                  + targetVersion
+                  + ".");
+          silentFeedback.send(
+              new Exception("Fail to set target version " + targetVersion + "."),
+              "Failed to commit migration version to disk.");
+        }
+      }
+    }
+
+    return true;
+  }
+
+  private boolean upgradeTo(FileKeyVersion targetVersion) {
+    switch (targetVersion) {
+      case ADD_DOWNLOAD_TRANSFORM:
+        return migrateToAddDownloadTransform();
+      case USE_CHECKSUM_ONLY:
+        return migrateToDedupOnChecksumOnly();
+      default:
+        throw new UnsupportedOperationException(
+            "Upgrade to version " + targetVersion.name() + "not supported!");
+    }
+  }
+
+  /** A one off method that is called when we migrate key to add download transform. */
+  private boolean migrateToAddDownloadTransform() {
+    LogUtil.d("%s: Starting migration to add download transform", TAG);
+    SharedPreferences prefs =
+        SharedPreferencesUtil.getSharedPreferences(context, MDD_SHARED_FILES, instanceId);
+    SharedPreferences.Editor editor = prefs.edit();
+    for (String serializedFileKey : prefs.getAll().keySet()) {
+
+      // Remove the data that we are unable to read or parse.
+      NewFileKey newFileKey;
+      try {
+        newFileKey =
+            SharedFilesMetadataUtil.deserializeNewFileKey(
+                serializedFileKey, context, silentFeedback);
+      } catch (FileKeyDeserializationException e) {
+        LogUtil.e(
+            "%s Failed to deserialize file key %s, remove and continue.", TAG, serializedFileKey);
+        silentFeedback.send(e, "Failed to deserialize file key, remove and continue.");
+        editor.remove(serializedFileKey);
+        continue;
+      }
+      SharedFile sharedFile =
+          SharedPreferencesUtil.readProto(prefs, serializedFileKey, SharedFile.parser());
+      if (sharedFile == null) {
+        LogUtil.e("%s: Unable to read sharedFile from shared preferences.", TAG);
+        editor.remove(serializedFileKey);
+        continue;
+      }
+
+      // Remove the old key and write the new one.
+      SharedPreferencesUtil.removeProto(editor, serializedFileKey);
+      SharedPreferencesUtil.writeProto(
+          editor,
+          SharedFilesMetadataUtil.serializeNewFileKeyWithDownloadTransform(newFileKey),
+          sharedFile);
+    }
+
+    if (!editor.commit()) {
+      LogUtil.e("Failed to commit migration metadata to disk");
+      silentFeedback.send(
+          new Exception("Migrate to DownloadTransform failed."),
+          "Failed to commit migration metadata to disk.");
+      return false;
+    }
+
+    return true;
+  }
+
+  /** A one off method that is called when we migrate key to contain checksum and allowedReaders. */
+  private boolean migrateToDedupOnChecksumOnly() {
+    LogUtil.d("%s: Starting migration to dedup on checksum only", TAG);
+    SharedPreferences prefs =
+        SharedPreferencesUtil.getSharedPreferences(context, MDD_SHARED_FILES, instanceId);
+    SharedPreferences.Editor editor = prefs.edit();
+    for (String serializedFileKey : prefs.getAll().keySet()) {
+
+      // Remove the data that we are unable to read or parse.
+      NewFileKey newFileKey;
+      try {
+        newFileKey =
+            SharedFilesMetadataUtil.deserializeNewFileKey(
+                serializedFileKey, context, silentFeedback);
+      } catch (FileKeyDeserializationException e) {
+        LogUtil.e(
+            "%s Failed to deserialize file key %s, remove and continue.", TAG, serializedFileKey);
+        silentFeedback.send(e, "Failed to deserialize file key, remove and continue.");
+        editor.remove(serializedFileKey);
+        continue;
+      }
+
+      SharedFile sharedFile =
+          SharedPreferencesUtil.readProto(prefs, serializedFileKey, SharedFile.parser());
+      if (sharedFile == null) {
+        LogUtil.e("%s: Unable to read sharedFile from shared preferences.", TAG);
+        editor.remove(serializedFileKey);
+        continue;
+      }
+
+      // Remove the old key and write the new one.
+      SharedPreferencesUtil.removeProto(editor, serializedFileKey);
+      SharedPreferencesUtil.writeProto(
+          editor,
+          SharedFilesMetadataUtil.serializeNewFileKeyWithChecksumOnly(newFileKey),
+          sharedFile);
+    }
+
+    if (!editor.commit()) {
+      LogUtil.e("Failed to commit migration metadata to disk");
+      silentFeedback.send(
+          new Exception("Migrate to ChecksumOnly failed."),
+          "Failed to commit migration metadata to disk.");
+      return false;
+    }
+
+    return true;
+  }
+
+  @SuppressWarnings("nullness")
+  @Override
+  public ListenableFuture<SharedFile> read(NewFileKey newFileKey) {
+    String serializedFileKey =
+        SharedFilesMetadataUtil.getSerializedFileKey(newFileKey, context, silentFeedback);
+
+    SharedPreferences prefs =
+        SharedPreferencesUtil.getSharedPreferences(context, MDD_SHARED_FILES, instanceId);
+    SharedFile sharedFile =
+        SharedPreferencesUtil.readProto(prefs, serializedFileKey, SharedFile.parser());
+
+    return Futures.immediateFuture(sharedFile);
+  }
+
+  @Override
+  public ListenableFuture<Boolean> write(NewFileKey newFileKey, SharedFile sharedFile) {
+    String serializedFileKey =
+        SharedFilesMetadataUtil.getSerializedFileKey(newFileKey, context, silentFeedback);
+
+    SharedPreferences prefs =
+        SharedPreferencesUtil.getSharedPreferences(context, MDD_SHARED_FILES, instanceId);
+    return Futures.immediateFuture(
+        SharedPreferencesUtil.writeProto(prefs, serializedFileKey, sharedFile));
+  }
+
+  @Override
+  public ListenableFuture<Boolean> remove(NewFileKey newFileKey) {
+    String serializedFileKey =
+        SharedFilesMetadataUtil.getSerializedFileKey(newFileKey, context, silentFeedback);
+
+    SharedPreferences prefs =
+        SharedPreferencesUtil.getSharedPreferences(context, MDD_SHARED_FILES, instanceId);
+    return Futures.immediateFuture(SharedPreferencesUtil.removeProto(prefs, serializedFileKey));
+  }
+
+  @Override
+  public ListenableFuture<List<NewFileKey>> getAllFileKeys() {
+    List<NewFileKey> newFileKeyList = new ArrayList<>();
+    SharedPreferences prefs =
+        SharedPreferencesUtil.getSharedPreferences(context, MDD_SHARED_FILES, instanceId);
+    SharedPreferences.Editor editor = null;
+    for (String serializedFileKey : prefs.getAll().keySet()) {
+      try {
+        NewFileKey newFileKey =
+            SharedFilesMetadataUtil.deserializeNewFileKey(
+                serializedFileKey, context, silentFeedback);
+        newFileKeyList.add(newFileKey);
+      } catch (FileKeyDeserializationException e) {
+        LogUtil.e(e, "Failed to deserialize newFileKey:" + serializedFileKey);
+        silentFeedback.send(
+            e,
+            "Failed to deserialize newFileKey, unexpected key size: %d",
+            Splitter.on(SPLIT_CHAR).splitToList(serializedFileKey).size());
+        // TODO(b/128850000): Refactor this code to a single corruption handling task during
+        // maintenance.
+        // Remove the corrupted file metadata and the related FileGroup metadata will be deleted
+        // in next maintenance task.
+        if (editor == null) {
+          editor = prefs.edit();
+        }
+        editor.remove(serializedFileKey);
+        continue;
+      }
+    }
+    if (editor != null) {
+      editor.commit();
+    }
+    return Futures.immediateFuture(newFileKeyList);
+  }
+
+  @Override
+  public ListenableFuture<Void> clear() {
+    SharedPreferences prefs =
+        SharedPreferencesUtil.getSharedPreferences(context, MDD_SHARED_FILES, instanceId);
+    prefs.edit().clear().commit();
+    return Futures.immediateFuture(null);
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/UninstalledAppException.java b/java/com/google/android/libraries/mobiledatadownload/internal/UninstalledAppException.java
new file mode 100644
index 0000000..7784b12
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/UninstalledAppException.java
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal;
+
+/**
+ * Thrown when trying to add a File Group that is owned by an app that is not present on the device.
+ */
+public class UninstalledAppException extends Exception {}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/annotations/Annotations.java b/java/com/google/android/libraries/mobiledatadownload/internal/annotations/Annotations.java
new file mode 100644
index 0000000..886b192
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/annotations/Annotations.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.annotations;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import javax.inject.Qualifier;
+
+/** Annotations for MDD internals. */
+// TODO(b/168081073): Add AccountManager and SequentialControlExecutor to this file.
+public final class Annotations {
+  /** Qualifier for the PDS migration diagnostic metadata. */
+  @Qualifier
+  @Retention(RetentionPolicy.RUNTIME)
+  public @interface PdsMigrationDiagnostic {}
+
+  /** Qualifier for the PDS migration destination metadata. */
+  @Qualifier
+  @Retention(RetentionPolicy.RUNTIME)
+  public @interface PdsMigrationDestination {}
+
+  /** Qualifier for the PDS migration file groups destination uri. */
+  @Qualifier
+  @Retention(RetentionPolicy.RUNTIME)
+  public @interface FileGroupsDestinationUri {}
+
+  /** Qualifier for the PDS migration file groups diagnostic uri. */
+  @Qualifier
+  @Retention(RetentionPolicy.RUNTIME)
+  public @interface FileGroupsDiagnosticUri {}
+
+  /** Qualifier for the PDS migration shared files destination uri. */
+  @Qualifier
+  @Retention(RetentionPolicy.RUNTIME)
+  public @interface SharedFilesDestinationUri {}
+
+  /** Qualifier for the PDS migration shared files diagnostic uri. */
+  @Qualifier
+  @Retention(RetentionPolicy.RUNTIME)
+  public @interface SharedFilesDiagnosticUri {}
+
+  /** Qualifier for the PDS for logging state. */
+  @Qualifier
+  @Retention(RetentionPolicy.RUNTIME)
+  public @interface LoggingStateStore {}
+
+  private Annotations() {}
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/annotations/BUILD b/java/com/google/android/libraries/mobiledatadownload/internal/annotations/BUILD
new file mode 100644
index 0000000..dc959e6
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/annotations/BUILD
@@ -0,0 +1,41 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_library")
+
+package(
+    default_visibility = [
+        "//visibility:public",
+    ],
+    licenses = ["notice"],
+)
+
+android_library(
+    name = "SequentialControlExecutor",
+    srcs = [
+        "SequentialControlExecutor.java",
+    ],
+    deps = [
+        "@com_google_dagger",
+        "@javax_inject",
+    ],
+)
+
+android_library(
+    name = "annotations",
+    srcs = ["Annotations.java"],
+    deps = [
+        "@com_google_dagger",
+        "@javax_inject",
+    ],
+)
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/annotations/SequentialControlExecutor.java b/java/com/google/android/libraries/mobiledatadownload/internal/annotations/SequentialControlExecutor.java
new file mode 100644
index 0000000..384989f
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/annotations/SequentialControlExecutor.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.annotations;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Target;
+import javax.inject.Qualifier;
+
+/**
+ * A Sequential Executor on which MDD runs control execution flow which will touch I/O. MDD depends
+ * on this SequentialControlExecutor for synchronizations.
+ */
+@Target({ElementType.PARAMETER, ElementType.METHOD, ElementType.FIELD})
+@Qualifier
+public @interface SequentialControlExecutor {}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/dagger/ApplicationContextModule.java b/java/com/google/android/libraries/mobiledatadownload/internal/dagger/ApplicationContextModule.java
new file mode 100644
index 0000000..3076120
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/dagger/ApplicationContextModule.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.dagger;
+
+import android.content.Context;
+import com.google.android.libraries.mobiledatadownload.internal.ApplicationContext;
+import dagger.Module;
+import dagger.Provides;
+
+/** Module for injecting a context from which we get the application Context */
+@Module
+public class ApplicationContextModule {
+
+  private final Context context;
+
+  public ApplicationContextModule(Context context) {
+    this.context = context.getApplicationContext();
+  }
+
+  @Provides
+  @ApplicationContext
+  Context provideContext() {
+    return context;
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/dagger/BUILD b/java/com/google/android/libraries/mobiledatadownload/internal/dagger/BUILD
new file mode 100644
index 0000000..065c222
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/dagger/BUILD
@@ -0,0 +1,99 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_library")
+
+package(
+    default_visibility = ["//:__subpackages__"],
+    licenses = ["notice"],
+)
+
+android_library(
+    name = "ApplicationContextModule",
+    srcs = ["ApplicationContextModule.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/internal:ApplicationContext",
+        "@com_google_dagger",
+    ],
+)
+
+android_library(
+    name = "ExecutorsModule",
+    srcs = ["ExecutorsModule.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/internal/annotations:SequentialControlExecutor",
+        "@com_google_dagger",
+        "@javax_inject",
+    ],
+)
+
+android_library(
+    name = "DownloaderModule",
+    srcs = ["DownloaderModule.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/delta:DeltaDecoder",
+        "//java/com/google/android/libraries/mobiledatadownload/downloader:FileDownloader",
+        "@com_google_dagger",
+        "@com_google_guava_guava",
+        "@javax_inject",
+    ],
+)
+
+android_library(
+    name = "MainMddLibModule",
+    srcs = ["MainMddLibModule.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload:AccountSource",
+        "//java/com/google/android/libraries/mobiledatadownload:ExperimentationConfig",
+        "//java/com/google/android/libraries/mobiledatadownload:Flags",
+        "//java/com/google/android/libraries/mobiledatadownload:SilentFeedback",
+        "//java/com/google/android/libraries/mobiledatadownload:TimeSource",
+        "//java/com/google/android/libraries/mobiledatadownload/annotations",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:ApplicationContext",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:FileGroupsMetadata",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:SharedFilesMetadata",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:SharedPreferencesFileGroupsMetadata",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:SharedPreferencesSharedFilesMetadata",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/annotations:SequentialControlExecutor",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/experimentation:DownloadStageManager",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/experimentation:NoOpDownloadStageManager",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:EventLogger",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LoggingStateStore",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:NoOpLoggingState",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:FuturesUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/monitor:DownloadProgressMonitor",
+        "//java/com/google/android/libraries/mobiledatadownload/monitor:NetworkUsageMonitor",
+        "@com_google_dagger",
+        "@com_google_guava_guava",
+        "@javax_inject",
+    ],
+)
+
+android_library(
+    name = "StandaloneComponent",
+    srcs = [
+        "StandaloneComponent.java",
+    ],
+    deps = [
+        ":ApplicationContextModule",
+        ":DownloaderModule",
+        ":ExecutorsModule",
+        ":MainMddLibModule",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:MobileDataDownloadManager",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:EventLogger",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LoggingStateStore",
+        "@com_google_dagger",
+        "@javax_inject",
+    ],
+)
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/dagger/DownloaderModule.java b/java/com/google/android/libraries/mobiledatadownload/internal/dagger/DownloaderModule.java
new file mode 100644
index 0000000..c3e218c
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/dagger/DownloaderModule.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.dagger;
+
+import com.google.android.libraries.mobiledatadownload.delta.DeltaDecoder;
+import com.google.android.libraries.mobiledatadownload.downloader.FileDownloader;
+import com.google.common.base.Optional;
+import com.google.common.base.Supplier;
+import com.google.common.base.Suppliers;
+import dagger.Module;
+import dagger.Provides;
+import javax.inject.Singleton;
+
+/** Module for MDD Lib downloader dependencies * */
+@Module
+public class DownloaderModule {
+
+  private final Optional<DeltaDecoder> deltaDecoderOptional;
+  private final Supplier<FileDownloader> fileDownloaderSupplier;
+
+  public DownloaderModule(
+      Optional<DeltaDecoder> deltaDecoderOptional,
+      Supplier<FileDownloader> fileDownloaderSupplier) {
+    this.deltaDecoderOptional = deltaDecoderOptional;
+    this.fileDownloaderSupplier = Suppliers.memoize(fileDownloaderSupplier);
+  }
+
+  @Provides
+  @Singleton
+  Supplier<FileDownloader> provideFileDownloaderSupplier() {
+    return fileDownloaderSupplier;
+  }
+
+  @Provides
+  @Singleton
+  Optional<DeltaDecoder> provideDeltaDecoder() {
+    return deltaDecoderOptional;
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/dagger/ExecutorsModule.java b/java/com/google/android/libraries/mobiledatadownload/internal/dagger/ExecutorsModule.java
new file mode 100644
index 0000000..5e7853c
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/dagger/ExecutorsModule.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.dagger;
+
+import com.google.android.libraries.mobiledatadownload.internal.annotations.SequentialControlExecutor;
+import dagger.Module;
+import dagger.Provides;
+import java.util.concurrent.Executor;
+import javax.inject.Singleton;
+
+/** Module that provides various Executors for MDD. */
+@Module
+public class ExecutorsModule {
+  // TODO(b/204211682): Also keep the general (non-sequential) controlExecutor.
+  // Executor to execute MDD tasks that needs to be done sequentially.
+  private final Executor sequentialControlExecutor;
+
+  public ExecutorsModule(Executor sequentialControlExecutor) {
+    this.sequentialControlExecutor = sequentialControlExecutor;
+  }
+
+  @Provides
+  @Singleton
+  @SequentialControlExecutor
+  public Executor provideSequentialControlExecutor() {
+    return sequentialControlExecutor;
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/dagger/MainMddLibModule.java b/java/com/google/android/libraries/mobiledatadownload/internal/dagger/MainMddLibModule.java
new file mode 100644
index 0000000..18c23f0
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/dagger/MainMddLibModule.java
@@ -0,0 +1,188 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.dagger;
+
+import android.content.Context;
+import com.google.android.libraries.mobiledatadownload.AccountSource;
+import com.google.android.libraries.mobiledatadownload.ExperimentationConfig;
+import com.google.android.libraries.mobiledatadownload.Flags;
+import com.google.android.libraries.mobiledatadownload.SilentFeedback;
+import com.google.android.libraries.mobiledatadownload.TimeSource;
+import com.google.android.libraries.mobiledatadownload.annotations.InstanceId;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.internal.ApplicationContext;
+import com.google.android.libraries.mobiledatadownload.internal.FileGroupsMetadata;
+import com.google.android.libraries.mobiledatadownload.internal.SharedFilesMetadata;
+import com.google.android.libraries.mobiledatadownload.internal.SharedPreferencesFileGroupsMetadata;
+import com.google.android.libraries.mobiledatadownload.internal.SharedPreferencesSharedFilesMetadata;
+import com.google.android.libraries.mobiledatadownload.internal.annotations.SequentialControlExecutor;
+import com.google.android.libraries.mobiledatadownload.internal.experimentation.DownloadStageManager;
+import com.google.android.libraries.mobiledatadownload.internal.experimentation.NoOpDownloadStageManager;
+import com.google.android.libraries.mobiledatadownload.internal.logging.EventLogger;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LoggingStateStore;
+import com.google.android.libraries.mobiledatadownload.internal.logging.NoOpLoggingState;
+import com.google.android.libraries.mobiledatadownload.internal.util.FuturesUtil;
+import com.google.android.libraries.mobiledatadownload.monitor.DownloadProgressMonitor;
+import com.google.android.libraries.mobiledatadownload.monitor.NetworkUsageMonitor;
+import com.google.common.base.Optional;
+import dagger.Module;
+import dagger.Provides;
+import java.util.concurrent.Executor;
+import javax.inject.Singleton;
+
+/** Module for MDD Lib dependencies */
+@Module
+public class MainMddLibModule {
+  /** The version of MDD library. Same as mdi_download module version. */
+  // TODO(b/122271766): Figure out how to update this automatically.
+  // LINT.IfChange
+  public static final int MDD_LIB_VERSION = 422883838;
+  // LINT.ThenChange(<internal>)
+
+  private final SynchronousFileStorage fileStorage;
+  private final NetworkUsageMonitor networkUsageMonitor;
+  private final EventLogger eventLogger;
+  private final Optional<DownloadProgressMonitor> downloadProgressMonitorOptional;
+  private final Optional<SilentFeedback> silentFeedbackOptional;
+  private final Optional<String> instanceId;
+  private final Optional<AccountSource> accountSourceOptional;
+  private final Flags flags;
+  private final Optional<ExperimentationConfig> experimentationConfigOptional;
+
+  public MainMddLibModule(
+      SynchronousFileStorage fileStorage,
+      NetworkUsageMonitor networkUsageMonitor,
+      EventLogger eventLogger,
+      Optional<DownloadProgressMonitor> downloadProgressMonitorOptional,
+      Optional<SilentFeedback> silentFeedbackOptional,
+      Optional<String> instanceId,
+      Optional<AccountSource> accountSourceOptional,
+      Flags flags,
+      Optional<ExperimentationConfig> experimentationConfigOptional) {
+    this.fileStorage = fileStorage;
+    this.networkUsageMonitor = networkUsageMonitor;
+    this.eventLogger = eventLogger;
+    this.downloadProgressMonitorOptional = downloadProgressMonitorOptional;
+    this.silentFeedbackOptional = silentFeedbackOptional;
+    this.instanceId = instanceId;
+    this.accountSourceOptional = accountSourceOptional;
+    this.flags = flags;
+    this.experimentationConfigOptional = experimentationConfigOptional;
+  }
+
+  @Provides
+  @Singleton
+  static FileGroupsMetadata provideFileGroupsMetadata(
+      SharedPreferencesFileGroupsMetadata fileGroupsMetadata) {
+    return fileGroupsMetadata;
+  }
+
+  @Provides
+  @Singleton
+  static SharedFilesMetadata provideSharedFilesMetadata(
+      SharedPreferencesSharedFilesMetadata sharedFilesMetadata) {
+    return sharedFilesMetadata;
+  }
+
+  @Provides
+  @Singleton
+  EventLogger provideEventLogger() {
+    return eventLogger;
+  }
+
+  @Provides
+  @Singleton
+  SilentFeedback providesSilentFeedback() {
+    if (this.silentFeedbackOptional.isPresent()) {
+      return this.silentFeedbackOptional.get();
+    } else {
+      return (throwable, description, args) -> {
+        // No-op SilentFeedback.
+      };
+    }
+  }
+
+  @Provides
+  @Singleton
+  Optional<AccountSource> provideAccountSourceOptional(@ApplicationContext Context context) {
+    return this.accountSourceOptional;
+  }
+
+  @Provides
+  @Singleton
+  static TimeSource provideTimeSource() {
+    return System::currentTimeMillis;
+  }
+
+  @Provides
+  @Singleton
+  @InstanceId
+  Optional<String> provideInstanceId() {
+    return this.instanceId;
+  }
+
+  @Provides
+  @Singleton
+  NetworkUsageMonitor provideNetworkUsageMonitor() {
+    return this.networkUsageMonitor;
+  }
+
+  @Provides
+  @Singleton
+  // TODO: We don't need to have @Singleton here and few other places in this class
+  // since it comes from the this instance. We should remove this since it could increase APK size.
+  Optional<DownloadProgressMonitor> provideDownloadProgressMonitor() {
+    return this.downloadProgressMonitorOptional;
+  }
+
+  @Provides
+  @Singleton
+  SynchronousFileStorage provideSynchronousFileStorage() {
+    return this.fileStorage;
+  }
+
+  @Provides
+  @Singleton
+  Flags provideFlags() {
+    return this.flags;
+  }
+
+  @Provides
+  Optional<ExperimentationConfig> provideExperimentationConfigOptional() {
+    return this.experimentationConfigOptional;
+  }
+
+  @Provides
+  @Singleton
+  static FuturesUtil provideFuturesUtil(@SequentialControlExecutor Executor sequentialExecutor) {
+    return new FuturesUtil(sequentialExecutor);
+  }
+
+  @Provides
+  @Singleton
+  static LoggingStateStore provideLoggingStateStore() {
+    return new NoOpLoggingState();
+  }
+
+  @Provides
+  static DownloadStageManager provideDownloadStageManager(
+      FileGroupsMetadata fileGroupsMetadata,
+      Optional<ExperimentationConfig> experimentationConfigOptional,
+      @SequentialControlExecutor Executor executor,
+      Flags flags) {
+    return new NoOpDownloadStageManager();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/dagger/StandaloneComponent.java b/java/com/google/android/libraries/mobiledatadownload/internal/dagger/StandaloneComponent.java
new file mode 100644
index 0000000..48367a4
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/dagger/StandaloneComponent.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.dagger;
+
+import com.google.android.libraries.mobiledatadownload.internal.MobileDataDownloadManager;
+import com.google.android.libraries.mobiledatadownload.internal.logging.EventLogger;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LoggingStateStore;
+import dagger.Component;
+import javax.inject.Singleton;
+
+/** Main component for standalone MDD library. */
+@Component(
+    modules = {
+      ApplicationContextModule.class,
+      DownloaderModule.class,
+      ExecutorsModule.class,
+      MainMddLibModule.class,
+    })
+@Singleton
+public abstract class StandaloneComponent {
+
+  public abstract MobileDataDownloadManager getMobileDataDownloadManager();
+
+  public abstract EventLogger getEventLogger();
+
+  // TODO(b/214632773): remove this when event logger can be constructed internally
+  public abstract LoggingStateStore getLoggingStateStore();
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/downloader/BUILD b/java/com/google/android/libraries/mobiledatadownload/internal/downloader/BUILD
new file mode 100644
index 0000000..5d239c1
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/downloader/BUILD
@@ -0,0 +1,124 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_library")
+
+package(
+    default_visibility = ["//:__subpackages__"],
+    licenses = ["notice"],
+)
+
+android_library(
+    name = "MddFileDownloader",
+    srcs = [
+        "MddFileDownloader.java",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload:DownloadException",
+        "//java/com/google/android/libraries/mobiledatadownload:FileSource",
+        "//java/com/google/android/libraries/mobiledatadownload:Flags",
+        "//java/com/google/android/libraries/mobiledatadownload/downloader:FileDownloader",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:ApplicationContext",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/annotations:SequentialControlExecutor",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LogUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LoggingStateStore",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "//java/com/google/android/libraries/mobiledatadownload/monitor:DownloadProgressMonitor",
+        "//java/com/google/android/libraries/mobiledatadownload/monitor:NetworkUsageMonitor",
+        "@androidx_annotation_annotation",
+        "@com_google_code_findbugs_jsr305",
+        "@com_google_dagger",
+        "@com_google_guava_guava",
+        "@javax_inject",
+    ],
+)
+
+android_library(
+    name = "FileNameUtil",
+    srcs = ["FileNameUtil.java"],
+)
+
+android_library(
+    name = "FileValidator",
+    srcs = ["FileValidator.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload:DownloadException",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:stream",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LogUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:FileGroupUtil",
+        "@com_google_code_findbugs_jsr305",
+    ],
+)
+
+android_library(
+    name = "DownloaderCallbackImpl",
+    srcs = [
+        "DownloaderCallbackImpl.java",
+    ],
+    deps = [
+        "MddFileDownloader",
+        ":FileNameUtil",
+        ":FileValidator",
+        ":ZipFolderOpener",
+        "//java/com/google/android/libraries/mobiledatadownload:DownloadException",
+        "//java/com/google/android/libraries/mobiledatadownload:Flags",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:recursive_size",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:stream",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:proto",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:SharedFilesMetadata",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:EventLogger",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LogUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:FileGroupUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/tracing:concurrent",
+        "@androidx_annotation_annotation",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "DeltaFileDownloaderCallbackImpl",
+    srcs = ["DeltaFileDownloaderCallbackImpl.java"],
+    deps = [
+        "MddFileDownloader",
+        ":DownloaderCallbackImpl",
+        ":FileNameUtil",
+        ":FileValidator",
+        "//java/com/google/android/libraries/mobiledatadownload:DownloadException",
+        "//java/com/google/android/libraries/mobiledatadownload:Flags",
+        "//java/com/google/android/libraries/mobiledatadownload:SilentFeedback",
+        "//java/com/google/android/libraries/mobiledatadownload/delta:DeltaDecoder",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:SharedFilesMetadata",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:EventLogger",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LogUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:DirectoryUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/tracing:concurrent",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "ZipFolderOpener",
+    srcs = ["ZipFolderOpener.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:stream",
+        "@com_google_guava_guava",
+    ],
+)
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/downloader/DeltaFileDownloaderCallbackImpl.java b/java/com/google/android/libraries/mobiledatadownload/internal/downloader/DeltaFileDownloaderCallbackImpl.java
new file mode 100644
index 0000000..a84397a
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/downloader/DeltaFileDownloaderCallbackImpl.java
@@ -0,0 +1,274 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.downloader;
+
+import static com.google.common.util.concurrent.Futures.immediateFailedFuture;
+import static com.google.common.util.concurrent.Futures.immediateVoidFuture;
+
+import android.content.Context;
+import android.net.Uri;
+import android.os.Build.VERSION;
+import android.os.Build.VERSION_CODES;
+import com.google.android.libraries.mobiledatadownload.DownloadException;
+import com.google.android.libraries.mobiledatadownload.DownloadException.DownloadResultCode;
+import com.google.android.libraries.mobiledatadownload.Flags;
+import com.google.android.libraries.mobiledatadownload.SilentFeedback;
+import com.google.android.libraries.mobiledatadownload.delta.DeltaDecoder;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.internal.SharedFilesMetadata;
+import com.google.android.libraries.mobiledatadownload.internal.downloader.MddFileDownloader.DownloaderCallback;
+import com.google.android.libraries.mobiledatadownload.internal.logging.EventLogger;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil;
+import com.google.android.libraries.mobiledatadownload.internal.util.DirectoryUtil;
+import com.google.android.libraries.mobiledatadownload.tracing.PropagatedFluentFuture;
+import com.google.android.libraries.mobiledatadownload.tracing.PropagatedFutures;
+import com.google.common.base.Ascii;
+import com.google.common.base.Optional;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFile;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal.AllowedReaders;
+import com.google.mobiledatadownload.internal.MetadataProto.DeltaFile;
+import com.google.mobiledatadownload.internal.MetadataProto.FileStatus;
+import com.google.mobiledatadownload.internal.MetadataProto.GroupKey;
+import com.google.mobiledatadownload.internal.MetadataProto.NewFileKey;
+import java.io.IOException;
+import java.util.concurrent.Executor;
+
+/**
+ * Impl for {@link DownloaderCallback} that handles delta download file, to restore full file with
+ * on device base file and the downloaded delta file.
+ */
+public final class DeltaFileDownloaderCallbackImpl implements DownloaderCallback {
+  private static final String TAG = "DeltaFileDownloaderCallbackImpl";
+
+  private final Context context;
+  private final SharedFilesMetadata sharedFilesMetadata;
+  private final SynchronousFileStorage fileStorage;
+  private final SilentFeedback silentFeedback;
+  private final DataFile dataFile;
+  private final AllowedReaders allowedReaders;
+  private final DeltaDecoder deltaDecoder;
+  private final DeltaFile deltaFile;
+  private final EventLogger eventLogger;
+  private final GroupKey groupKey;
+  private final int fileGroupVersionNumber;
+  private final long buildId;
+  private final String variantId;
+  private final Optional<String> instanceId;
+  private final Flags flags;
+  private final Executor sequentialControlExecutor;
+
+  public DeltaFileDownloaderCallbackImpl(
+      Context context,
+      SharedFilesMetadata sharedFilesMetadata,
+      SynchronousFileStorage fileStorage,
+      SilentFeedback silentFeedback,
+      DataFile dataFile,
+      AllowedReaders allowedReaders,
+      DeltaDecoder deltaDecoder,
+      DeltaFile deltaFile,
+      EventLogger eventLogger,
+      GroupKey groupKey,
+      int fileGroupVersionNumber,
+      long buildId,
+      String variantId,
+      Optional<String> instanceId,
+      Flags flags,
+      Executor sequentialControlExecutor) {
+    this.context = context;
+    this.sharedFilesMetadata = sharedFilesMetadata;
+    this.fileStorage = fileStorage;
+    this.silentFeedback = silentFeedback;
+    this.dataFile = dataFile;
+    this.allowedReaders = allowedReaders;
+    this.deltaDecoder = deltaDecoder;
+    this.deltaFile = deltaFile;
+    this.eventLogger = eventLogger;
+    this.groupKey = groupKey;
+    this.fileGroupVersionNumber = fileGroupVersionNumber;
+    this.buildId = buildId;
+    this.variantId = variantId;
+    this.instanceId = instanceId;
+    this.flags = flags;
+    this.sequentialControlExecutor = sequentialControlExecutor;
+  }
+
+  @Override
+  public ListenableFuture<Void> onDownloadComplete(Uri deltaFileUri) {
+    LogUtil.d("%s: Successfully downloaded delta file %s", TAG, deltaFileUri);
+
+    if (!FileValidator.verifyChecksum(fileStorage, deltaFileUri, deltaFile.getChecksum())) {
+      LogUtil.e(
+          "%s: Downloaded delta file at uri = %s, checksum = %s verification failed",
+          TAG, deltaFileUri, deltaFile.getChecksum());
+      DownloadException exception =
+          DownloadException.builder()
+              .setDownloadResultCode(DownloadResultCode.DOWNLOADED_FILE_CHECKSUM_MISMATCH_ERROR)
+              .build();
+      // File was downloaded successfully, but failed checksum mismatch error. This indicates a
+      // corrupted file that should be deleted so MDD can attempt to redownload from scratch.
+      return PropagatedFluentFuture.from(
+              DownloaderCallbackImpl.maybeDeleteFileOnChecksumMismatch(
+                  sharedFilesMetadata,
+                  dataFile,
+                  allowedReaders,
+                  fileStorage,
+                  deltaFileUri,
+                  deltaFile.getChecksum(),
+                  eventLogger,
+                  flags,
+                  sequentialControlExecutor))
+          .catchingAsync(
+              IOException.class,
+              ioException -> {
+                // Delete on checksum failed, add it as a suppressed exception if supported (API
+                // level 19 or higher).
+                if (VERSION.SDK_INT >= VERSION_CODES.KITKAT) {
+                  exception.addSuppressed(ioException);
+                }
+                return immediateVoidFuture();
+              },
+              sequentialControlExecutor)
+          .transformAsync(unused -> immediateFailedFuture(exception), sequentialControlExecutor);
+    }
+
+    Uri fullFileUri = FileNameUtil.getFinalFileUriWithTempDownloadedFile(deltaFileUri);
+    return PropagatedFutures.transformAsync(
+        handleDeltaDownloadFile(fullFileUri, deltaFileUri),
+        voidArg -> {
+          // TODO(b/149260496): once DeltaDownloader supports shared files, which have ChecksumType
+          // == SHA256, change from DataFile.ChecksumType.DFEFAULT to dataFile.getChecksumType().
+          if (!FileValidator.verifyChecksum(fileStorage, fullFileUri, dataFile.getChecksum())) {
+            LogUtil.e("%s: Final file checksum verification failed. %s.", TAG, fullFileUri);
+            return immediateFailedFuture(
+                DownloadException.builder()
+                    .setDownloadResultCode(DownloadResultCode.FINAL_FILE_CHECKSUM_MISMATCH_ERROR)
+                    .build());
+          }
+
+          return DownloaderCallbackImpl.updateFileStatus(
+              FileStatus.DOWNLOAD_COMPLETE,
+              dataFile,
+              allowedReaders,
+              sharedFilesMetadata,
+              sequentialControlExecutor);
+        },
+        sequentialControlExecutor);
+  }
+
+  @Override
+  public ListenableFuture<Void> onDownloadFailed(DownloadException exception) {
+    LogUtil.d("%s: Failed to download file(delta) %s", TAG, dataFile.getChecksum());
+    if (exception
+        .getDownloadResultCode()
+        .equals(DownloadResultCode.DOWNLOADED_FILE_CHECKSUM_MISMATCH_ERROR)) {
+      return DownloaderCallbackImpl.updateFileStatus(
+          FileStatus.CORRUPTED,
+          dataFile,
+          allowedReaders,
+          sharedFilesMetadata,
+          sequentialControlExecutor);
+    }
+    return DownloaderCallbackImpl.updateFileStatus(
+        FileStatus.DOWNLOAD_FAILED,
+        dataFile,
+        allowedReaders,
+        sharedFilesMetadata,
+        sequentialControlExecutor);
+  }
+
+  private ListenableFuture<Void> handleDeltaDownloadFile(Uri fullFileUri, Uri deltaFileUri) {
+    NewFileKey baseFileKey =
+        NewFileKey.newBuilder()
+            .setChecksum(deltaFile.getBaseFile().getChecksum())
+            .setAllowedReaders(allowedReaders)
+            .build();
+    return PropagatedFutures.transformAsync(
+        sharedFilesMetadata.read(baseFileKey),
+        baseFileMetadata -> {
+          Uri baseFileUri = null;
+          if (baseFileMetadata != null
+              && baseFileMetadata.getFileStatus() == FileStatus.DOWNLOAD_COMPLETE) {
+            baseFileUri =
+                DirectoryUtil.getOnDeviceUri(
+                    context,
+                    allowedReaders,
+                    baseFileMetadata.getFileName(),
+                    baseFileKey.getChecksum(),
+                    silentFeedback,
+                    instanceId,
+                    /* androidShared = */ false);
+          }
+
+          if (baseFileUri == null) {
+            return immediateFailedFuture(
+                DownloadException.builder()
+                    .setDownloadResultCode(
+                        DownloadResultCode.DELTA_DOWNLOAD_BASE_FILE_NOT_FOUND_ERROR)
+                    .build());
+          }
+
+          try {
+            decodeDeltaFile(baseFileUri, fullFileUri, deltaFileUri);
+          } catch (IOException e) {
+            LogUtil.e(
+                e,
+                "%s: Failed to decode delta file with url = %s failed. checksum = %s ",
+                TAG,
+                deltaFile.getUrlToDownload(),
+                dataFile.getChecksum());
+            silentFeedback.send(e, "Failed to decode delta file.");
+            return immediateFailedFuture(
+                DownloadException.builder()
+                    .setDownloadResultCode(DownloadResultCode.DELTA_DOWNLOAD_DECODE_IO_ERROR)
+                    .setCause(e)
+                    .build());
+          }
+          Void fileGroupStats = null;
+          eventLogger.logMddNetworkSavings(
+              fileGroupStats,
+              0,
+              dataFile.getByteSize(),
+              deltaFile.getByteSize(),
+              dataFile.getFileId(),
+              getDeltaFileIndex());
+          return immediateVoidFuture();
+        },
+        sequentialControlExecutor);
+  }
+
+  private int getDeltaFileIndex() {
+    for (int i = 0; i < dataFile.getDeltaFileCount(); i++) {
+      if (Ascii.equalsIgnoreCase(dataFile.getDeltaFile(i).getChecksum(), deltaFile.getChecksum())) {
+        return i + 1;
+      }
+    }
+    return 0;
+  }
+
+  private void decodeDeltaFile(Uri baseFileUri, Uri fullFileUri, Uri deltaFileUri)
+      throws IOException {
+    if (fileStorage.exists(fullFileUri)) {
+      // Delete if the full file was partially downloaded before.
+      fileStorage.deleteFile(fullFileUri);
+    }
+    deltaDecoder.decode(baseFileUri, deltaFileUri, fullFileUri);
+    // Only delete delta file on success case. Not delete and re-download if decode fails as it is
+    // most likely configuration issue.
+    // TODO(b/123584890): Delete delta file on decode failure once MDD server test is in place.
+    fileStorage.deleteFile(deltaFileUri);
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/downloader/DownloaderCallbackImpl.java b/java/com/google/android/libraries/mobiledatadownload/internal/downloader/DownloaderCallbackImpl.java
new file mode 100644
index 0000000..897261d
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/downloader/DownloaderCallbackImpl.java
@@ -0,0 +1,451 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.downloader;
+
+import static com.google.common.util.concurrent.Futures.immediateFailedFuture;
+import static com.google.common.util.concurrent.Futures.immediateFuture;
+import static com.google.common.util.concurrent.Futures.immediateVoidFuture;
+
+import android.net.Uri;
+import android.os.Build.VERSION;
+import android.os.Build.VERSION_CODES;
+import androidx.annotation.VisibleForTesting;
+import com.google.android.libraries.mobiledatadownload.DownloadException;
+import com.google.android.libraries.mobiledatadownload.DownloadException.DownloadResultCode;
+import com.google.android.libraries.mobiledatadownload.Flags;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.openers.ReadStreamOpener;
+import com.google.android.libraries.mobiledatadownload.file.openers.RecursiveSizeOpener;
+import com.google.android.libraries.mobiledatadownload.file.openers.WriteStreamOpener;
+import com.google.android.libraries.mobiledatadownload.file.transforms.TransformProtos;
+import com.google.android.libraries.mobiledatadownload.internal.SharedFilesMetadata;
+import com.google.android.libraries.mobiledatadownload.internal.downloader.MddFileDownloader.DownloaderCallback;
+import com.google.android.libraries.mobiledatadownload.internal.logging.EventLogger;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil;
+import com.google.android.libraries.mobiledatadownload.internal.util.FileGroupUtil;
+import com.google.android.libraries.mobiledatadownload.tracing.PropagatedFluentFuture;
+import com.google.android.libraries.mobiledatadownload.tracing.PropagatedFutures;
+import com.google.common.io.ByteStreams;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFile;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal.AllowedReaders;
+import com.google.mobiledatadownload.internal.MetadataProto.FileStatus;
+import com.google.mobiledatadownload.internal.MetadataProto.GroupKey;
+import com.google.mobiledatadownload.internal.MetadataProto.NewFileKey;
+import com.google.mobiledatadownload.internal.MetadataProto.SharedFile;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.concurrent.Executor;
+
+/**
+ * Impl for {@link DownloaderCallback}, that is called by the file downloader on download complete
+ * or failed events
+ */
+public class DownloaderCallbackImpl implements DownloaderCallback {
+
+  private static final String TAG = "DownloaderCallbackImpl";
+
+  private final SharedFilesMetadata sharedFilesMetadata;
+  private final SynchronousFileStorage fileStorage;
+  private final DataFile dataFile;
+  private final AllowedReaders allowedReaders;
+  private final String checksum;
+  private final EventLogger eventLogger;
+  private final GroupKey groupKey;
+  private final int fileGroupVersionNumber;
+  private final long buildId;
+  private final String variantId;
+  private final Flags flags;
+  private final Executor sequentialControlExecutor;
+
+  public DownloaderCallbackImpl(
+      SharedFilesMetadata sharedFilesMetadata,
+      SynchronousFileStorage fileStorage,
+      DataFile dataFile,
+      AllowedReaders allowedReaders,
+      EventLogger eventLogger,
+      GroupKey groupKey,
+      int fileGroupVersionNumber,
+      long buildId,
+      String variantId,
+      Flags flags,
+      Executor sequentialControlExecutor) {
+    this.sharedFilesMetadata = sharedFilesMetadata;
+    this.fileStorage = fileStorage;
+    this.dataFile = dataFile;
+    this.allowedReaders = allowedReaders;
+    checksum = FileGroupUtil.getFileChecksum(dataFile);
+    this.eventLogger = eventLogger;
+    this.groupKey = groupKey;
+    this.fileGroupVersionNumber = fileGroupVersionNumber;
+    this.buildId = buildId;
+    this.variantId = variantId;
+    this.flags = flags;
+    this.sequentialControlExecutor = sequentialControlExecutor;
+  }
+
+  @Override
+  public ListenableFuture<Void> onDownloadComplete(Uri fileUri) {
+    LogUtil.d("%s: Successfully downloaded file %s", TAG, checksum);
+
+    // Use DownloadedFileChecksum to verify downloaded file integrity if the file has Download
+    // Transforms
+    String downloadedFileChecksum =
+        dataFile.hasDownloadTransforms()
+            ? dataFile.getDownloadedFileChecksum()
+            : dataFile.getChecksum();
+
+    try {
+      FileValidator.validateDownloadedFile(fileStorage, dataFile, fileUri, downloadedFileChecksum);
+
+      if (dataFile.hasDownloadTransforms()) {
+        handleDownloadTransform(fileUri);
+      }
+    } catch (DownloadException exception) {
+      if (exception
+          .getDownloadResultCode()
+          .equals(DownloadResultCode.DOWNLOADED_FILE_CHECKSUM_MISMATCH_ERROR)) {
+        // File was downloaded successfully, but failed checksum mismatch error. Attempt to delete
+        // the file, then fail with the given exception.
+        return PropagatedFluentFuture.from(
+                maybeDeleteFileOnChecksumMismatch(
+                    sharedFilesMetadata,
+                    dataFile,
+                    allowedReaders,
+                    fileStorage,
+                    fileUri,
+                    checksum,
+                    eventLogger,
+                    flags,
+                    sequentialControlExecutor))
+            .catchingAsync(
+                IOException.class,
+                ioException -> {
+                  // Delete on checksum failed, add it as a suppressed exception if supported (API
+                  // level 19 or higher).
+                  if (VERSION.SDK_INT >= VERSION_CODES.KITKAT) {
+                    exception.addSuppressed(ioException);
+                  }
+                  return immediateVoidFuture();
+                },
+                sequentialControlExecutor)
+            .transformAsync(unused -> immediateFailedFuture(exception), sequentialControlExecutor);
+      }
+      return immediateFailedFuture(exception);
+    }
+
+    return updateFileStatus(
+        FileStatus.DOWNLOAD_COMPLETE,
+        dataFile,
+        allowedReaders,
+        sharedFilesMetadata,
+        sequentialControlExecutor);
+  }
+
+  @Override
+  public ListenableFuture<Void> onDownloadFailed(DownloadException exception) {
+    LogUtil.d("%s: Failed to download file %s", TAG, checksum);
+    if (exception
+        .getDownloadResultCode()
+        .equals(DownloadResultCode.DOWNLOADED_FILE_CHECKSUM_MISMATCH_ERROR)) {
+      return updateFileStatus(
+          FileStatus.CORRUPTED,
+          dataFile,
+          allowedReaders,
+          sharedFilesMetadata,
+          sequentialControlExecutor);
+    }
+    return updateFileStatus(
+        FileStatus.DOWNLOAD_FAILED,
+        dataFile,
+        allowedReaders,
+        sharedFilesMetadata,
+        sequentialControlExecutor);
+  }
+
+  private void handleDownloadTransform(Uri downloadedFileUri) throws DownloadException {
+    if (!dataFile.hasDownloadTransforms()) {
+      return;
+    }
+    Uri finalFileUri = FileNameUtil.getFinalFileUriWithTempDownloadedFile(downloadedFileUri);
+    if (FileGroupUtil.hasZipDownloadTransform(dataFile)) {
+      applyZipDownloadTransforms(
+          eventLogger,
+          fileStorage,
+          downloadedFileUri,
+          finalFileUri,
+          groupKey,
+          fileGroupVersionNumber,
+          buildId,
+          variantId,
+          dataFile.getFileId());
+    } else {
+      handleNonZipDownloadTransform(downloadedFileUri, finalFileUri);
+    }
+  }
+
+  private void handleNonZipDownloadTransform(Uri downloadedFileUri, Uri finalFileUri)
+      throws DownloadException {
+    Uri downloadFileUriWithTransform;
+    try {
+      downloadFileUriWithTransform =
+          downloadedFileUri
+              .buildUpon()
+              .encodedFragment(TransformProtos.toEncodedFragment(dataFile.getDownloadTransforms()))
+              .build();
+    } catch (IllegalArgumentException e) {
+      LogUtil.e(e, "%s: Exception while trying to serialize download transform", TAG);
+      throw DownloadException.builder()
+          .setDownloadResultCode(DownloadResultCode.UNABLE_TO_SERIALIZE_DOWNLOAD_TRANSFORM_ERROR)
+          .setCause(e)
+          .build();
+    }
+    applyDownloadTransforms(
+        eventLogger,
+        fileStorage,
+        downloadFileUriWithTransform,
+        finalFileUri,
+        groupKey,
+        fileGroupVersionNumber,
+        buildId,
+        variantId,
+        dataFile);
+    // Verify original checksum if provided.
+    if (dataFile.getChecksumType() != DataFile.ChecksumType.NONE
+        && !FileValidator.verifyChecksum(fileStorage, finalFileUri, checksum)) {
+      LogUtil.e("%s: Final file checksum verification failed. %s.", TAG, finalFileUri);
+      throw DownloadException.builder()
+          .setDownloadResultCode(DownloadResultCode.FINAL_FILE_CHECKSUM_MISMATCH_ERROR)
+          .build();
+    }
+  }
+
+  @VisibleForTesting
+  static void applyDownloadTransforms(
+      EventLogger eventLogger,
+      SynchronousFileStorage fileStorage,
+      Uri source,
+      Uri target,
+      GroupKey groupKey,
+      int fileGroupVersionNumber,
+      long buildId,
+      String variantId,
+      DataFile dataFile)
+      throws DownloadException {
+
+    try (InputStream in = fileStorage.open(source, ReadStreamOpener.create());
+        OutputStream out = fileStorage.open(target, WriteStreamOpener.create())) {
+      ByteStreams.copy(in, out);
+    } catch (IOException ioe) {
+      LogUtil.e(ioe, "%s: Failed to apply download transform for file %s.", TAG, source);
+      throw DownloadException.builder()
+          .setDownloadResultCode(DownloadResultCode.DOWNLOAD_TRANSFORM_IO_ERROR)
+          .setCause(ioe)
+          .build();
+    }
+    try {
+      if (FileGroupUtil.hasCompressDownloadTransform(dataFile)) {
+        long fullFileSize = fileStorage.fileSize(target);
+        long downloadedFileSize = fileStorage.fileSize(source);
+        if (fullFileSize > downloadedFileSize) {
+          Void fileGroupStats = null;
+          eventLogger.logMddNetworkSavings(
+              fileGroupStats,
+              0,
+              fullFileSize,
+              downloadedFileSize,
+              dataFile.getFileId(),
+              /* deltaIndex = */ 0);
+        }
+      }
+      fileStorage.deleteFile(source);
+    } catch (IOException ioe) {
+      // Ignore if fails to log file size or delete the temp compress file, as it will eventually
+      // be garbage collected.
+      LogUtil.d(ioe, "%s: Failed to get file size or delete compress file %s.", TAG, source);
+    }
+  }
+
+  @VisibleForTesting
+  static void applyZipDownloadTransforms(
+      EventLogger eventLogger,
+      SynchronousFileStorage fileStorage,
+      Uri source,
+      Uri target,
+      GroupKey groupKey,
+      int fileGroupVersionNumber,
+      long buildId,
+      String variantId,
+      String fileId)
+      throws DownloadException {
+
+    try {
+      fileStorage.open(source, ZipFolderOpener.create(target));
+    } catch (IOException ioe) {
+      LogUtil.e(ioe, "%s: Failed to apply zip download transform for file %s.", TAG, source);
+      throw DownloadException.builder()
+          .setDownloadResultCode(DownloadResultCode.DOWNLOAD_TRANSFORM_IO_ERROR)
+          .setCause(ioe)
+          .build();
+    }
+    try {
+      Void fileGroupStats = null;
+      eventLogger.logMddNetworkSavings(
+          fileGroupStats,
+          0,
+          getFileOrDirectorySize(fileStorage, target),
+          fileStorage.fileSize(source),
+          fileId,
+          0);
+      // Delete the zip file only if unzip successfully to avoid re-download
+      fileStorage.deleteFile(source);
+    } catch (IOException ioe) {
+      // Ignore if fails to log file size or delete the temp zip file, as it will eventually be
+      // garbage collected.
+      LogUtil.d(ioe, "%s: Failed to get file size or delete zip file %s.", TAG, source);
+    }
+  }
+
+  private static long getFileOrDirectorySize(SynchronousFileStorage fileStorage, Uri uri)
+      throws IOException {
+    return fileStorage.open(uri, RecursiveSizeOpener.create());
+  }
+
+  /** Get {@link SharedFile} or fail with {@link DownloadException}. */
+  private static ListenableFuture<SharedFile> getSharedFileOrFail(
+      SharedFilesMetadata sharedFilesMetadata,
+      NewFileKey newFileKey,
+      Executor sequentialControlExecutor) {
+    return PropagatedFutures.transformAsync(
+        sharedFilesMetadata.read(newFileKey),
+        sharedFile -> {
+          // Cannot find the file metadata, fail to update the file status.
+          if (sharedFile == null) {
+            // TODO(b/131166925): MDD dump should not use lite proto toString.
+            LogUtil.e("%s: Shared file not found, newFileKey = %s", TAG, newFileKey);
+            return immediateFailedFuture(
+                DownloadException.builder()
+                    .setDownloadResultCode(DownloadResultCode.SHARED_FILE_NOT_FOUND_ERROR)
+                    .build());
+          }
+
+          return immediateFuture(sharedFile);
+        },
+        sequentialControlExecutor);
+  }
+
+  /**
+   * Maybe delete on-device file after a completed download when a checksum mismatch occurs.
+   *
+   * <p>When a checksum mismatch occurs after a completed download, it's possible that the data has
+   * been corrupted on-disk. In this event, we should delete the on-disk file so it can be
+   * redownloaded again in a non-corrupted state.
+   *
+   * <p>However, it's also possible that a bad config was sent with a wrong checksum. In this event,
+   * the on-disk file may not be corrupted, so deleting it could lead to an increase in network
+   * bandwidth usage.
+   *
+   * <p>In order to balance the two cases, MDD will start to delete the on-disk file, but after a
+   * certain number of retries, this deletion will be skipped to prevent unnecessary network
+   * bandwidth usage.
+   *
+   * <p>This future may return a failed future with an IOException if attempting to delete the file
+   * fails.
+   */
+  static ListenableFuture<Void> maybeDeleteFileOnChecksumMismatch(
+      SharedFilesMetadata sharedFilesMetadata,
+      DataFile dataFile,
+      AllowedReaders allowedReaders,
+      SynchronousFileStorage fileStorage,
+      Uri fileUri,
+      String checksum,
+      EventLogger eventLogger,
+      Flags flags,
+      Executor sequentialControlExecutor) {
+    NewFileKey newFileKey = SharedFilesMetadata.createKeyFromDataFile(dataFile, allowedReaders);
+    return PropagatedFluentFuture.from(
+            getSharedFileOrFail(sharedFilesMetadata, newFileKey, sequentialControlExecutor))
+        .transformAsync(
+            sharedFile -> {
+              if (sharedFile.getChecksumMismatchRetryDownloadCount()
+                  >= flags.downloaderMaxRetryOnChecksumMismatchCount()) {
+                LogUtil.d(
+                    "%s: Checksum mismatch detected but the has already reached retry limit!"
+                        + " Skipping removal for file %s",
+                    TAG, checksum);
+                eventLogger.logEventSampled(0);
+              } else {
+                LogUtil.d(
+                    "%s: Removing file and marking as corrupted due to checksum mismatch", TAG);
+                try {
+                  fileStorage.deleteFile(fileUri);
+                } catch (IOException e) {
+                  // Deleting the corrupted file is best effort, the next time MDD attempts to
+                  // download, we will try again to delete the file. For now, just log this error.
+                  LogUtil.e(e, "%s: Failed to remove corrupted file %s", TAG, checksum);
+                  return immediateFailedFuture(e);
+                }
+              }
+              return immediateVoidFuture();
+            },
+            sequentialControlExecutor);
+  }
+
+  /**
+   * Find the file metadata and update the file status. Throws {@link DownloadException} if the file
+   * status failed to be updated.
+   */
+  static ListenableFuture<Void> updateFileStatus(
+      FileStatus fileStatus,
+      DataFile dataFile,
+      AllowedReaders allowedReaders,
+      SharedFilesMetadata sharedFilesMetadata,
+      Executor sequentialControlExecutor) {
+    NewFileKey newFileKey = SharedFilesMetadata.createKeyFromDataFile(dataFile, allowedReaders);
+
+    return PropagatedFluentFuture.from(
+            getSharedFileOrFail(sharedFilesMetadata, newFileKey, sequentialControlExecutor))
+        .transformAsync(
+            sharedFile -> {
+              SharedFile.Builder sharedFileBuilder =
+                  sharedFile.toBuilder().setFileStatus(fileStatus);
+              if (fileStatus.equals(FileStatus.CORRUPTED)) {
+                // Corrupted state indicates a checksum mismatch failure, so increment the retry
+                // download count.
+                sharedFileBuilder.setChecksumMismatchRetryDownloadCount(
+                    sharedFile.getChecksumMismatchRetryDownloadCount() + 1);
+              }
+              return sharedFilesMetadata.write(newFileKey, sharedFileBuilder.build());
+            },
+            sequentialControlExecutor)
+        .transformAsync(
+            writeSuccess -> {
+              if (!writeSuccess) {
+                // TODO(b/131166925): MDD dump should not use lite proto toString.
+                LogUtil.e(
+                    "%s: Unable to write back download info for file entry with %s",
+                    TAG, newFileKey);
+                return immediateFailedFuture(
+                    DownloadException.builder()
+                        .setDownloadResultCode(DownloadResultCode.UNABLE_TO_UPDATE_FILE_STATE_ERROR)
+                        .build());
+              }
+              return immediateVoidFuture();
+            },
+            sequentialControlExecutor);
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/downloader/FileNameUtil.java b/java/com/google/android/libraries/mobiledatadownload/internal/downloader/FileNameUtil.java
new file mode 100644
index 0000000..b06570a
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/downloader/FileNameUtil.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.downloader;
+
+import android.net.Uri;
+
+/** Delta file name utility class. */
+public class FileNameUtil {
+
+  public static final String NAME_SEPARATOR = "_";
+
+  /**
+   * For cases that the downloaded file is different than the final file, generate a temporary file
+   * for download and name it with a suffix of the downloaded file checksum to avoid naming
+   * conflicts.
+   */
+  public static String getTempFileNameWithDownloadedFileChecksum(String fileName, String checksum) {
+    return fileName + NAME_SEPARATOR + checksum;
+  }
+
+  /** Get the final file name by removing the temporary downloaded file suffix as "_checksum". */
+  public static Uri getFinalFileUriWithTempDownloadedFile(Uri downloadedFileUri) {
+    String serializedDeltaFileUri = downloadedFileUri.toString();
+    return Uri.parse(
+        serializedDeltaFileUri.substring(0, serializedDeltaFileUri.lastIndexOf(NAME_SEPARATOR)));
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/downloader/FileValidator.java b/java/com/google/android/libraries/mobiledatadownload/internal/downloader/FileValidator.java
new file mode 100644
index 0000000..1954073
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/downloader/FileValidator.java
@@ -0,0 +1,179 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.downloader;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.DownloadException;
+import com.google.android.libraries.mobiledatadownload.DownloadException.DownloadResultCode;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.openers.ReadStreamOpener;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil;
+import com.google.android.libraries.mobiledatadownload.internal.util.FileGroupUtil;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFile;
+import java.io.IOException;
+import java.io.InputStream;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import javax.annotation.Nullable;
+
+/** Util class that validate the downloaded file. */
+public final class FileValidator {
+  private static final String TAG = "FileValidator";
+
+  private static final char[] HEX_LOWERCASE = {
+    '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'
+  };
+
+  // <internal>
+  private FileValidator() {}
+
+  /**
+   * Returns if the file checksum verification passes.
+   *
+   * @param fileUri - the uri of the file to calculate a sha1 hash for.
+   * @param fileChecksum - the expected file checksum
+   */
+  public static boolean verifyChecksum(
+      SynchronousFileStorage fileStorage, Uri fileUri, String fileChecksum) {
+    String digest = FileValidator.computeSha1Digest(fileStorage, fileUri);
+    return digest.equals(fileChecksum);
+  }
+
+  /**
+   * Returns sha1 hash of file, empty string if unable to read file.
+   *
+   * @param uri - the uri of the file to calculate a sha1 hash for.
+   */
+  // TODO(b/139472295): convert this to a MobStore Opener.
+  public static String computeSha1Digest(SynchronousFileStorage fileStorage, Uri uri) {
+    try (InputStream inputStream = fileStorage.open(uri, ReadStreamOpener.create())) {
+      return computeDigest(inputStream, "SHA1");
+    } catch (IOException e) {
+      // TODO(b/118137672): reconsider on the swallowed exception.
+      LogUtil.e("%s: Failed to open file, uri = %s", TAG, uri);
+      return "";
+    }
+  }
+
+  /** Compute the SHA1 of the input string. */
+  public static String computeSha1Digest(String input) {
+    MessageDigest messageDigest = getMessageDigest("SHA1");
+    if (messageDigest == null) {
+      return "";
+    }
+
+    byte[] bytes = input.getBytes();
+    messageDigest.update(bytes, 0, bytes.length);
+    return bytesToStringLowercase(messageDigest.digest());
+  }
+
+  /**
+   * Returns sha256 hash of file, empty string if unable to read file.
+   *
+   * @param uri - the uri of the file to calculate a sha256 hash for.
+   */
+  public static String computeSha256Digest(SynchronousFileStorage fileStorage, Uri uri) {
+    try (InputStream inputStream = fileStorage.open(uri, ReadStreamOpener.create())) {
+      return computeDigest(inputStream, "SHA-256");
+    } catch (IOException e) {
+      // TODO(b/118137672): reconsider on the swallowed exception.
+      LogUtil.e("%s: Failed to open file, uri = %s", TAG, uri);
+      return "";
+    }
+  }
+
+  // Caller is responsible for opening and closing stream.
+  private static String computeDigest(InputStream inputStream, String algorithm)
+      throws IOException {
+    MessageDigest messageDigest = getMessageDigest(algorithm);
+    if (messageDigest == null) {
+      return "";
+    }
+
+    byte[] bytes = new byte[8192];
+
+    int byteCount = inputStream.read(bytes);
+    while (byteCount != -1) {
+      messageDigest.update(bytes, 0, byteCount);
+      byteCount = inputStream.read(bytes);
+    }
+    return bytesToStringLowercase(messageDigest.digest());
+  }
+
+  /**
+   * Throws {@link DownloadException} if the downloaded file doesn't exist, or the SHA1 hash of the
+   * file checksum doesn't match.
+   */
+  public static void validateDownloadedFile(
+      SynchronousFileStorage fileStorage, DataFile dataFile, Uri fileUri, String checksum)
+      throws DownloadException {
+    try {
+      if (!fileStorage.exists(fileUri)) {
+        LogUtil.e(
+            "%s: Downloaded file %s is not present at %s",
+            TAG, FileGroupUtil.getFileChecksum(dataFile), fileUri);
+        throw DownloadException.builder()
+            .setDownloadResultCode(DownloadResultCode.DOWNLOADED_FILE_NOT_FOUND_ERROR)
+            .build();
+      }
+      if (dataFile.getChecksumType() == DataFile.ChecksumType.NONE) {
+        return;
+      }
+      if (!verifyChecksum(fileStorage, fileUri, checksum)) {
+        LogUtil.e(
+            "%s: Downloaded file at uri = %s, checksum = %s verification failed",
+            TAG, fileUri, checksum);
+        throw DownloadException.builder()
+            .setDownloadResultCode(DownloadResultCode.DOWNLOADED_FILE_CHECKSUM_MISMATCH_ERROR)
+            .build();
+      }
+    } catch (IOException e) {
+      LogUtil.e(
+          e,
+          "%s: Failed to validate download file %s",
+          TAG,
+          FileGroupUtil.getFileChecksum(dataFile));
+      throw DownloadException.builder()
+          .setDownloadResultCode(DownloadResultCode.UNABLE_TO_VALIDATE_DOWNLOAD_FILE_ERROR)
+          .setCause(e)
+          .build();
+    }
+  }
+
+  @Nullable
+  private static MessageDigest getMessageDigest(String hashAlgorithm) {
+    try {
+      MessageDigest messageDigest = MessageDigest.getInstance(hashAlgorithm);
+      if (messageDigest != null) {
+        return messageDigest;
+      }
+    } catch (NoSuchAlgorithmException e) {
+      // Do nothing.
+    }
+    return null;
+  }
+
+  private static String bytesToStringLowercase(byte[] bytes) {
+    char[] hexChars = new char[bytes.length * 2];
+    int j = 0;
+    for (int i = 0; i < bytes.length; i++) {
+      int v = bytes[i] & 0xFF;
+      hexChars[j++] = HEX_LOWERCASE[v >>> 4];
+      hexChars[j++] = HEX_LOWERCASE[v & 0x0F];
+    }
+    return new String(hexChars);
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/downloader/MddFileDownloader.java b/java/com/google/android/libraries/mobiledatadownload/internal/downloader/MddFileDownloader.java
new file mode 100644
index 0000000..b1de88b
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/downloader/MddFileDownloader.java
@@ -0,0 +1,433 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.downloader;
+
+import static java.lang.Math.min;
+
+import android.content.Context;
+import android.net.Uri;
+import android.os.StatFs;
+import android.util.Pair;
+import androidx.annotation.VisibleForTesting;
+import com.google.android.libraries.mobiledatadownload.DownloadException;
+import com.google.android.libraries.mobiledatadownload.DownloadException.DownloadResultCode;
+import com.google.android.libraries.mobiledatadownload.FileSource;
+import com.google.android.libraries.mobiledatadownload.Flags;
+import com.google.android.libraries.mobiledatadownload.downloader.DownloadConstraints;
+import com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest;
+import com.google.android.libraries.mobiledatadownload.downloader.FileDownloader;
+import com.google.android.libraries.mobiledatadownload.downloader.InlineDownloadParams;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.internal.ApplicationContext;
+import com.google.android.libraries.mobiledatadownload.internal.annotations.SequentialControlExecutor;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LoggingStateStore;
+import com.google.android.libraries.mobiledatadownload.monitor.DownloadProgressMonitor;
+import com.google.android.libraries.mobiledatadownload.monitor.NetworkUsageMonitor;
+import com.google.common.base.Optional;
+import com.google.common.base.Supplier;
+import com.google.common.collect.ImmutableList;
+import com.google.common.util.concurrent.FluentFuture;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.mobiledatadownload.internal.MetadataProto.DownloadConditions;
+import com.google.mobiledatadownload.internal.MetadataProto.DownloadConditions.DeviceNetworkPolicy;
+import com.google.mobiledatadownload.internal.MetadataProto.ExtraHttpHeader;
+import com.google.mobiledatadownload.internal.MetadataProto.GroupKey;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.concurrent.Executor;
+import javax.annotation.Nullable;
+import javax.inject.Inject;
+
+/**
+ * Responsible for downloading files in MDD.
+ *
+ * <p>Provides methods to start and stop downloading a file. The stop method can be called if the
+ * file is no longer needed, or the file was already downloaded to the device.
+ *
+ * <p>This class supports both standard downloads (over https) or inline files (from a ByteString),
+ * using {@link #startDownloading} and {@link #startCopying}, respectively.
+ */
+// TODO(b/129497867): Add tracking for on-going download to dedup download request from
+// FileDownloader.
+public class MddFileDownloader {
+
+  private static final String TAG = "MddFileDownloader";
+
+  // These should only be accessed through the getters and never directly.
+  private final Context context;
+  private final Supplier<FileDownloader> fileDownloaderSupplier;
+  private final SynchronousFileStorage fileStorage;
+  private final NetworkUsageMonitor networkUsageMonitor;
+  private final Optional<DownloadProgressMonitor> downloadMonitorOptional;
+  private final LoggingStateStore loggingStateStore;
+  private final Executor sequentialControlExecutor;
+  private final Flags flags;
+
+  // Cache for all on-going downloads. This will be used to de-dup download requests.
+  // NOTE: currently we assume that this map will only be accessed through the
+  // SequentialControlExecutor, so we don't need synchronization here.
+  @VisibleForTesting
+  final HashMap<Uri, ListenableFuture<Void>> fileUriToDownloadFutureMap = new HashMap<>();
+
+  @Inject
+  public MddFileDownloader(
+      @ApplicationContext Context context,
+      Supplier<FileDownloader> fileDownloaderSupplier,
+      SynchronousFileStorage fileStorage,
+      NetworkUsageMonitor networkUsageMonitor,
+      Optional<DownloadProgressMonitor> downloadMonitor,
+      LoggingStateStore loggingStateStore,
+      @SequentialControlExecutor Executor sequentialControlExecutor,
+      Flags flags) {
+    this.context = context;
+    this.fileDownloaderSupplier = fileDownloaderSupplier;
+    this.fileStorage = fileStorage;
+    this.networkUsageMonitor = networkUsageMonitor;
+    this.downloadMonitorOptional = downloadMonitor;
+    this.loggingStateStore = loggingStateStore;
+    this.sequentialControlExecutor = sequentialControlExecutor;
+    this.flags = flags;
+  }
+
+  /**
+   * Start downloading the file.
+   *
+   * @param groupKey GroupKey that contains the file to download.
+   * @param fileGroupVersionNumber version number of the group that contains the file to download.
+   * @param buildId build id of the group that contains the file to download.
+   * @param fileUri - the File Uri to download the file at.
+   * @param urlToDownload - The url of the file to download.
+   * @param fileSize - the expected size of the file to download.
+   * @param downloadConditions - conditions under which this file should be downloaded.
+   * @param callback - callback called when the download either completes or fails.
+   * @param trafficTag - Tag for the network traffic to download this dataFile.
+   * @param extraHttpHeaders - Extra Headers for this request.
+   * @return - ListenableFuture representing the download result of a file.
+   */
+  public ListenableFuture<Void> startDownloading(
+      GroupKey groupKey,
+      int fileGroupVersionNumber,
+      long buildId,
+      Uri fileUri,
+      String urlToDownload,
+      int fileSize,
+      @Nullable DownloadConditions downloadConditions,
+      DownloaderCallback callback,
+      int trafficTag,
+      List<ExtraHttpHeader> extraHttpHeaders) {
+    if (fileUriToDownloadFutureMap.containsKey(fileUri)) {
+      return fileUriToDownloadFutureMap.get(fileUri);
+    }
+    return addCallbackAndRegister(
+        fileUri,
+        callback,
+        startDownloadingInternal(
+            groupKey,
+            fileGroupVersionNumber,
+            buildId,
+            fileUri,
+            urlToDownload,
+            fileSize,
+            downloadConditions,
+            trafficTag,
+            extraHttpHeaders));
+  }
+
+  /**
+   * Adds Callback to given Future and Registers future in in-progress cache.
+   *
+   * <p>Contains shared logic of connecting {@code callback} to {@code downloadOrCopyFuture} and
+   * registers future in the internal in-progress cache. This cache allows similar download/copy
+   * requests to be deduped instead of being performed twice.
+   *
+   * <p>NOTE: this method assumes the cache has already been checked for an in-progress operation
+   * and no in-progress operation exists for {@code fileUri}.
+   *
+   * @param fileUri the destination of the download/copy (used as Key in in-progress cache)
+   * @param callback the callback that should be run after the given download/copy future
+   * @param downloadOrCopyFuture a ListenableFuture that will perform the download/copy
+   * @return a ListenableFuture that calls the correct callback after {@code downloadOrCopyFuture
+   *     completes}
+   */
+  private ListenableFuture<Void> addCallbackAndRegister(
+      Uri fileUri, DownloaderCallback callback, ListenableFuture<Void> downloadOrCopyFuture) {
+    // Use transform & catching to ensure that we correctly chain everything.
+    FluentFuture<Void> transformedFuture =
+        FluentFuture.from(downloadOrCopyFuture)
+            .transformAsync(
+                voidArg -> callback.onDownloadComplete(fileUri),
+                sequentialControlExecutor /*Run callbacks on @SequentialControlExecutor*/)
+            .catchingAsync(
+                DownloadException.class,
+                e ->
+                    Futures.transformAsync(
+                        callback.onDownloadFailed(e),
+                        voidArg -> {
+                          throw e;
+                        },
+                        sequentialControlExecutor),
+                sequentialControlExecutor /*Run callbacks on @SequentialControlExecutor*/);
+
+    fileUriToDownloadFutureMap.put(fileUri, transformedFuture);
+
+    // We want to remove the transformedFuture from the cache when the transformedFuture finishes.
+    // However there may be a race condition and transformedFuture may finish before we put it into
+    // the cache.
+    // To prevent this race condition, we add a callback to transformedFuture to make sure the
+    // removal happens after the putting it in the map.
+    // A transform would not work since we want to run the removal even when the transform failed.
+    transformedFuture.addListener(
+        () -> fileUriToDownloadFutureMap.remove(fileUri), sequentialControlExecutor);
+
+    return transformedFuture;
+  }
+
+  private ListenableFuture<Void> startDownloadingInternal(
+      GroupKey groupKey,
+      int fileGroupVersionNumber,
+      long buildId,
+      Uri fileUri,
+      String urlToDownload,
+      int fileSize,
+      @Nullable DownloadConditions downloadConditions,
+      int trafficTag,
+      List<ExtraHttpHeader> extraHttpHeaders) {
+    if (urlToDownload.startsWith("http")
+        && flags.downloaderEnforceHttps()
+        && !urlToDownload.startsWith("https")) {
+      LogUtil.e("%s: File url = %s is not secure", TAG, urlToDownload);
+      return Futures.immediateFailedFuture(
+          DownloadException.builder()
+              .setDownloadResultCode(DownloadResultCode.INSECURE_URL_ERROR)
+              .build());
+    }
+
+    long currentFileSize = 0;
+    try {
+      currentFileSize = fileStorage.fileSize(fileUri);
+    } catch (IOException e) {
+      // Proceed with 0 as the current file size. It is only used for deciding whether we should
+      // download the file or not.
+    }
+
+    try {
+      checkStorageConstraints(context, fileSize - currentFileSize, downloadConditions, flags);
+    } catch (DownloadException e) {
+      // Wrap exception in future to break future chain.
+      LogUtil.e("%s: Not enough space to download file %s", TAG, urlToDownload);
+      return Futures.immediateFailedFuture(e);
+    }
+
+    if (flags.logNetworkStats()) {
+      networkUsageMonitor.monitorUri(
+          fileUri, groupKey, buildId, fileGroupVersionNumber, loggingStateStore);
+    } else {
+      LogUtil.w("%s: NetworkUsageMonitor is disabled", TAG);
+    }
+
+    if (downloadMonitorOptional.isPresent()) {
+      downloadMonitorOptional.get().monitorUri(fileUri, groupKey.getGroupName());
+    }
+
+    DownloadRequest.Builder downloadRequestBuilder =
+        DownloadRequest.newBuilder().setFileUri(fileUri).setUrlToDownload(urlToDownload);
+
+    // TODO: consider to do this conversion upstream and we can pass in the
+    //  DownloadConstraints.
+    if (downloadConditions != null
+        && downloadConditions.getDeviceNetworkPolicy()
+            == DeviceNetworkPolicy.DOWNLOAD_ON_ANY_NETWORK) {
+      downloadRequestBuilder.setDownloadConstraints(DownloadConstraints.NETWORK_CONNECTED);
+    } else {
+      downloadRequestBuilder.setDownloadConstraints(DownloadConstraints.NETWORK_UNMETERED);
+    }
+
+    if (trafficTag > 0) {
+      downloadRequestBuilder.setTrafficTag(trafficTag);
+    }
+
+    ImmutableList.Builder<Pair<String, String>> headerBuilder = ImmutableList.builder();
+    for (ExtraHttpHeader header : extraHttpHeaders) {
+      headerBuilder.add(Pair.create(header.getKey(), header.getValue()));
+    }
+
+    downloadRequestBuilder.setExtraHttpHeaders(headerBuilder.build());
+
+    return fileDownloaderSupplier.get().startDownloading(downloadRequestBuilder.build());
+  }
+
+  /**
+   * Start Copying a file to internal storage
+   *
+   * @param fileUri the File Uri where content should be copied.
+   * @param urlToDownload the url to copy, should be inlinefile: scheme.
+   * @param fileSize the size of the file to copy.
+   * @param downloadConditions conditions under which this file should be copied.
+   * @param downloaderCallback callback called when the copy either completes or fails.
+   * @param inlineFileSource Source of file content to copy.
+   * @return ListenableFuture representing the result of a file copy.
+   */
+  public ListenableFuture<Void> startCopying(
+      Uri fileUri,
+      String urlToDownload,
+      int fileSize,
+      @Nullable DownloadConditions downloadConditions,
+      DownloaderCallback downloaderCallback,
+      FileSource inlineFileSource) {
+    if (fileUriToDownloadFutureMap.containsKey(fileUri)) {
+      return fileUriToDownloadFutureMap.get(fileUri);
+    }
+    return addCallbackAndRegister(
+        fileUri,
+        downloaderCallback,
+        startCopyingInternal(
+            fileUri, urlToDownload, fileSize, downloadConditions, inlineFileSource));
+  }
+
+  private ListenableFuture<Void> startCopyingInternal(
+      Uri fileUri,
+      String urlToCopy,
+      int fileSize,
+      @Nullable DownloadConditions downloadConditions,
+      FileSource inlineFileSource) {
+
+    try {
+      checkStorageConstraints(context, fileSize, downloadConditions, flags);
+    } catch (DownloadException e) {
+      // Wrap exception in future to break future chain.
+      LogUtil.e("%s: Not enough space to download file %s", TAG, urlToCopy);
+      return Futures.immediateFailedFuture(e);
+    }
+
+    // TODO(b/177361344): Only monitor file if download listener is supported
+
+    DownloadRequest downloadRequest =
+        DownloadRequest.newBuilder()
+            .setUrlToDownload(urlToCopy)
+            .setFileUri(fileUri)
+            .setInlineDownloadParamsOptional(
+                InlineDownloadParams.newBuilder().setInlineFileContent(inlineFileSource).build())
+            .build();
+
+    // Use file download supplier to perform inline file download
+    return fileDownloaderSupplier.get().startDownloading(downloadRequest);
+  }
+
+  /**
+   * Stop downloading the file.
+   *
+   * @param fileUri - the File Uri of the file to stop downloading.
+   */
+  public void stopDownloading(Uri fileUri) {
+    ListenableFuture<Void> pendingDownloadFuture = fileUriToDownloadFutureMap.get(fileUri);
+    if (pendingDownloadFuture != null) {
+      LogUtil.d("%s: Cancel download file %s", TAG, fileUri);
+      fileUriToDownloadFutureMap.remove(fileUri);
+      pendingDownloadFuture.cancel(true);
+    } else {
+      LogUtil.w("%s: stopDownloading on non-existent download", TAG);
+    }
+  }
+
+  /**
+   * Checks if storage constraints are enabled and if so, performs storage check.
+   *
+   * <p>If low storage enforcement is enabled, this method will check if a file with {@code
+   * bytesNeeded} can be stored on disk without hitting the storage threshold defined in {@code
+   * downloadConditions}.
+   *
+   * <p>If low storage enforcement is not enabled, this method is a no-op.
+   *
+   * <p>If {@code bytesNeeded} does hit the given storage threshold, a {@link DownloadException}
+   * will be thrown with the {@code DownloadResultCode.LOW_DISK_ERROR} error code.
+   *
+   * @param context Context in which storage should be checked
+   * @param bytesNeeded expected size of the file to store on disk
+   * @param downloadConditions conditions that contain the type of storage threshold to check
+   * @throws DownloadException when storing a file with the given size would hit the given storage
+   *     thresholds
+   */
+  public static void checkStorageConstraints(
+      Context context,
+      long bytesNeeded,
+      @Nullable DownloadConditions downloadConditions,
+      Flags flags)
+      throws DownloadException {
+    if (flags.enforceLowStorageBehavior()
+        && !shouldDownload(context, bytesNeeded, downloadConditions, flags)) {
+      throw DownloadException.builder()
+          .setDownloadResultCode(DownloadResultCode.LOW_DISK_ERROR)
+          .build();
+    }
+  }
+
+  /**
+   * This calculates if the file should be downloaded. It checks that after download you have at
+   * least a certain fraction of free space or an absolute minimum space still available.
+   *
+   * <p>This is in parity with what the DownloadApi does- <internal>
+   */
+  private static boolean shouldDownload(
+      Context context,
+      long bytesNeeded,
+      @Nullable DownloadConditions downloadConditions,
+      Flags flags) {
+    StatFs stats = new StatFs(context.getFilesDir().getAbsolutePath());
+
+    long totalBytes = (long) stats.getBlockCount() * stats.getBlockSize();
+    long freeBytes = (long) stats.getAvailableBlocks() * stats.getBlockSize();
+
+    double remainingBytesAfterDownload = freeBytes - bytesNeeded;
+
+    double minBytes =
+        min(totalBytes * flags.fractionFreeSpaceAfterDownload(), flags.absFreeSpaceAfterDownload());
+
+    if (downloadConditions != null) {
+      switch (downloadConditions.getDeviceStoragePolicy()) {
+        case BLOCK_DOWNLOAD_LOWER_THRESHOLD:
+          minBytes =
+              min(
+                  totalBytes * flags.fractionFreeSpaceAfterDownload(),
+                  flags.absFreeSpaceAfterDownloadLowStorageAllowed());
+          break;
+
+        case EXTREMELY_LOW_THRESHOLD:
+          minBytes =
+              min(
+                  totalBytes * flags.fractionFreeSpaceAfterDownload(),
+                  flags.absFreeSpaceAfterDownloadExtremelyLowStorageAllowed());
+          break;
+        default:
+          // fallthrough.
+      }
+    }
+
+    return remainingBytesAfterDownload > minBytes;
+  }
+
+  /** Interface called by the downloader when download either completes or fails. */
+  public static interface DownloaderCallback {
+    /** Called on download complete. */
+    // TODO(b/123424546): Consider to drop fileUri.
+    ListenableFuture<Void> onDownloadComplete(Uri fileUri);
+
+    /** Called on download failed. */
+    ListenableFuture<Void> onDownloadFailed(DownloadException exception);
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/downloader/ZipFolderOpener.java b/java/com/google/android/libraries/mobiledatadownload/internal/downloader/ZipFolderOpener.java
new file mode 100644
index 0000000..5ca21ea
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/downloader/ZipFolderOpener.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.downloader;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.OpenContext;
+import com.google.android.libraries.mobiledatadownload.file.Opener;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.openers.ReadStreamOpener;
+import com.google.android.libraries.mobiledatadownload.file.openers.WriteStreamOpener;
+import com.google.common.io.ByteStreams;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipException;
+import java.util.zip.ZipInputStream;
+
+/**
+ * An opener takes in an output folder URI and expands all resources in the zip input stream to the
+ * folder.
+ */
+public final class ZipFolderOpener implements Opener<Void> {
+
+  private final Uri targetFolderUri;
+  private final SaferZipUtils zipUtils;
+
+  private ZipFolderOpener(Uri targetFolderUri) {
+    this.targetFolderUri = targetFolderUri;
+    this.zipUtils = new SaferZipUtils() {};
+  }
+
+  public static ZipFolderOpener create(Uri targetFolderUri) {
+    return new ZipFolderOpener(targetFolderUri);
+  }
+
+  @Override
+  public Void open(OpenContext openContext) throws IOException {
+    SynchronousFileStorage fileStorage = openContext.storage();
+    try (ZipInputStream zipInputStream =
+        new ZipInputStream(ReadStreamOpener.create().withBufferedIo().open(openContext))) {
+      // Iterate all entries and write to target URI one by one
+      ZipEntry zipEntry;
+      while ((zipEntry = zipInputStream.getNextEntry()) != null) {
+        String path = zipUtils.getValidatedName(zipEntry);
+        Uri uri = targetFolderUri.buildUpon().appendPath(path).build();
+        if (zipEntry.isDirectory()) {
+          fileStorage.createDirectory(uri);
+        } else {
+          try (OutputStream out = fileStorage.open(uri, WriteStreamOpener.create())) {
+            ByteStreams.copy(zipInputStream, out);
+          }
+        }
+      }
+    } catch (IOException ioe) {
+      // Cleanup the target directory if any error occurred.
+      fileStorage.deleteRecursively(targetFolderUri);
+      throw ioe;
+    }
+    return null;
+  }
+
+  /** Utilities for safely accessing ZipEntry APIs. */
+  private interface SaferZipUtils {
+    /**
+     * Return the name of a ZipEntry after verifying that it does not exploit any path traversal
+     * attacks.
+     *
+     * @throws ZipException if {@code zipEntry} contains any possible path traversal characters.
+     */
+    default String getValidatedName(ZipEntry entry) throws ZipException {
+      return entry.getName();
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/experimentation/BUILD b/java/com/google/android/libraries/mobiledatadownload/internal/experimentation/BUILD
new file mode 100644
index 0000000..ffa6fc9
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/experimentation/BUILD
@@ -0,0 +1,42 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_library")
+
+package(
+    default_visibility = [
+        "//visibility:public",
+    ],
+    licenses = ["notice"],
+)
+
+android_library(
+    name = "DownloadStageManager",
+    srcs = ["DownloadStageManager.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "@com_google_errorprone_error_prone_annotations",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "NoOpDownloadStageManager",
+    srcs = ["NoOpDownloadStageManager.java"],
+    deps = [
+        ":DownloadStageManager",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "@com_google_errorprone_error_prone_annotations",
+        "@com_google_guava_guava",
+    ],
+)
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/experimentation/DownloadStageManager.java b/java/com/google/android/libraries/mobiledatadownload/internal/experimentation/DownloadStageManager.java
new file mode 100644
index 0000000..0b91472
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/experimentation/DownloadStageManager.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.experimentation;
+
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.errorprone.annotations.CheckReturnValue;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal;
+import java.util.Collection;
+
+/** Responsible for attaching external experiment ids to log sources. */
+@CheckReturnValue
+public interface DownloadStageManager {
+
+  /**
+   * Clear all set experiment ids from phenotype.
+   *
+   * <p>Note: this must be called before any metadata is cleared since this reads from metadata to
+   * learn which builds to clear.
+   */
+  ListenableFuture<Void> clearAll();
+
+  /**
+   * For each file group: if there are no active versions of the build, all experiment ids are
+   * removed from phenotype. If there are active versions of the build (which can happen if there
+   * are multiple variants/accounts), this will update the experiment ids to reflect the current
+   * state given that an instance of the build was removed.
+   *
+   * @param fileGroupsToClear the file groups to remove experiment ids
+   * @return a future signalling completion of the task
+   */
+  ListenableFuture<Void> clearExperimentIdsForBuildsIfNoneActive(
+      Collection<DataFileGroupInternal> fileGroupsToClear);
+
+  /**
+   * Propagates the experiment ids for {@code groupName} to phenotype. If there are multiple active
+   * builds with the given name, all experiment ids will be propagated.
+   *
+   * <p>Any failures encountered will be propagated to the returned future.
+   */
+  ListenableFuture<Void> updateExperimentIds(String groupName);
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/experimentation/NoOpDownloadStageManager.java b/java/com/google/android/libraries/mobiledatadownload/internal/experimentation/NoOpDownloadStageManager.java
new file mode 100644
index 0000000..b4553e1
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/experimentation/NoOpDownloadStageManager.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.experimentation;
+
+import static com.google.common.util.concurrent.Futures.immediateVoidFuture;
+
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.errorprone.annotations.CheckReturnValue;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal;
+import java.util.Collection;
+
+/** Implementation of DownloadStageManager that does nothing. */
+@CheckReturnValue
+public final class NoOpDownloadStageManager implements DownloadStageManager {
+
+  /** Clear all set experiment ids from phenotype. */
+  @Override
+  public ListenableFuture<Void> clearAll() {
+    return immediateVoidFuture();
+  }
+
+  @Override
+  public ListenableFuture<Void> clearExperimentIdsForBuildsIfNoneActive(
+      Collection<DataFileGroupInternal> fileGroupsToClear) {
+    return immediateVoidFuture();
+  }
+
+  /**
+   * Propagates the experiment ids for {@code groupName} to phenotype. If there are multiple active
+   * builds with the given name, all experiment ids will be propagated.
+   *
+   * <p>Any failures encountered will be propagated to the returned future.
+   */
+  @Override
+  public ListenableFuture<Void> updateExperimentIds(String groupName) {
+    return immediateVoidFuture();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/logging/BUILD b/java/com/google/android/libraries/mobiledatadownload/internal/logging/BUILD
new file mode 100644
index 0000000..8ea9550
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/logging/BUILD
@@ -0,0 +1,168 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_library")
+
+package(
+    default_visibility = ["//:__subpackages__"],
+    licenses = ["notice"],
+)
+
+android_library(
+    name = "LogUtil",
+    srcs = ["LogUtil.java"],
+    deps = [
+        "@com_google_code_findbugs_jsr305",
+        "@com_google_errorprone_error_prone_annotations",
+    ],
+)
+
+android_library(
+    name = "EventLogger",
+    srcs = ["EventLogger.java"],
+    deps = [
+        "@com_google_auto_value",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "NoOpEventLogger",
+    srcs = ["NoOpEventLogger.java"],
+    deps = [
+        ":EventLogger",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "FileGroupStatsLogger",
+    srcs = ["FileGroupStatsLogger.java"],
+    deps = [
+        ":EventLogger",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:FileGroupManager",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:FileGroupsMetadata",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/annotations:SequentialControlExecutor",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "//java/com/google/android/libraries/mobiledatadownload/tracing:concurrent",
+        "@com_google_guava_guava",
+        "@javax_inject",
+    ],
+)
+
+android_library(
+    name = "DownloadStateLogger",
+    srcs = [
+        "DownloadStateLogger.java",
+    ],
+    deps = [
+        ":EventLogger",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "@androidx_annotation_annotation",
+        "@com_google_errorprone_error_prone_annotations",
+    ],
+)
+
+android_library(
+    name = "MddEventLogger",
+    srcs = [
+        "MddEventLogger.java",
+    ],
+    deps = [
+        ":EventLogger",
+        ":LogSampler",
+        ":LogUtil",
+        ":LoggingStateStore",
+        "//java/com/google/android/libraries/mobiledatadownload:Flags",
+        "//java/com/google/android/libraries/mobiledatadownload:Logger",
+        "//java/com/google/android/libraries/mobiledatadownload/tracing:concurrent",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "StorageLogger",
+    srcs = ["StorageLogger.java"],
+    deps = [
+        ":EventLogger",
+        ":LogUtil",
+        "//java/com/google/android/libraries/mobiledatadownload:SilentFeedback",
+        "//java/com/google/android/libraries/mobiledatadownload/annotations",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:ApplicationContext",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:FileGroupsMetadata",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:MddConstants",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:MddExceptions",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:SharedFileManager",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:SharedFilesMetadata",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/annotations:SequentialControlExecutor",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:FileGroupUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/tracing:concurrent",
+        "@com_google_auto_value",
+        "@com_google_guava_guava",
+        "@javax_inject",
+    ],
+)
+
+android_library(
+    name = "NetworkLogger",
+    srcs = ["NetworkLogger.java"],
+    deps = [
+        ":EventLogger",
+        ":LoggingStateStore",
+        "//java/com/google/android/libraries/mobiledatadownload:Flags",
+        "//java/com/google/android/libraries/mobiledatadownload/annotations",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:ApplicationContext",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "//java/com/google/android/libraries/mobiledatadownload/tracing:concurrent",
+        "@com_google_guava_guava",
+        "@javax_inject",
+    ],
+)
+
+android_library(
+    name = "LoggingStateStore",
+    srcs = [
+        "LoggingStateStore.java",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "LogSampler",
+    srcs = ["LogSampler.java"],
+    deps = [
+        ":LogUtil",
+        ":LoggingStateStore",
+        "//java/com/google/android/libraries/mobiledatadownload:Flags",
+        "//java/com/google/android/libraries/mobiledatadownload/tracing:concurrent",
+        "@com_google_errorprone_error_prone_annotations",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "NoOpLoggingState",
+    srcs = [
+        "NoOpLoggingState.java",
+    ],
+    deps = [
+        ":LoggingStateStore",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "@com_google_guava_guava",
+    ],
+)
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/logging/DownloadStateLogger.java b/java/com/google/android/libraries/mobiledatadownload/internal/logging/DownloadStateLogger.java
new file mode 100644
index 0000000..a8f388f
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/logging/DownloadStateLogger.java
@@ -0,0 +1,122 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.logging;
+
+import androidx.annotation.VisibleForTesting;
+import com.google.errorprone.annotations.CheckReturnValue;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupBookkeeping;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal;
+
+/** Helper logger to log the events associated with an MDD Download or Import operation. */
+@CheckReturnValue
+public final class DownloadStateLogger {
+  private static final String TAG = "FileGroupStatusLogger";
+
+  @VisibleForTesting
+  enum Operation {
+    DOWNLOAD,
+    IMPORT,
+  };
+
+  private final EventLogger eventLogger;
+  private final Operation operation;
+
+  private DownloadStateLogger(EventLogger eventLogger, Operation operation) {
+    this.eventLogger = eventLogger;
+    this.operation = operation;
+  }
+
+  public static DownloadStateLogger forDownload(EventLogger eventLogger) {
+    return new DownloadStateLogger(eventLogger, Operation.DOWNLOAD);
+  }
+
+  public static DownloadStateLogger forImport(EventLogger eventLogger) {
+    return new DownloadStateLogger(eventLogger, Operation.IMPORT);
+  }
+
+  public void logStarted(DataFileGroupInternal fileGroup) {
+    switch (operation) {
+      case DOWNLOAD:
+        logEventWithDataFileGroup(0, fileGroup);
+        break;
+      case IMPORT:
+        logEventWithDataFileGroup(0, fileGroup);
+        break;
+    }
+  }
+
+  public void logPending(DataFileGroupInternal fileGroup) {
+    switch (operation) {
+      case DOWNLOAD:
+        logEventWithDataFileGroup(0, fileGroup);
+        break;
+      case IMPORT:
+        logEventWithDataFileGroup(0, fileGroup);
+        break;
+    }
+  }
+
+  public void logFailed(DataFileGroupInternal fileGroup) {
+    switch (operation) {
+      case DOWNLOAD:
+        logEventWithDataFileGroup(0, fileGroup);
+        break;
+      case IMPORT:
+        logEventWithDataFileGroup(0, fileGroup);
+        break;
+    }
+  }
+
+  public void logComplete(DataFileGroupInternal fileGroup) {
+    switch (operation) {
+      case DOWNLOAD:
+        logEventWithDataFileGroup(0, fileGroup);
+        logDownloadLatency(fileGroup);
+        break;
+      case IMPORT:
+        logEventWithDataFileGroup(0, fileGroup);
+        break;
+    }
+  }
+
+  private void logDownloadLatency(DataFileGroupInternal fileGroup) {
+    // This operation only makes sense for download operation, exit early if it's not the download
+    // operation.
+    if (operation != Operation.DOWNLOAD) {
+      return;
+    }
+
+    Void fileGroupDetails = null;
+
+    DataFileGroupBookkeeping bookkeeping = fileGroup.getBookkeeping();
+    long newFilesReceivedTimestamp = bookkeeping.getGroupNewFilesReceivedTimestamp();
+    long downloadStartedTimestamp = bookkeeping.getGroupDownloadStartedTimestampInMillis();
+    long downloadCompleteTimestamp = bookkeeping.getGroupDownloadedTimestampInMillis();
+
+    Void downloadLatency = null;
+
+    eventLogger.logMddDownloadLatency(fileGroupDetails, downloadLatency);
+  }
+
+  private void logEventWithDataFileGroup(int code, DataFileGroupInternal fileGroup) {
+    eventLogger.logEventSampled(
+        code,
+        fileGroup.getGroupName(),
+        fileGroup.getFileGroupVersionNumber(),
+        fileGroup.getBuildId(),
+        fileGroup.getVariantId());
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/logging/EventLogger.java b/java/com/google/android/libraries/mobiledatadownload/internal/logging/EventLogger.java
new file mode 100644
index 0000000..e1ed276
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/logging/EventLogger.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.logging;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.util.concurrent.AsyncCallable;
+import com.google.common.util.concurrent.ListenableFuture;
+import java.util.List;
+
+/** Interface for remote logging. */
+public interface EventLogger {
+
+  /** Log an mdd event */
+  void logEventSampled(int eventCode);
+
+  /** Log an mdd event with an associated file group. */
+  void logEventSampled(
+      int eventCode,
+      String fileGroupName,
+      int fileGroupVersionNumber,
+      long buildId,
+      String variantId);
+
+  /**
+   * Log an mdd event. This not sampled. Caller should make sure this method is called after
+   * sampling at the passed in value of sample interval.
+   */
+  void logEventAfterSample(int eventCode, int sampleInterval);
+
+  /**
+   * Log mdd file group stats. The buildFileGroupStats callable is only called if the event is going
+   * to be logged.
+   *
+   * @param buildFileGroupStats callable which builds a List of FileGroupStatusWithDetails. Each
+   *     file group status will be logged individually.
+   * @return a future that completes when the logging work is done. The future will complete with a
+   *     failure if the callable fails or if there is an error when logging.
+   */
+  ListenableFuture<Void> logMddFileGroupStats(
+      AsyncCallable<List<FileGroupStatusWithDetails>> buildFileGroupStats);
+
+  /** Simple wrapper class for MDD file group stats and details. */
+  @AutoValue
+  abstract class FileGroupStatusWithDetails {
+    abstract Void fileGroupStatus();
+
+    abstract Void fileGroupDetails();
+
+    static FileGroupStatusWithDetails create(Void fileGroupStatus, Void fileGroupDetails) {
+      return new AutoValue_EventLogger_FileGroupStatusWithDetails(
+          fileGroupStatus, fileGroupDetails);
+    }
+  }
+
+  /** Log mdd api call stats. */
+  void logMddApiCallStats(Void fileGroupDetails, Void apiCallStats);
+
+  /**
+   * Log mdd storage stats. The buildMddStorageStats callable is only called if the event is going
+   * to be logged.
+   *
+   * @param buildMddStorageStats callable which builds the Void to log.
+   * @return a future that completes when the logging work is done. The future will complete with a
+   *     failure if the callable fails or if there is an error when logging.
+   */
+  ListenableFuture<Void> logMddStorageStats(AsyncCallable<Void> buildMddStorageStats);
+
+  /**
+   * Log mdd network stats. The buildMddNetworkStats callable is only called if the event is going
+   * to be logged.
+   *
+   * @param buildMddNetworkStats callable which builds the Void to log.
+   * @return a future that completes when the logging work is done. The future will complete with a
+   *     failure if the callable fails or if there is an error when logging.
+   */
+  ListenableFuture<Void> logMddNetworkStats(AsyncCallable<Void> buildMddNetworkStats);
+
+  /** Log the number of unaccounted files/metadata deleted during maintenance */
+  void logMddDataDownloadFileExpirationEvent(int eventCode, int count);
+
+  /** Log the network savings of MDD download features */
+  void logMddNetworkSavings(
+      Void fileGroupDetails,
+      int code,
+      long fullFileSize,
+      long downloadedFileSize,
+      String fileId,
+      int deltaIndex);
+
+  /** Log mdd download result events. */
+  void logMddDownloadResult(int code, Void fileGroupDetails);
+
+  /** Log stats of mdd {@code getFileGroup} and {@code getFileGroupByFilter} calls. */
+  void logMddQueryStats(Void fileGroupDetails);
+
+  /** Log mdd stats on android sharing events. */
+  void logMddAndroidSharingLog(Void event);
+
+  /** Log mdd download latency. */
+  void logMddDownloadLatency(Void fileGroupStats, Void downloadLatency);
+
+  /** Log mdd usage event. */
+  void logMddUsageEvent(Void fileGroupDetails, Void usageEventLog);
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/logging/FileGroupStatsLogger.java b/java/com/google/android/libraries/mobiledatadownload/internal/logging/FileGroupStatsLogger.java
new file mode 100644
index 0000000..3803c33
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/logging/FileGroupStatsLogger.java
@@ -0,0 +1,94 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.logging;
+
+import android.util.Pair;
+import com.google.android.libraries.mobiledatadownload.internal.FileGroupManager;
+import com.google.android.libraries.mobiledatadownload.internal.FileGroupsMetadata;
+import com.google.android.libraries.mobiledatadownload.internal.annotations.SequentialControlExecutor;
+import com.google.android.libraries.mobiledatadownload.tracing.PropagatedFutures;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal;
+import com.google.mobiledatadownload.internal.MetadataProto.GroupKey;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Executor;
+import javax.inject.Inject;
+
+/**
+ * Log MDD file group stats. For each file group, it will log the file group details along with the
+ * current state of the file group (pending, downloaded or stale).
+ */
+public class FileGroupStatsLogger {
+
+  private static final String TAG = "FileGroupStatsLogger";
+  private final FileGroupManager fileGroupManager;
+  private final FileGroupsMetadata fileGroupsMetadata;
+  private final EventLogger eventLogger;
+  private final Executor sequentialControlExecutor;
+
+  @Inject
+  public FileGroupStatsLogger(
+      FileGroupManager fileGroupManager,
+      FileGroupsMetadata fileGroupsMetadata,
+      EventLogger eventLogger,
+      @SequentialControlExecutor Executor sequentialControlExecutor) {
+    this.fileGroupManager = fileGroupManager;
+    this.fileGroupsMetadata = fileGroupsMetadata;
+    this.eventLogger = eventLogger;
+    this.sequentialControlExecutor = sequentialControlExecutor;
+  }
+
+  // TODO(b/73490689): Also log stats about stale groups.
+  public ListenableFuture<Void> log(int daysSinceLastLog) {
+    return eventLogger.logMddFileGroupStats(() -> buildFileGroupStatusList(daysSinceLastLog));
+  }
+
+  private ListenableFuture<List<EventLogger.FileGroupStatusWithDetails>> buildFileGroupStatusList(
+      int daysSinceLastLog) {
+    return PropagatedFutures.transformAsync(
+        fileGroupsMetadata.getAllFreshGroups(),
+        downloadedAndPendingGroups -> {
+          List<ListenableFuture<EventLogger.FileGroupStatusWithDetails>> futures =
+              new ArrayList<>();
+          for (Pair<GroupKey, DataFileGroupInternal> pair : downloadedAndPendingGroups) {
+            GroupKey groupKey = pair.first;
+            DataFileGroupInternal dataFileGroup = pair.second;
+            if (dataFileGroup == null) {
+              continue;
+            }
+
+            Void fileGroupDetails = null;
+
+            futures.add(
+                PropagatedFutures.transform(
+                    buildFileGroupStatus(dataFileGroup, groupKey, daysSinceLastLog),
+                    fileGroupStatus ->
+                        EventLogger.FileGroupStatusWithDetails.create(
+                            fileGroupStatus, fileGroupDetails),
+                    sequentialControlExecutor));
+          }
+          return Futures.allAsList(futures);
+        },
+        sequentialControlExecutor);
+  }
+
+  private ListenableFuture<Void> buildFileGroupStatus(
+      DataFileGroupInternal dataFileGroup, GroupKey groupKey, int daysSinceLastLog) {
+    return Futures.immediateVoidFuture();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/logging/LogSampler.java b/java/com/google/android/libraries/mobiledatadownload/internal/logging/LogSampler.java
new file mode 100644
index 0000000..212dec5
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/logging/LogSampler.java
@@ -0,0 +1,133 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.logging;
+
+import static com.google.common.util.concurrent.Futures.immediateFuture;
+import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
+
+import com.google.android.libraries.mobiledatadownload.Flags;
+import com.google.android.libraries.mobiledatadownload.tracing.PropagatedFluentFuture;
+import com.google.common.base.Optional;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.errorprone.annotations.CheckReturnValue;
+import java.util.Random;
+
+/** Class responsible for sampling events. */
+@CheckReturnValue
+public final class LogSampler {
+
+  private final Flags flags;
+  private final Random random;
+
+  /**
+   * Construct the log sampler.
+   *
+   * @param flags used to check whether stable sampling is enabled.
+   * @param random used to generate random numbers for event based sampling only.
+   */
+  public LogSampler(Flags flags, Random random) {
+    this.flags = flags;
+    this.random = random;
+  }
+
+  /**
+   * Determines whether the event should be logged. If the event should be logged it returns an
+   * instance of Void that should be attached to the log events.
+   *
+   * <p>If stable sampling is enabled, this is deterministic. If stable sampling is disabled, the
+   * result can change on each call based on the provided Random instance.
+   *
+   * @param sampleInterval the inverse sampling rate to use. This is controlled by flags per
+   *     event-type. For stable sampling it's expected that 100 % sampleInterval == 0.
+   * @param loggingStateStore used to read persisted random number when stable sampling is enabled.
+   *     If it is absent, stable sampling will not be used.
+   * @return a future of an optional of StableSamplingInfo. The future will resolve to an absent
+   *     Optional if the event should not be logged. If the event should be logged, the returned
+   *     Void should be attached to the log event.
+   */
+  public ListenableFuture<Optional<Void>> shouldLog(
+      long sampleInterval, Optional<LoggingStateStore> loggingStateStore) {
+    if (sampleInterval == 0L) {
+      return immediateFuture(Optional.absent());
+    } else if (sampleInterval < 0L) {
+      LogUtil.e("Bad sample interval (negative number): %d", sampleInterval);
+      return immediateFuture(Optional.absent());
+    } else if (flags.enableRngBasedDeviceStableSampling() && loggingStateStore.isPresent()) {
+      return shouldLogDeviceStable(sampleInterval, loggingStateStore.get());
+    } else {
+      return shouldLogPerEvent(sampleInterval);
+    }
+  }
+
+  /**
+   * Returns standard random event based sampling.
+   *
+   * @return if the event should be sampled, returns the Void with stable_sampling_used = false.
+   *     Otherwise, returns an empty Optional.
+   */
+  private ListenableFuture<Optional<Void>> shouldLogPerEvent(long sampleInterval) {
+    if (shouldSamplePerEvent(sampleInterval)) {
+      return immediateFuture(Optional.absent());
+    } else {
+      return immediateFuture(Optional.absent());
+    }
+  }
+
+  private boolean shouldSamplePerEvent(long sampleInterval) {
+    if (sampleInterval == 0L) {
+      return false;
+    } else if (sampleInterval < 0L) {
+      LogUtil.e("Bad sample interval (negative number): %d", sampleInterval);
+      return false;
+    } else {
+      return isPartOfSample(random.nextLong(), sampleInterval);
+    }
+  }
+
+  /**
+   * Returns device stable sampling.
+   *
+   * @return if the event should be sampled, returns the Void with stable_sampling_used = true and
+   *     all other fields populated. Otherwise, returns an empty Optional.
+   */
+  private ListenableFuture<Optional<Void>> shouldLogDeviceStable(
+      long sampleInterval, LoggingStateStore loggingStateStore) {
+    return PropagatedFluentFuture.from(loggingStateStore.getStableSamplingInfo())
+        .transform(
+            samplingInfo -> {
+              boolean invalidSamplingRateUsed = ((100 % sampleInterval) != 0);
+              if (invalidSamplingRateUsed) {
+                LogUtil.e(
+                    "Bad sample interval (1 percent cohort will not log): %d", sampleInterval);
+              }
+
+              if (!isPartOfSample(samplingInfo.getStableLogSamplingSalt(), sampleInterval)) {
+                return Optional.absent();
+              }
+
+              return Optional.absent();
+            },
+            directExecutor());
+  }
+
+  /**
+   * Returns whether this device is part of the sample with the given sampling rate and random
+   * number.
+   */
+  private boolean isPartOfSample(long randomNumber, long sampleInterval) {
+    return randomNumber % sampleInterval == 0;
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/logging/LogUtil.java b/java/com/google/android/libraries/mobiledatadownload/internal/logging/LogUtil.java
new file mode 100644
index 0000000..bba7ab3
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/logging/LogUtil.java
@@ -0,0 +1,276 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.logging;
+
+import android.annotation.SuppressLint;
+import android.util.Log;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.errorprone.annotations.FormatMethod;
+import com.google.errorprone.annotations.FormatString;
+import java.util.Locale;
+import java.util.Random;
+import javax.annotation.Nullable;
+
+/** Utility class for logging with the "MDD" tag. */
+@CanIgnoreReturnValue
+public class LogUtil {
+  public static final String TAG = "MDD";
+
+  private static final Random random = new Random();
+
+  public static int getLogPriority() {
+    int level = Log.ASSERT;
+    while (level > Log.VERBOSE) {
+      if (!Log.isLoggable(TAG, level - 1)) {
+        break;
+      }
+      level--;
+    }
+    return level;
+  }
+
+  public static int v(String msg) {
+    if (Log.isLoggable(TAG, Log.VERBOSE)) {
+      return Log.v(TAG, msg);
+    }
+    return 0;
+  }
+
+  @FormatMethod
+  public static int v(@FormatString String format, Object obj0) {
+    if (Log.isLoggable(TAG, Log.VERBOSE)) {
+      String msg = format(format, obj0);
+      return Log.v(TAG, msg);
+    }
+    return 0;
+  }
+
+  @FormatMethod
+  public static int v(@FormatString String format, Object obj0, Object obj1) {
+    if (Log.isLoggable(TAG, Log.VERBOSE)) {
+      String msg = format(format, obj0, obj1);
+      return Log.v(TAG, msg);
+    }
+    return 0;
+  }
+
+  @FormatMethod
+  public static int v(@FormatString String format, Object... params) {
+    if (Log.isLoggable(TAG, Log.VERBOSE)) {
+      String msg = format(format, params);
+      return Log.v(TAG, msg);
+    }
+    return 0;
+  }
+
+  public static int d(String msg) {
+    if (Log.isLoggable(TAG, Log.DEBUG)) {
+      return Log.d(TAG, msg);
+    }
+    return 0;
+  }
+
+  @FormatMethod
+  public static int d(@FormatString String format, Object obj0) {
+    if (Log.isLoggable(TAG, Log.DEBUG)) {
+      String msg = format(format, obj0);
+      return Log.d(TAG, msg);
+    }
+    return 0;
+  }
+
+  @FormatMethod
+  public static int d(@FormatString String format, Object obj0, Object obj1) {
+    if (Log.isLoggable(TAG, Log.DEBUG)) {
+      String msg = format(format, obj0, obj1);
+      return Log.d(TAG, msg);
+    }
+    return 0;
+  }
+
+  @FormatMethod
+  public static int d(@FormatString String format, Object... params) {
+    if (Log.isLoggable(TAG, Log.DEBUG)) {
+      String msg = format(format, params);
+      return Log.d(TAG, msg);
+    }
+    return 0;
+  }
+
+  @FormatMethod
+  public static int d(@Nullable Throwable tr, @FormatString String format, Object... params) {
+    if (Log.isLoggable(TAG, Log.DEBUG)) {
+      String msg = format(format, params);
+      return Log.d(TAG, msg, tr);
+    }
+    return 0;
+  }
+
+  public static int i(String msg) {
+    if (Log.isLoggable(TAG, Log.INFO)) {
+      return Log.i(TAG, msg);
+    }
+    return 0;
+  }
+
+  @FormatMethod
+  public static int i(@FormatString String format, Object obj0) {
+    if (Log.isLoggable(TAG, Log.INFO)) {
+      String msg = format(format, obj0);
+      return Log.i(TAG, msg);
+    }
+    return 0;
+  }
+
+  @FormatMethod
+  public static int i(@FormatString String format, Object obj0, Object obj1) {
+    if (Log.isLoggable(TAG, Log.INFO)) {
+      String msg = format(format, obj0, obj1);
+      return Log.i(TAG, msg);
+    }
+    return 0;
+  }
+
+  @FormatMethod
+  public static int i(@FormatString String format, Object... params) {
+    if (Log.isLoggable(TAG, Log.INFO)) {
+      String msg = format(format, params);
+      return Log.i(TAG, msg);
+    }
+    return 0;
+  }
+
+  public static int e(String msg) {
+    if (Log.isLoggable(TAG, Log.ERROR)) {
+      return Log.e(TAG, msg);
+    }
+    return 0;
+  }
+
+  @FormatMethod
+  public static int e(@FormatString String format, Object obj0) {
+    if (Log.isLoggable(TAG, Log.ERROR)) {
+      String msg = format(format, obj0);
+      return Log.e(TAG, msg);
+    }
+    return 0;
+  }
+
+  @FormatMethod
+  public static int e(@FormatString String format, Object obj0, Object obj1) {
+    if (Log.isLoggable(TAG, Log.ERROR)) {
+      String msg = format(format, obj0, obj1);
+      return Log.e(TAG, msg);
+    }
+    return 0;
+  }
+
+  @FormatMethod
+  public static int e(@FormatString String format, Object... params) {
+    if (Log.isLoggable(TAG, Log.ERROR)) {
+      String msg = format(format, params);
+      return Log.e(TAG, msg);
+    }
+    return 0;
+  }
+
+  @SuppressLint("LogTagMismatch")
+  public static int e(@Nullable Throwable tr, String msg) {
+    if (Log.isLoggable(TAG, Log.ERROR)) {
+      if (Log.isLoggable(TAG, Log.DEBUG)) {
+        return Log.e(TAG, msg, tr);
+      } else {
+        // If not DEBUG level, only print the throwable type and message.
+        msg = msg + ": " + tr;
+        return Log.e(TAG, msg);
+      }
+    }
+    return 0;
+  }
+
+  @FormatMethod
+  public static int e(@Nullable Throwable tr, @FormatString String format, Object... params) {
+    return Log.isLoggable(TAG, Log.ERROR) ? e(tr, format(format, params)) : 0;
+  }
+
+  public static int w(String msg) {
+    if (Log.isLoggable(TAG, Log.WARN)) {
+      return Log.w(TAG, msg);
+    }
+    return 0;
+  }
+
+  @FormatMethod
+  public static int w(@FormatString String format, Object obj0) {
+    if (Log.isLoggable(TAG, Log.WARN)) {
+      String msg = format(format, obj0);
+      return Log.w(TAG, msg);
+    }
+    return 0;
+  }
+
+  @FormatMethod
+  public static int w(@FormatString String format, Object obj0, Object obj1) {
+    if (Log.isLoggable(TAG, Log.WARN)) {
+      String msg = format(format, obj0, obj1);
+      return Log.w(TAG, msg);
+    }
+    return 0;
+  }
+
+  @FormatMethod
+  public static int w(@FormatString String format, Object... params) {
+    if (Log.isLoggable(TAG, Log.WARN)) {
+      String msg = format(format, params);
+      return Log.w(TAG, msg);
+    }
+    return 0;
+  }
+
+  @SuppressLint("LogTagMismatch")
+  @FormatMethod
+  public static int w(@Nullable Throwable tr, @FormatString String format, Object... params) {
+    if (Log.isLoggable(TAG, Log.WARN)) {
+      if (Log.isLoggable(TAG, Log.DEBUG)) {
+        String msg = format(format, params);
+        return Log.w(TAG, msg, tr);
+      } else {
+        // If not DEBUG level, only print the throwable type and message.
+        String msg = format(format, params) + ": " + tr;
+        return Log.w(TAG, msg);
+      }
+    }
+    return 0;
+  }
+
+  @FormatMethod
+  private static String format(@FormatString String format, Object... args) {
+    return String.format(Locale.US, format, args);
+  }
+
+  public static boolean shouldSampleInterval(long sampleInterval) {
+    if (sampleInterval <= 0L) {
+      if (sampleInterval < 0L) {
+        LogUtil.e("Bad sample interval: %d", sampleInterval);
+      }
+      return false;
+    } else {
+      return (random.nextLong() % sampleInterval) == 0;
+    }
+  }
+
+  private LogUtil() {}
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/logging/LoggingStateStore.java b/java/com/google/android/libraries/mobiledatadownload/internal/logging/LoggingStateStore.java
new file mode 100644
index 0000000..ac8318f
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/logging/LoggingStateStore.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.logging;
+
+import com.google.common.base.Optional;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.mobiledatadownload.internal.MetadataProto.FileGroupLoggingState;
+import com.google.mobiledatadownload.internal.MetadataProto.SamplingInfo;
+import java.util.List;
+
+/** Interface for keeping track of state necessary for accurate logging. */
+public interface LoggingStateStore {
+
+  /**
+   * Gets the number of days since maintenance was last run and updates the timestamp for future
+   * calls.
+   *
+   * @return a future representing the number of days since maintenance was last run. If this method
+   *     hasn't succeeded before, the future will complete with an absent Optional. The future may
+   *     complete with (invalid) negative values. Future will fail with IOException if there was an
+   *     issue reading or updating the value.
+   */
+  public ListenableFuture<Optional<Integer>> getAndResetDaysSinceLastMaintenance();
+
+  /**
+   * Increment the data usage stats for the file group. If there exists an entry which matches the
+   * GroupKey, version number and build id, then the data usage from dataUsageIncrements will be
+   * added to that, otherwise a new entry will be created.
+   */
+  public ListenableFuture<Void> incrementDataUsage(FileGroupLoggingState dataUsageIncrements);
+
+  /**
+   * Returns a list of all the data usage increments grouped by GroupKey, build id and version
+   * number.
+   */
+  public ListenableFuture<List<FileGroupLoggingState>> getAndResetAllDataUsage();
+
+  /** Resets all LoggingStateStore state. */
+  public ListenableFuture<Void> clear();
+
+  /**
+   * Gets info necessary for stable sampling. Callers are responsible for ensuring that stable
+   * sampling is enabled.
+   *
+   * <p>If the stable sampling random number hasn't been persisted yet, this will populate it before
+   * returning.
+   */
+  public ListenableFuture<SamplingInfo> getStableSamplingInfo();
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/logging/MddEventLogger.java b/java/com/google/android/libraries/mobiledatadownload/internal/logging/MddEventLogger.java
new file mode 100644
index 0000000..c2b4984
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/logging/MddEventLogger.java
@@ -0,0 +1,275 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.logging;
+
+import static com.google.common.util.concurrent.Futures.immediateVoidFuture;
+import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import com.google.android.libraries.mobiledatadownload.Flags;
+import com.google.android.libraries.mobiledatadownload.Logger;
+import com.google.android.libraries.mobiledatadownload.tracing.PropagatedFutures;
+import com.google.common.base.Optional;
+import com.google.common.util.concurrent.AsyncCallable;
+import com.google.common.util.concurrent.FluentFuture;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.ListenableFuture;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/** Assembles data and logs them with underlying {@link Logger}. */
+public final class MddEventLogger implements EventLogger {
+
+  private static final String TAG = "MddEventLogger";
+
+  private final Context context;
+  private final Logger logger;
+  // A process that has mdi download module loaded will get restarted if a new module version is
+  // installed.
+  private final int moduleVersion;
+  private final String hostPackageName;
+  private final Flags flags;
+  private final LogSampler logSampler;
+
+  private Optional<LoggingStateStore> loggingStateStore = Optional.absent();
+
+  public MddEventLogger(
+      Context context, Logger logger, int moduleVersion, LogSampler logSampler, Flags flags) {
+    this.context = context;
+    this.logger = logger;
+    this.moduleVersion = moduleVersion;
+    this.hostPackageName = context.getPackageName();
+    this.logSampler = logSampler;
+    this.flags = flags;
+  }
+
+  /**
+   * This should be called before MddEventLogger is used. If it is not called before MddEventLogger
+   * is used, stable sampling will not be used.
+   *
+   * <p>Note(rohitsat): this is required because LoggingStateStore is constructed with a PDS in the
+   * MainMddLibModule. MddEventLogger is required to construct the MainMddLibModule.
+   *
+   * @param loggingStateStore the LoggingStateStore that contains the persisted random number for
+   *     stable sampling.
+   */
+  public void setLoggingStateStore(LoggingStateStore loggingStateStore) {
+    this.loggingStateStore = Optional.of(loggingStateStore);
+  }
+
+  @Override
+  public void logEventSampled(int eventCode) {}
+
+  @Override
+  public void logEventSampled(
+      int eventCode,
+      String fileGroupName,
+      int fileGroupVersionNumber,
+      long buildId,
+      String variantId) {
+
+    Void dataDownloadFileGroupStats = null;
+  }
+
+  @Override
+  public void logEventAfterSample(int eventCode, int sampleInterval) {
+    // TODO(b/138392640): delete this method once the pds migration is complete. If it's necessary
+    // for other use cases, we can establish a pattern where this class is still responsible for
+    // sampling.
+    Void logData = null;
+    processAndSendEventWithoutStableSampling(eventCode, logData, sampleInterval);
+  }
+
+  @Override
+  public void logMddApiCallStats(Void fileGroupDetails, Void apiCallStats) {
+    // TODO(b/144684763): update this to use stable sampling. Leaving it as is for now since it is
+    // fairly high volume.
+    long sampleInterval = flags.apiLoggingSampleInterval();
+    if (!LogUtil.shouldSampleInterval(sampleInterval)) {
+      return;
+    }
+    Void logData = null;
+    processAndSendEventWithoutStableSampling(0, logData, sampleInterval);
+  }
+
+  @Override
+  public ListenableFuture<Void> logMddFileGroupStats(
+      AsyncCallable<List<EventLogger.FileGroupStatusWithDetails>> buildFileGroupStats) {
+    return lazySampleAndSendLogEvent(
+        0,
+        () ->
+            PropagatedFutures.transform(
+                buildFileGroupStats.call(),
+                fileGroupStatusAndDetailsList -> {
+                  List<Void> allIcingLogData = new ArrayList<>();
+
+                  for (FileGroupStatusWithDetails fileGroupStatusAndDetails :
+                      fileGroupStatusAndDetailsList) {
+                    allIcingLogData.add(null);
+                  }
+                  return allIcingLogData;
+                },
+                directExecutor()),
+        flags.groupStatsLoggingSampleInterval());
+  }
+
+  @Override
+  public ListenableFuture<Void> logMddStorageStats(AsyncCallable<Void> buildStorageStats) {
+    return lazySampleAndSendLogEvent(
+        0,
+        () ->
+            PropagatedFutures.transform(
+                buildStorageStats.call(), storageStats -> Arrays.asList(), directExecutor()),
+        flags.storageStatsLoggingSampleInterval());
+  }
+
+  @Override
+  public ListenableFuture<Void> logMddNetworkStats(AsyncCallable<Void> buildNetworkStats) {
+    return lazySampleAndSendLogEvent(
+        0,
+        () ->
+            PropagatedFutures.transform(
+                buildNetworkStats.call(), networkStats -> Arrays.asList(), directExecutor()),
+        flags.networkStatsLoggingSampleInterval());
+  }
+
+  @Override
+  public void logMddDataDownloadFileExpirationEvent(int eventCode, int count) {
+    Void logData = null;
+    sampleAndSendLogEvent(0, logData, flags.mddDefaultSampleInterval());
+  }
+
+  @Override
+  public void logMddNetworkSavings(
+      Void fileGroupDetails,
+      int code,
+      long fullFileSize,
+      long downloadedFileSize,
+      String fileId,
+      int deltaIndex) {
+    Void logData = null;
+
+    sampleAndSendLogEvent(0, logData, flags.mddDefaultSampleInterval());
+  }
+
+  @Override
+  public void logMddQueryStats(Void fileGroupDetails) {
+    Void logData = null;
+
+    sampleAndSendLogEvent(0, logData, flags.mddDefaultSampleInterval());
+  }
+
+  @Override
+  public void logMddDownloadLatency(Void fileGroupDetails, Void downloadLatency) {
+    Void logData = null;
+
+    sampleAndSendLogEvent(0, logData, flags.mddDefaultSampleInterval());
+  }
+
+  @Override
+  public void logMddDownloadResult(int code, Void fileGroupDetails) {
+    Void logData = null;
+
+    sampleAndSendLogEvent(0, logData, flags.mddDefaultSampleInterval());
+  }
+
+  @Override
+  public void logMddAndroidSharingLog(Void event) {
+    // TODO(b/144684763): consider moving this to stable sampling depending on frequency of events.
+    long sampleInterval = flags.mddAndroidSharingSampleInterval();
+    if (!LogUtil.shouldSampleInterval(sampleInterval)) {
+      return;
+    }
+    Void logData = null;
+    processAndSendEventWithoutStableSampling(0, logData, sampleInterval);
+  }
+
+  @Override
+  public void logMddUsageEvent(Void fileGroupDetails, Void usageEventLog) {
+    Void logData = null;
+
+    sampleAndSendLogEvent(0, logData, flags.mddDefaultSampleInterval());
+  }
+
+  /**
+   * Determines whether the log event will be a part of the sample, and if so calls {@code
+   * buildStats} to construct the log event. This is like {@link sampleAndSendLogEvent} but
+   * constructs the log event lazy. This is useful if constructing the log event is expensive.
+   */
+  private ListenableFuture<Void> lazySampleAndSendLogEvent(
+      int eventCode, AsyncCallable<List<Void>> buildStats, int sampleInterval) {
+    return PropagatedFutures.transformAsync(
+        logSampler.shouldLog(sampleInterval, loggingStateStore),
+        samplingInfoOptional -> {
+          if (!samplingInfoOptional.isPresent()) {
+            return immediateVoidFuture();
+          }
+
+          return FluentFuture.from(buildStats.call())
+              .transform(
+                  icingLogDataList -> {
+                    if (icingLogDataList != null) {
+                      for (Void icingLogData : icingLogDataList) {
+                        processAndSendEvent(
+                            eventCode, null, sampleInterval, samplingInfoOptional.get());
+                      }
+                    }
+                    return null;
+                  },
+                  directExecutor());
+        },
+        directExecutor());
+  }
+
+  private void sampleAndSendLogEvent(int eventCode, Void logData, long sampleInterval) {
+    PropagatedFutures.addCallback(
+        logSampler.shouldLog(sampleInterval, loggingStateStore),
+        new FutureCallback<Optional<Void>>() {
+          @Override
+          public void onSuccess(Optional<Void> stableSamplingInfo) {
+            if (stableSamplingInfo.isPresent()) {
+              processAndSendEvent(eventCode, logData, sampleInterval, stableSamplingInfo.get());
+            }
+          }
+
+          @Override
+          public void onFailure(Throwable t) {
+            LogUtil.e(t, "%s: failure when sampling log!", TAG);
+          }
+        },
+        directExecutor());
+  }
+
+  /** Adds all transforms common to all logs and sends the event to Logger. */
+  private void processAndSendEventWithoutStableSampling(
+      int eventCode, Void logData, long sampleInterval) {
+    processAndSendEvent(eventCode, logData, sampleInterval, null);
+  }
+
+  /** Adds all transforms common to all logs and sends the event to Logger. */
+  private void processAndSendEvent(
+      int eventCode, Void logData, long sampleInterval, Void stableSamplingInfo) {}
+
+  /** Returns whether the device is in low storage state. */
+  private static boolean isDeviceStorageLow(Context context) {
+    // Check if the system says storage is low, by reading the sticky intent.
+    return context.registerReceiver(null, new IntentFilter(Intent.ACTION_DEVICE_STORAGE_LOW))
+        != null;
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/logging/NetworkLogger.java b/java/com/google/android/libraries/mobiledatadownload/internal/logging/NetworkLogger.java
new file mode 100644
index 0000000..9f2ac1f
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/logging/NetworkLogger.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.logging;
+
+import static com.google.common.util.concurrent.Futures.immediateVoidFuture;
+import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
+
+import android.content.Context;
+import com.google.android.libraries.mobiledatadownload.Flags;
+import com.google.android.libraries.mobiledatadownload.annotations.InstanceId;
+import com.google.android.libraries.mobiledatadownload.internal.ApplicationContext;
+import com.google.android.libraries.mobiledatadownload.tracing.PropagatedFutures;
+import com.google.common.base.Optional;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.mobiledatadownload.internal.MetadataProto.FileGroupLoggingState;
+import java.util.List;
+import javax.inject.Inject;
+
+/**
+ * Log MDD network stats at daily maintenance. For each file group, it will log the total bytes
+ * downloaded on Wifi and Cellular and also total bytes downloaded by MDD on Wifi and Cellular.
+ */
+public class NetworkLogger {
+
+  private final EventLogger eventLogger;
+  private final Flags flags;
+  private final LoggingStateStore loggingStateStore;
+
+  @Inject
+  public NetworkLogger(
+      @ApplicationContext Context context,
+      EventLogger eventLogger,
+      @InstanceId Optional<String> instanceIdOptional,
+      Flags flags,
+      LoggingStateStore loggingStateStore) {
+    this.eventLogger = eventLogger;
+    this.flags = flags;
+    this.loggingStateStore = loggingStateStore;
+  }
+
+  public ListenableFuture<Void> log() {
+    if (!flags.logNetworkStats()) {
+      return immediateVoidFuture();
+    }
+
+    // Clear the accumulated network usage even if the device isn't logging, otherwise with 1%
+    // sampling, we could potentially log network usage for up to 100 days.
+    ListenableFuture<List<FileGroupLoggingState>> allDataUsageFuture =
+        loggingStateStore.getAndResetAllDataUsage();
+
+    return eventLogger.logMddNetworkStats(
+        () ->
+            PropagatedFutures.transform(
+                allDataUsageFuture, this::buildNetworkStats, directExecutor()));
+  }
+
+  private Void buildNetworkStats(List<FileGroupLoggingState> allDataUsage) {
+    long totalMddWifiCount = 0;
+    long totalMddCellularCount = 0;
+    Void networkStatsBuilder = null;
+
+    return networkStatsBuilder;
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/logging/NoOpEventLogger.java b/java/com/google/android/libraries/mobiledatadownload/internal/logging/NoOpEventLogger.java
new file mode 100644
index 0000000..5bf6ebc
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/logging/NoOpEventLogger.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.logging;
+
+import static com.google.common.util.concurrent.Futures.immediateVoidFuture;
+
+import com.google.common.util.concurrent.AsyncCallable;
+import com.google.common.util.concurrent.ListenableFuture;
+import java.util.List;
+
+/** No-Op EventLogger implementation. */
+public final class NoOpEventLogger implements EventLogger {
+
+  @Override
+  public void logEventSampled(int eventCode) {}
+
+  @Override
+  public void logEventSampled(
+      int eventCode,
+      String fileGroupName,
+      int fileGroupVersionNumber,
+      long buildId,
+      String variantId) {}
+
+  @Override
+  public void logEventAfterSample(int eventCode, int sampleInterval) {}
+
+  @Override
+  public ListenableFuture<Void> logMddFileGroupStats(
+      AsyncCallable<List<EventLogger.FileGroupStatusWithDetails>> buildFileGroupStats) {
+    return immediateVoidFuture();
+  }
+
+  @Override
+  public void logMddApiCallStats(Void fileGroupDetails, Void apiCallStats) {}
+
+  @Override
+  public ListenableFuture<Void> logMddStorageStats(AsyncCallable<Void> buildMddStorageStats) {
+    return immediateVoidFuture();
+  }
+
+  @Override
+  public ListenableFuture<Void> logMddNetworkStats(AsyncCallable<Void> buildMddNetworkStats) {
+    return immediateVoidFuture();
+  }
+
+  @Override
+  public void logMddDataDownloadFileExpirationEvent(int eventCode, int count) {}
+
+  @Override
+  public void logMddNetworkSavings(
+      Void fileGroupDetails,
+      int code,
+      long fullFileSize,
+      long downloadedFileSize,
+      String fileId,
+      int deltaIndex) {}
+
+  @Override
+  public void logMddDownloadResult(int code, Void fileGroupDetails) {}
+
+  @Override
+  public void logMddQueryStats(Void fileGroupDetails) {}
+
+  @Override
+  public void logMddAndroidSharingLog(Void event) {}
+
+  @Override
+  public void logMddDownloadLatency(Void fileGroupStats, Void downloadLatency) {}
+
+  @Override
+  public void logMddUsageEvent(Void fileGroupDetails, Void usageEventLog) {}
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/logging/NoOpLoggingState.java b/java/com/google/android/libraries/mobiledatadownload/internal/logging/NoOpLoggingState.java
new file mode 100644
index 0000000..d64f74c
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/logging/NoOpLoggingState.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.logging;
+
+import static com.google.common.util.concurrent.Futures.immediateFuture;
+import static com.google.common.util.concurrent.Futures.immediateVoidFuture;
+
+import com.google.common.base.Optional;
+import com.google.common.collect.ImmutableList;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.mobiledatadownload.internal.MetadataProto.FileGroupLoggingState;
+import com.google.mobiledatadownload.internal.MetadataProto.SamplingInfo;
+import java.util.List;
+
+/** LoggingStateStore that returns empty or void for all operations. */
+public final class NoOpLoggingState implements LoggingStateStore {
+
+  public NoOpLoggingState() {}
+
+  @Override
+  public ListenableFuture<Optional<Integer>> getAndResetDaysSinceLastMaintenance() {
+    return immediateFuture(Optional.absent());
+  }
+
+  @Override
+  public ListenableFuture<Void> incrementDataUsage(FileGroupLoggingState unused) {
+    return immediateVoidFuture();
+  }
+
+  @Override
+  public ListenableFuture<List<FileGroupLoggingState>> getAndResetAllDataUsage() {
+    return immediateFuture(ImmutableList.of());
+  }
+
+  @Override
+  public ListenableFuture<Void> clear() {
+    return immediateVoidFuture();
+  }
+
+  @Override
+  public ListenableFuture<SamplingInfo> getStableSamplingInfo() {
+    return immediateFuture(SamplingInfo.getDefaultInstance());
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/logging/StorageLogger.java b/java/com/google/android/libraries/mobiledatadownload/internal/logging/StorageLogger.java
new file mode 100644
index 0000000..5307941
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/logging/StorageLogger.java
@@ -0,0 +1,311 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.logging;
+
+import static com.google.android.libraries.mobiledatadownload.internal.MddConstants.SPLIT_CHAR;
+
+import android.content.Context;
+import android.util.Pair;
+import com.google.android.libraries.mobiledatadownload.SilentFeedback;
+import com.google.android.libraries.mobiledatadownload.annotations.InstanceId;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.internal.ApplicationContext;
+import com.google.android.libraries.mobiledatadownload.internal.FileGroupsMetadata;
+import com.google.android.libraries.mobiledatadownload.internal.MddConstants;
+import com.google.android.libraries.mobiledatadownload.internal.SharedFileManager;
+import com.google.android.libraries.mobiledatadownload.internal.SharedFileMissingException;
+import com.google.android.libraries.mobiledatadownload.internal.SharedFilesMetadata;
+import com.google.android.libraries.mobiledatadownload.internal.annotations.SequentialControlExecutor;
+import com.google.android.libraries.mobiledatadownload.internal.util.FileGroupUtil;
+import com.google.android.libraries.mobiledatadownload.tracing.PropagatedFluentFuture;
+import com.google.android.libraries.mobiledatadownload.tracing.PropagatedFutures;
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Optional;
+import com.google.common.base.Preconditions;
+import com.google.common.base.Strings;
+import com.google.common.util.concurrent.FluentFuture;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFile;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal;
+import com.google.mobiledatadownload.internal.MetadataProto.GroupKey;
+import com.google.mobiledatadownload.internal.MetadataProto.NewFileKey;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.Executor;
+import java.util.concurrent.atomic.AtomicLong;
+import javax.inject.Inject;
+
+/**
+ * Log MDD storage stats at daily maintenance. For each file group, it will log the total bytes used
+ * on disk for that file group and the bytes used by the downloaded group.
+ */
+public class StorageLogger {
+  private static final String TAG = "StorageLogger";
+  private final FileGroupsMetadata fileGroupsMetadata;
+  private final SharedFileManager sharedFileManager;
+  private final SynchronousFileStorage fileStorage;
+  private final EventLogger eventLogger;
+  private final Context context;
+  private final SilentFeedback silentFeedback;
+  private final Optional<String> instanceId;
+  private final Executor sequentialControlExecutor;
+
+  /** Store the storage stats for a file group. */
+  static class GroupStorage {
+    // The sum of all on-disk file sizes of the files belonging to this file group, in bytes.
+    public long totalBytesUsed;
+
+    // The sum of all on-disk inline file sizes of the files belonging to this file group, in bytes.
+    public long totalInlineBytesUsed;
+
+    // The sum of all on-disk file sizes of this downloaded file group in bytes.
+    public long downloadedGroupBytesUsed;
+
+    // The sum of all on-disk inline files sizes of this downloaded file group in bytes.
+    public long downloadedGroupInlineBytesUsed;
+
+    // The total number of files in the group.
+    public int totalFileCount;
+
+    // The number of inline files in the group.
+    public int totalInlineFileCount;
+  }
+
+  @Inject
+  public StorageLogger(
+      @ApplicationContext Context context,
+      FileGroupsMetadata fileGroupsMetadata,
+      SharedFileManager sharedFileManager,
+      SynchronousFileStorage fileStorage,
+      EventLogger eventLogger,
+      SilentFeedback silentFeedback,
+      @InstanceId Optional<String> instanceId,
+      @SequentialControlExecutor Executor sequentialControlExecutor) {
+    this.context = context;
+    this.fileGroupsMetadata = fileGroupsMetadata;
+    this.sharedFileManager = sharedFileManager;
+    this.fileStorage = fileStorage;
+    this.eventLogger = eventLogger;
+    this.silentFeedback = silentFeedback;
+    this.instanceId = instanceId;
+    this.sequentialControlExecutor = sequentialControlExecutor;
+  }
+
+  // TODO(b/64764648): Combine this with MobileDataDownloadManager.createGroupKey
+  private static GroupKey createGroupKey(DataFileGroupInternal fileGroup) {
+    GroupKey.Builder groupKey = GroupKey.newBuilder().setGroupName(fileGroup.getGroupName());
+
+    if (Strings.isNullOrEmpty(fileGroup.getOwnerPackage())) {
+      groupKey.setOwnerPackage(MddConstants.GMS_PACKAGE);
+    } else {
+      groupKey.setOwnerPackage(fileGroup.getOwnerPackage());
+    }
+
+    return groupKey.build();
+  }
+
+  public ListenableFuture<Void> logStorageStats(int daysSinceLastLog) {
+    return eventLogger.logMddStorageStats(() -> buildStorageStatsIcingLogData(daysSinceLastLog));
+  }
+
+  private ListenableFuture<Void> buildStorageStatsIcingLogData(int daysSinceLastLog) {
+    return PropagatedFluentFuture.from(fileGroupsMetadata.getAllFreshGroups())
+        .transformAsync(
+            allGroups ->
+                PropagatedFutures.transformAsync(
+                    fileGroupsMetadata.getAllStaleGroups(),
+                    staleGroups ->
+                        buildStorageStatsInternal(allGroups, staleGroups, daysSinceLastLog),
+                    sequentialControlExecutor),
+            sequentialControlExecutor);
+  }
+
+  private ListenableFuture<Void> buildStorageStatsInternal(
+      List<Pair<GroupKey, DataFileGroupInternal>> allKeysAndGroupPairs,
+      List<DataFileGroupInternal> staleGroups,
+      int daysSinceLastLog) {
+
+    List<GroupKeyAndDataFileGroupInternal> allKeysAndGroups = new ArrayList<>();
+    for (Pair<GroupKey, DataFileGroupInternal> groupKeyAndGroup : allKeysAndGroupPairs) {
+      allKeysAndGroups.add(
+          GroupKeyAndDataFileGroupInternal.create(groupKeyAndGroup.first, groupKeyAndGroup.second));
+    }
+
+    // Adding staleGroups to allGroups.
+    for (DataFileGroupInternal fileGroup : staleGroups) {
+      allKeysAndGroups.add(
+          GroupKeyAndDataFileGroupInternal.create(createGroupKey(fileGroup), fileGroup));
+    }
+
+    Map<String, GroupStorage> groupKeyToGroupStorage = new HashMap<>();
+    Map<String, Set<NewFileKey>> groupKeyToFileKeys = new HashMap<>();
+    Map<String, Set<NewFileKey>> downloadedGroupKeyToFileKeys = new HashMap<>();
+    Map<String, DataFileGroupInternal> downloadedGroupKeyToDataFileGroup = new HashMap<>();
+
+    Set<NewFileKey> allFileKeys = new HashSet<>();
+    // Our bytes counter has to be wrapped in an Object because variables captured by lambda
+    // expressions need to be "effectively final" - meaning they never appear on the left-hand side
+    // of an assignment statement. As such, we use AtomicLong.
+    AtomicLong totalMddBytesUsed = new AtomicLong(0L);
+
+    List<ListenableFuture<Void>> futures = new ArrayList<>();
+    for (GroupKeyAndDataFileGroupInternal groupKeyAndGroup : allKeysAndGroups) {
+
+      Set<NewFileKey> fileKeys =
+          safeGetFileKeys(
+              groupKeyToFileKeys, getGroupWithOwnerPackageKey(groupKeyAndGroup.groupKey()));
+
+      GroupStorage groupStorage =
+          safeGetGroupStorage(
+              groupKeyToGroupStorage, getGroupWithOwnerPackageKey(groupKeyAndGroup.groupKey()));
+
+      Set<NewFileKey> downloadedFileKeysInit = null;
+
+      if (groupKeyAndGroup.groupKey().getDownloaded()) {
+        downloadedFileKeysInit =
+            safeGetFileKeys(
+                downloadedGroupKeyToFileKeys,
+                getGroupWithOwnerPackageKey(groupKeyAndGroup.groupKey()));
+        downloadedGroupKeyToDataFileGroup.put(
+            getGroupWithOwnerPackageKey(groupKeyAndGroup.groupKey()),
+            groupKeyAndGroup.dataFileGroupInternal());
+      }
+
+      // Variables captured by lambdas must be effectively final.
+      Set<NewFileKey> downloadedFileKeys = downloadedFileKeysInit;
+      int totalFileCount = groupKeyAndGroup.dataFileGroupInternal().getFileCount();
+      for (DataFile dataFile : groupKeyAndGroup.dataFileGroupInternal().getFileList()) {
+        boolean isInlineFile = FileGroupUtil.isInlineFile(dataFile);
+
+        NewFileKey fileKey =
+            SharedFilesMetadata.createKeyFromDataFile(
+                dataFile, groupKeyAndGroup.dataFileGroupInternal().getAllowedReadersEnum());
+        futures.add(
+            Futures.transform(
+                computeFileSize(fileKey),
+                fileSize -> {
+                  if (!allFileKeys.contains(fileKey)) {
+                    totalMddBytesUsed.getAndAdd(fileSize);
+                    allFileKeys.add(fileKey);
+                  }
+
+                  // Check if we have processed this fileKey before.
+                  if (!fileKeys.contains(fileKey)) {
+                    if (isInlineFile) {
+                      groupStorage.totalInlineBytesUsed += fileSize;
+                    }
+
+                    groupStorage.totalBytesUsed += fileSize;
+                    fileKeys.add(fileKey);
+                  }
+
+                  if (groupKeyAndGroup.groupKey().getDownloaded()) {
+                    // Note: Nullness checker is not smart enough to figure out that
+                    // downloadedFileKeys is never null.
+                    Preconditions.checkNotNull(downloadedFileKeys);
+                    // Check if we have processed this fileKey before.
+                    if (!downloadedFileKeys.contains(fileKey)) {
+                      if (isInlineFile) {
+                        groupStorage.downloadedGroupInlineBytesUsed += fileSize;
+                        groupStorage.totalInlineFileCount += 1;
+                      }
+
+                      groupStorage.downloadedGroupBytesUsed += fileSize;
+                      downloadedFileKeys.add(fileKey);
+                    }
+                  }
+                  return null;
+                },
+                sequentialControlExecutor));
+      }
+      groupStorage.totalFileCount = totalFileCount;
+    }
+
+    return Futures.whenAllComplete(futures)
+        .call(
+            () -> {
+              Void storageStatsBuilder = null;
+              return storageStatsBuilder;
+            },
+            sequentialControlExecutor);
+  }
+
+  private String getGroupWithOwnerPackageKey(GroupKey groupKey) {
+    return new StringBuilder(groupKey.getGroupName())
+        .append(SPLIT_CHAR)
+        .append(groupKey.getOwnerPackage())
+        .toString();
+  }
+
+  private Set<NewFileKey> safeGetFileKeys(
+      Map<String, Set<NewFileKey>> groupNameToFileKeys, String groupName) {
+    Set<NewFileKey> fileKeys = groupNameToFileKeys.get(groupName);
+    if (fileKeys == null) {
+      groupNameToFileKeys.put(groupName, new HashSet<>());
+      fileKeys = groupNameToFileKeys.get(groupName);
+    }
+    return fileKeys;
+  }
+
+  private GroupStorage safeGetGroupStorage(
+      Map<String, GroupStorage> groupNameToStats, String groupName) {
+    GroupStorage groupStorage = groupNameToStats.get(groupName);
+    if (groupStorage == null) {
+      groupNameToStats.put(groupName, new GroupStorage());
+      groupStorage = groupNameToStats.get(groupName);
+    }
+    return groupStorage;
+  }
+
+  private ListenableFuture<Long> computeFileSize(NewFileKey newFileKey) {
+    return FluentFuture.from(sharedFileManager.getOnDeviceUri(newFileKey))
+        .catchingAsync(
+            SharedFileMissingException.class,
+            e -> Futures.immediateFuture(null),
+            sequentialControlExecutor)
+        .transform(
+            fileUri -> {
+              if (fileUri != null) {
+                try {
+                  return fileStorage.fileSize(fileUri);
+                } catch (IOException e) {
+                  LogUtil.e(e, "%s: Failed to call mobstore fileSize on uri %s!", TAG, fileUri);
+                }
+              }
+              return 0L;
+            },
+            sequentialControlExecutor);
+  }
+
+  @AutoValue
+  abstract static class GroupKeyAndDataFileGroupInternal {
+    static GroupKeyAndDataFileGroupInternal create(
+        GroupKey groupKey, DataFileGroupInternal dataFileGroupInternal) {
+      return new AutoValue_StorageLogger_GroupKeyAndDataFileGroupInternal(
+          groupKey, dataFileGroupInternal);
+    }
+
+    abstract GroupKey groupKey();
+
+    abstract DataFileGroupInternal dataFileGroupInternal();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/logging/testing/BUILD b/java/com/google/android/libraries/mobiledatadownload/internal/logging/testing/BUILD
new file mode 100644
index 0000000..9bf9510
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/logging/testing/BUILD
@@ -0,0 +1,31 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_library")
+
+package(
+    default_visibility = [
+        "//visibility:public",
+    ],
+    licenses = ["notice"],
+)
+
+android_library(
+    name = "FakeEventLogger",
+    testonly = 1,
+    srcs = ["FakeEventLogger.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:EventLogger",
+        "@com_google_guava_guava",
+    ],
+)
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/logging/testing/FakeEventLogger.java b/java/com/google/android/libraries/mobiledatadownload/internal/logging/testing/FakeEventLogger.java
new file mode 100644
index 0000000..e2dba29
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/logging/testing/FakeEventLogger.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.logging.testing;
+
+import static com.google.common.util.concurrent.Futures.immediateFailedFuture;
+
+import com.google.android.libraries.mobiledatadownload.internal.logging.EventLogger;
+import com.google.android.libraries.mobiledatadownload.internal.logging.EventLogger.FileGroupStatusWithDetails;
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.util.concurrent.AsyncCallable;
+import com.google.common.util.concurrent.ListenableFuture;
+import java.util.ArrayList;
+import java.util.List;
+
+/** Fake implementation of {@link EventLogger} for use in tests. */
+public final class FakeEventLogger implements EventLogger {
+
+  private final ArrayList<Integer> loggedCodes = new ArrayList<>();
+  private final ArrayListMultimap<Void, Void> loggedLatencies = ArrayListMultimap.create();
+
+  @Override
+  public void logEventSampled(int eventCode) {
+    loggedCodes.add(eventCode);
+  }
+
+  @Override
+  public void logEventSampled(
+      int eventCode,
+      String fileGroupName,
+      int fileGroupVersionNumber,
+      long buildId,
+      String variantId) {
+    loggedCodes.add(eventCode);
+  }
+
+  @Override
+  public void logEventAfterSample(int eventCode, int sampleInterval) {
+    loggedCodes.add(eventCode);
+  }
+
+  @Override
+  public ListenableFuture<Void> logMddFileGroupStats(
+      AsyncCallable<List<FileGroupStatusWithDetails>> buildFileGroupStats) {
+    return immediateFailedFuture(
+        new UnsupportedOperationException("This method is not implemented in the fake yet."));
+  }
+
+  @Override
+  public void logMddApiCallStats(Void fileGroupDetails, Void apiCallStats) {
+    throw new UnsupportedOperationException("This method is not implemented in the fake yet.");
+  }
+
+  @Override
+  public ListenableFuture<Void> logMddStorageStats(AsyncCallable<Void> buildMddStorageStats) {
+    return immediateFailedFuture(
+        new UnsupportedOperationException("This method is not implemented in the fake yet."));
+  }
+
+  @Override
+  public ListenableFuture<Void> logMddNetworkStats(AsyncCallable<Void> buildMddNetworkStats) {
+    return immediateFailedFuture(
+        new UnsupportedOperationException("This method is not implemented in the fake yet."));
+  }
+
+  @Override
+  public void logMddDataDownloadFileExpirationEvent(int eventCode, int count) {
+    throw new UnsupportedOperationException("This method is not implemented in the fake yet.");
+  }
+
+  @Override
+  public void logMddNetworkSavings(
+      Void fileGroupDetails,
+      int code,
+      long fullFileSize,
+      long downloadedFileSize,
+      String fileId,
+      int deltaIndex) {
+    throw new UnsupportedOperationException("This method is not implemented in the fake yet.");
+  }
+
+  @Override
+  public void logMddDownloadResult(int code, Void fileGroupDetails) {
+    throw new UnsupportedOperationException("This method is not implemented in the fake yet.");
+  }
+
+  @Override
+  public void logMddQueryStats(Void fileGroupDetails) {
+    throw new UnsupportedOperationException("This method is not implemented in the fake yet.");
+  }
+
+  @Override
+  public void logMddAndroidSharingLog(Void event) {
+    throw new UnsupportedOperationException("This method is not implemented in the fake yet.");
+  }
+
+  @Override
+  public void logMddDownloadLatency(Void fileGroupStats, Void downloadLatency) {
+    loggedLatencies.put(fileGroupStats, downloadLatency);
+  }
+
+  @Override
+  public void logMddUsageEvent(Void fileGroupDetails, Void usageEventLog) {
+    throw new UnsupportedOperationException("This method is not implemented in the fake yet.");
+  }
+
+  public List<Integer> getLoggedCodes() {
+    return loggedCodes;
+  }
+
+  public ArrayListMultimap<Void, Void> getLoggedLatencies() {
+    return loggedLatencies;
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/proto/BUILD b/java/com/google/android/libraries/mobiledatadownload/internal/proto/BUILD
new file mode 100644
index 0000000..d6f0c9d
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/proto/BUILD
@@ -0,0 +1,34 @@
+# Copyright 2022 Google LLC
+#
+# 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(
+    default_visibility = ["//:__subpackages__"],
+    licenses = ["notice"],
+)
+
+proto_library(
+    name = "metadata_proto",
+    srcs = ["metadata.proto"],
+    cc_api_version = 2,
+    deps = [
+        "//proto:transform_proto",
+        "@com_google_protobuf//:any_proto",
+        "@com_google_protobuf//:timestamp_proto",
+    ],
+    alwayslink = 1,
+)
+
+java_lite_proto_library(
+    name = "metadata_java_proto_lite",
+    deps = [":metadata_proto"],
+)
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/proto/metadata.proto b/java/com/google/android/libraries/mobiledatadownload/internal/proto/metadata.proto
new file mode 100644
index 0000000..cab8a0f
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/proto/metadata.proto
@@ -0,0 +1,673 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+// The main purpose of mirroring external protos here is to separate API and
+// storage protos. The same tag numbers are used for making migration work.
+
+syntax = "proto2";
+
+package mdi.download.internal;
+
+import "google/protobuf/any.proto";
+import "google/protobuf/timestamp.proto";
+import "proto/transform.proto";
+
+option java_package = "com.google.mobiledatadownload.internal";
+option java_outer_classname = "MetadataProto";
+
+// Mirrors mdi.download.ExtraHttpHeader
+//
+// HTTP headers are described in https://tools.ietf.org/html/rfc7230#section-3.2
+// as key:value, where the value may have a whitespace on each end.
+message ExtraHttpHeader {
+  optional string key = 1;
+  optional string value = 2;
+}
+
+// This proto is used to store file group metadata on disk for internal use. It
+// consists of all fields mirrored from DataFileGroup and an extra field for
+// bookkeeping.
+//
+// The tag number of extra fields should start from 1000 to reserve room for
+// growing DataFileGroup.
+//
+// Next id: 1000
+message DataFileGroupInternal {
+  // Extra information that is kept on disk.
+  //
+  // The extension was originally introduced in cl/248813966. We are migrating
+  // away from the extension. However, we still need to read from fields in the
+  // extension. Reuse the same tag number as the extension number to read from
+  // the extension.
+  optional DataFileGroupBookkeeping bookkeeping = 248813966;
+
+  // Unique name to identify the group. It should be unique per owner package.
+  // In GMSCore, use the module name as the prefix of the group name.
+  //
+  // Ex: A group name in mdisync module could be named: mdisync-profile-photos.
+  //
+  // This shouldn't ideally be something like "config", and
+  // instead should better define the feature it will be used for.
+  //
+  // Ex: "icing-language-detection-model", "smart-action-detection-model"
+  optional string group_name = 1;
+
+  // The name of the package that owns this group. If this field is left empty,
+  // the owner is assumed to be the package name of the host app.
+  //
+  // The files will only be downloaded onto the device if the owner package is
+  // present on the device.
+  //
+  // Ex: "com.google.android.gms", "com.google.android.apps.bugle"
+  optional string owner_package = 6;
+
+  // Client set version number used to identify the file group.
+  //
+  // Note that this does not uniquely identify the contents of the file group.
+  // It simply reflects a snapshot of client config changes.
+  // For example: say there's a file group 'language-detector-model' that
+  // downloads a different file per user locale.
+  // data_file_group {
+  //   file_group_name = 'language-detector-model'
+  //   file_group_version_number = 1
+  //   file {
+  //      url = 'en-model'
+  //   }
+  // }
+  // data_file_group {
+  //   file_group_name = 'language-detector-model'
+  //   file_group_version_number = 1
+  //   file {
+  //      url = 'es-model'
+  //   }
+  // }
+  // Note that even though the actual contents of the file group are different
+  // for each locale, the version is the same because this config was pushed
+  // at the same snapshot.
+  //
+  // Available GMS v18+.
+  optional int32 file_group_version_number = 10;
+
+  // DEPRECATED
+  // MDD team recommends to use explicit properties instead.
+  optional google.protobuf.Any custom_property = 20 [deprecated = true];
+
+  // Custom metadata attached to the file group.
+  //
+  // This allows clients to include specific metadata about the group for their
+  // own processing purposes. The metadata will be stored with the group and
+  // accessible when the file group is retrieved.
+  //
+  // This property should only be used if absolutely necessary. Please consult
+  // with <internal>@ if you have questions about this property or a potential
+  // use-case.
+  optional google.protobuf.Any custom_metadata = 27;
+
+  reserved 21;
+
+  // Mirrors mdi.download.DataFileGroup.AllowedReaders
+  enum AllowedReaders {
+    ALL_GOOGLE_APPS = 0;
+    ONLY_GOOGLE_PLAY_SERVICES = 1;
+    ALL_APPS = 2;
+  }
+
+  // Defines who is allowed to read this file group. Currently the options are:
+  //
+  // ALL_GOOGLE_APPS: accessible to all Google 1p Apps.
+  // ONLY_GOOGLE_PLAY_SERVICES: accessible to only GMS Core.
+  //
+  // If this field is not explicitly set it defaults to "ALL_GOOGLE_APPS".
+  //
+  // Available GMS v20+.
+  optional AllowedReaders allowed_readers_enum = 12;
+
+  // Length of time (in seconds) for which a file group version will live after
+  // since a newer version became fully downloaded. Clients should set this time
+  // to be more than the time in which they call MDD to refresh their data.
+  // NOTE: MDD will delete the file group version within a day of this time.
+  // Ex: 172800  // 2 Days
+  optional int64 stale_lifetime_secs = 3;
+
+  // The timestamp at which this filegroup should be deleted, even if it is
+  // still active, specified in seconds since epoch.
+  // NOTE: MDD will delete the file group version within a day of this time.
+  optional int64 expiration_date_secs = 11;
+
+  // Specify the conditions under which the file group should be downloaded.
+  optional DownloadConditions download_conditions = 13;
+
+  // Setting this flag to true will mean that the downloaded files will appear
+  // to be in a directory by themselves.
+  // The file name/file path of the exposed file will be the filename set in the
+  // file.relative_file_path field, OR if that field is empty, the file name
+  // from the file.url_to_download field. This enables downloaded files to refer
+  // to each other by name.
+  // It's invalid to set this flag to true if two files end up with the same
+  // file path.
+  // Valid on iOS, cMDD, and aMDD.
+  //
+  // NOTE: For aMDD, this feature is not available if Android Blob Sharing is
+  // enabled or if using an API level below 21 (L). If either case is true, this
+  // option will be ignored.
+  optional bool preserve_filenames_and_isolate_files = 14;
+
+  // List of files in the group.
+  repeated DataFile file = 2;
+
+  // Tag for the network traffic to download this file group.
+  // Tag space is determined by the host app.
+  // For Gmscore, the tag should come from:
+  // <internal>
+  optional int32 traffic_tag = 16;
+
+  // Extra HTTP headers to apply when downloading all files in the group.
+  repeated ExtraHttpHeader group_extra_http_headers = 17;
+
+  reserved 19;
+
+  // Unique identifier of a DataFileGroup config (i.e. a "snapshot") created
+  // when using MDD Ingress API.
+  optional int64 build_id = 23;
+
+  // A fingerprint allowing clients to identify a DataFileGroup
+  // config based on a given set of properties (i.e. a "partition" of
+  // any file group properties). This can be used by clients as an exact match
+  // for a class of DataFileGroups during targeting or as a compatibility check.
+  optional string variant_id = 26;
+
+  // The locales compatible with the file group. This can be different from the
+  // device locale.
+  //
+  // Values in this list may be exact locales (e.g. "en-US") or language-only
+  // ("en-*").
+  // Example 1: locale = ["en-US"]; // compatible with "en-US" only
+  // Example 2: locale = ["en-US", "en-CA"]; // compatible with "en-US" or
+  //                                         // "en-CA"
+  // Example 3: locale = ["en-*"]; // compatible with all "en" locales
+  repeated string locale = 25;
+
+  reserved 28;
+
+  reserved 4, 5, 7, 8, 9, 15, 18, 22, 24;
+}
+
+// Mirrors mdi.download.DataFile
+//
+// A data file represents all the metadata to download the file and then
+// manage it on the device.
+// Next tag: 22
+//
+// This should not contain any fields that are marked internal, as we compare
+// the protos directly to decide if it is a new version of the file.
+// LINT.IfChange(data_file)
+message DataFile {
+  // A unique identifier of the file within the group, that can be used to
+  // get this file from the group.
+  // Ex: "language-detection-model"
+  optional string file_id = 7;
+
+  // Url from where the file is to be downloaded.
+  // Ex: https://www.gstatic.com/group-name/model_1234.zip
+  optional string url_to_download = 2;
+
+  // Exact size of the file. This is used to check if there is space available
+  // for the file before scheduling the download.
+  // The byte_size is optional. If not set, MDD will try with best effort to get
+  // the file size using the HTTP HEAD request.
+  optional int32 byte_size = 4;
+
+  // Enum for checksum types.
+  // NOTE: do not add any new checksum type here, older MDD versions would break
+  // otherwise.
+  enum ChecksumType {
+    // Default checksum is SHA1.
+    DEFAULT = 0;
+
+    // No checksum is provided.
+    NONE = 1;
+
+    reserved 2 /* SHA256 */;
+  }
+
+  optional ChecksumType checksum_type = 15;
+
+  // SHA1 checksum to verify the file before it can be used. This is also used
+  // to de-duplicate files between different groups.
+  // For most files, this will be the checksum of the file being downloaded.
+  // For files with download_transform, this should contain the transform of
+  // the file after the transforms have been applied.
+  // The checksum is optional. If not set, the checksum_type must be
+  // ChecksumType.NONE.
+  optional string checksum = 5;
+
+  // The following are <internal> transforms to apply to the downloaded files.
+  // Transforms are bi-directional and defined in terms of what they do on
+  // write. Since these transforms are applied while reading, their
+  // directionality is reversed. Eg, you'll see 'compress' to indicate that the
+  // file should be decompressed.
+
+  // These transforms are applied once by MDD after downloading the file.
+  // Currently only compress is available.
+  // Valid on Android. iOS support is tracked by b/118828045.
+  optional mobstore.proto.Transforms download_transforms = 11;
+
+  // If DataFile has download_transforms, this field must be provided with the
+  // SHA1 checksum of the file before any transform are applied. The original
+  // checksum would also be checked after the download_transforms are applied.
+  optional string downloaded_file_checksum = 14;
+
+  // Exact size of the downloaded file. If the DataFile has download transforms
+  // like compress and zip, the downloaded file size would be different than
+  // the final file size on disk. Client could use
+  // this field to track the downloaded file size and calculate the download
+  // progress percentage. This field is not used by MDD currently.
+  optional int32 downloaded_file_byte_size = 16;
+
+  // These transforms are evaluated by the caller on-the-fly when reading the
+  // data with MobStore. Any transforms installed in the caller's MobStore
+  // instance is available.
+  // Valid on Android. iOS support is tracked by b/118759254.
+  optional mobstore.proto.Transforms read_transforms = 12;
+
+  // List of delta files that can be encoded and decoded with base files.
+  // If the device has any base file, the delta file which is much
+  // smaller will be downloaded instead of the full file.
+  // For most clients, only one delta file should be enough. If specifying
+  // multiple delta files, they should be in a sequence from the most recent
+  // base file to the oldest.
+  // This is currently only supported on Android.
+  repeated DeltaFile delta_file = 13;
+
+  enum AndroidSharingType {
+    // The dataFile isn't available for sharing.
+    UNSUPPORTED = 0;
+
+    // If sharing with the Android Blob Sharing Service isn't available, fall
+    // back to normal behavior, i.e. download locally.
+    ANDROID_BLOB_WHEN_AVAILABLE = 1;
+  }
+
+  // Defines whether the file should be shared and how.
+  // NOTE: currently this field is only used by aMDD and has no effect on iMDD.
+  optional AndroidSharingType android_sharing_type = 17;
+
+  // Enum for android sharing checksum types.
+  enum AndroidSharingChecksumType {
+    NOT_SET = 0;
+
+    // If the file group should be shared through the Android Blob Sharing
+    // Service, the checksum type must be set to SHA256.
+    SHA2_256 = 1;
+  }
+
+  optional AndroidSharingChecksumType android_sharing_checksum_type = 18;
+
+  // Checksum used to access files through the Android Blob Sharing Service.
+  optional string android_sharing_checksum = 19;
+
+  // Relative file path and file name to be preserved within the parent
+  // directory when creating symlinks for the file groups that have
+  // preserve_filenames_and_isolate_files set to true.
+  // This filename should NOT start or end with a '/', and it can not contain
+  // the substring '..'.
+  // Working example: "subDir/FileName.txt".
+  optional string relative_file_path = 20;
+
+  // Custom metadata attached to the file.
+  //
+  // This allows clients to include specific metadata about the file for their
+  // own processing purposes. The metadata will be stored with the file and
+  // accessible when the file's file group is retrieved.
+  //
+  // This property should only be used if absolutely necessary. Please consult
+  // with <internal>@ if you have questions about this property or a potential
+  // use-case.
+  optional google.protobuf.Any custom_metadata = 21;
+
+  reserved 1, 3, 6, 8, 9;
+}
+// LINT.ThenChange(
+//     <internal>,
+//     <internal>)
+
+// Mirrors mdi.download.DeltaFile
+//
+// A delta file represents all the metadata to download for a diff file encoded
+// based on a base file
+// LINT.IfChange(delta_file)
+message DeltaFile {
+  // These fields all mirror the similarly-named fields in DataFile.
+  optional string url_to_download = 1;
+  optional int32 byte_size = 2;
+  optional string checksum = 3;
+
+  // Enum of all diff decoders supported
+  enum DiffDecoder {
+    // Default to have no diff decoder specified, will thrown unsupported
+    // exception
+    UNSPECIFIED = 0;
+
+    // VcDIFF decoder
+    // Generic Differencing and Compression Data Format
+    // For more information, please refer to rfc3284
+    // The VcDiff decoder for GMS service:
+    // <internal>
+    VC_DIFF = 1;
+  }
+  // The diff decoder used to generate full file with delta and base file.
+  // For MDD as a GMS service, a VcDiff decoder will be registered and injected
+  // in by default. Using MDD as a library, clients need to register and inject
+  // in a VcDiff decoder, otherwise, an exception will be thrown.
+  optional DiffDecoder diff_decoder = 5;
+
+  // The base file represents to a full file on device. It should contain the
+  // bare minimum fields of a DataFile to identify a DataFile on device.
+  optional BaseFile base_file = 6;
+
+  reserved 4;
+}
+// LINT.ThenChange(
+//     <internal>,
+//     <internal>)
+
+// Mirrors mdi.download.BaseFile
+message BaseFile {
+  // SHA1 checksum of the base file to identify a file on device. It should
+  // match the checksum field of the base file used to generate the delta file.
+  optional string checksum = 1;
+}
+
+// Mirrors mdi.download.DownloadConditions
+//
+// Next id: 5
+message DownloadConditions {
+  // TODO(b/143548753): The first value in an enum must have a specific prefix.
+  enum DeviceStoragePolicy {
+    // MDD will block download of files in android low storage. Currently MDD
+    // doesn't delete the files in case the device reaches low storage
+    // after the file has been downloaded.
+    BLOCK_DOWNLOAD_IN_LOW_STORAGE = 0;
+
+    // Block download of files only under a lower threshold defined here
+    // <internal>
+    BLOCK_DOWNLOAD_LOWER_THRESHOLD = 1;
+
+    // Set the storage threshold to an extremely low value when downloading.
+    // IMPORTANT: if the download make the device runs out of disk, this could
+    // render the device unusable.
+    // This should only be used for critical use cases such as privacy
+    // violations. Emergency fix should not belong to this category. Please
+    // talks to <internal>@ when you want to use this option.
+    EXTREMELY_LOW_THRESHOLD = 2;
+  }
+
+  // Specify the device storage under which the files should be downloaded.
+  // By default, the files will only be downloaded if the device is not in
+  // low storage.
+  optional DeviceStoragePolicy device_storage_policy = 1;
+
+  // TODO(b/143548753): The first value in an enum must have a specific prefix.
+  enum DeviceNetworkPolicy {
+    // Only download files on wifi.
+    DOWNLOAD_ONLY_ON_WIFI = 0;
+
+    // Allow download on any network including wifi and cellular.
+    DOWNLOAD_ON_ANY_NETWORK = 1;
+
+    // Allow downloading only on wifi first, then after a configurable time
+    // period set in the field download_first_on_wifi_period_secs below,
+    // allow downloading on any network including wifi and cellular.
+    DOWNLOAD_FIRST_ON_WIFI_THEN_ON_ANY_NETWORK = 2;
+  }
+
+  // Specify the device network under which the files should be downloaded.
+  // By default, the files will only be downloaded on wifi.
+  //
+  // If your feature targets below v20 and want to download on cellular in
+  // these versions of gms, also set allow_dowload_without_wifi = true;
+  optional DeviceNetworkPolicy device_network_policy = 2;
+
+  // This field will only be used when the
+  // DeviceNetworkPolicy = DOWNLOAD_FIRST_ON_WIFI_THEN_ON_ANY_NETWORK
+  // MDD will download the file only on wifi for this period of time. If the
+  // download was not finished, MDD will download on any network including
+  // wifi and cellular.
+  // Ex: 604800  // 7 Days
+  optional int64 download_first_on_wifi_period_secs = 4;
+
+  // TODO(b/143548753): The first value in an enum must have a specific prefix.
+  enum ActivatingCondition {
+    // The download is activated as soon the server side config is received and
+    // the server configured download conditions are satisfied.
+    ALWAYS_ACTIVATED = 0;
+
+    // The download is activated when both server side activation conditions
+    // are satisfied and the client has activated the download on device.
+    //
+    // Clients can activate this group using the activateFileGroup API.
+    // <internal>
+    DEVICE_ACTIVATED = 1;
+  }
+
+  // Specify how the download is activated. By default, the download is
+  // activated as soon as server configured activating conditions are satisfied.
+  optional ActivatingCondition activating_condition = 3;
+}
+
+// This proto contains extra information about a file group for bookkeeping.
+// Next tag: 6
+message DataFileGroupBookkeeping {
+  // The epoch time (seconds since 1/1/1970) at which this stale file group will
+  // be deleted.
+  optional int64 stale_expiration_date = 1;
+
+  // The timestamp (epoch time, milliseconds since 1/1/1970) that the file group
+  // was first received.
+  //
+  // If this is an update on an existing group, then the timestamp from the old
+  // group is used if no files were updated.
+  optional int64 group_new_files_received_timestamp = 2;
+
+  // The timestamp (epoch time, milliseconds since 1/1/1970) at which the group
+  // was first marked as downloaded.
+  optional int64 group_downloaded_timestamp_in_millis = 3;
+
+  // The timestamp (epoch time, milliseconds since 1/1/1970) that MDD starts
+  // downloading the file group for the first time.
+  optional int64 group_download_started_timestamp_in_millis = 4;
+
+  // The total count of download periodic tasks needed to fully download the
+  // file group.
+  optional int32 download_started_count = 5;
+}
+
+// Key used by mdd to uniquely identify a client group.
+message GroupKey {
+  // The name of the group.
+  optional string group_name = 1;
+
+  // The package name of the group owner. A null value or empty value means
+  // that the group is not associated with any package.
+  optional string owner_package = 2;
+
+  // The account associated to the file group.
+  optional string account = 5;
+
+  // Whether or not all files in a fileGroup have been downloaded.
+  optional bool downloaded = 4;
+
+  // The variant id of the group. A null or empty value indicates that the group
+  // does not have an associated variant.
+  optional string variant_id = 6;
+
+  reserved 3;
+}
+
+// Group Key properties that apply to all groups with that key.
+message GroupKeyProperties {
+  // Whether this group key has been activated on the device.
+  optional bool activated_on_device = 1;
+}
+
+// SharedFile is a internal data structure of the SharedFileManager.
+message SharedFile {
+  optional string file_name = 4;
+  optional FileStatus file_status = 5;
+
+  // This field will be used to determine if a file can be retrieved from the
+  // Android Blob Sharing Service.
+  optional bool android_shared = 8;
+
+  // The maximum expiration date found for a downloaded data file. If
+  // {@code android_shared} is set to true, this field stores the current lease
+  // expiration date. The default value is 0.
+  // See <internal> for more details.
+  optional int64 max_expiration_date_secs = 9;
+
+  // Checksum used to access files through the Android Blob Sharing Service.
+  optional string android_sharing_checksum = 10;
+
+  // If the file is downloaded successfully but fails checksum matching, we will
+  // attempt to delete the file so it can be redownloaded from scratch. To
+  // prevent unnecessary network bandwidth, we keep track of the number of these
+  // attempts in this field and stop after a certain number. (configurable by a
+  // download flag).
+  optional int32 checksum_mismatch_retry_download_count = 11;
+
+  reserved 1, 2, 3, 6, 7;
+}
+
+// Metadata used by
+// com.google.android.libraries.mdi.download.MobileDataDownloadManager
+message MobileDataDownloadManagerMetadata {
+  optional bool mdd_migrated_to_offroad = 1;
+  optional int32 reset_trigger = 2;
+}
+
+// Metadata used by
+// com.google.android.libraries.mdi.download.SharedFileManager
+message SharedFileManagerMetadata {
+  optional bool migrated_to_new_file_key = 1;
+  optional int64 next_file_name = 2;
+}
+
+// Collects all data used by
+// com.google.android.libraries.mdi.download.internal.Migrations
+message MigrationsStore {
+  enum FileKeyVersion {
+    NEW_FILE_KEY = 0;
+    ADD_DOWNLOAD_TRANSFORM = 1;
+    USE_CHECKSUM_ONLY = 2;
+  }
+  optional bool is_migrated_to_new_file_key = 1;
+  optional FileKeyVersion current_version = 2;
+}
+
+// Collects all data used by
+// com.google.android.libraries.mdi.download.internal.FileGroupsMetadata
+message FileGroupsMetadataStore {
+  // Key must be a serialized GroupKey.
+  map<string, DataFileGroupInternal> data_file_groups = 1;
+  // Key must be a serialized GroupKey.
+  map<string, GroupKeyProperties> group_key_properties = 2;
+  repeated DataFileGroupInternal stale_groups = 3;
+}
+
+// Collects all data used by
+// com.google.android.libraries.mdi.download.internal.SharedFilesMetadata
+message SharedFilesMetadataStore {
+  // The key must be a serialized NewFileKey.
+  map<string, SharedFile> shared_files = 1;
+}
+
+enum FileStatus {
+  // The file has never been seen before.
+  NONE = 0;
+  // The file has been subscribed to, but download has not been attempted.
+  SUBSCRIBED = 1;
+  // The file download is currently in progress.
+  DOWNLOAD_IN_PROGRESS = 2;
+  // Downloading the file failed.
+  DOWNLOAD_FAILED = 3;
+  // The file was downloaded completely, and is available for use.
+  DOWNLOAD_COMPLETE = 4;
+  // The file was corrupted or lost after being successfully downloaded.
+  CORRUPTED = 6;
+  // Status returned when their is an error while getting the file status.
+  // This is never saved on disk.
+  INTERNAL_ERROR = 5;
+}
+
+// Key used by the SharedFileManager to identify a shared file.
+message NewFileKey {
+  // These fields all mirror the similarly-named fields in DataFile.
+  optional string url_to_download = 1 [deprecated = true];
+  optional int32 byte_size = 2 [deprecated = true];
+  optional string checksum = 3;
+  optional DataFileGroupInternal.AllowedReaders allowed_readers = 4;
+  optional mobstore.proto.Transforms download_transforms = 5
+      [deprecated = true];
+}
+
+// This proto is used to store state for logging. See details at
+// <internal>
+message LoggingState {
+  // The last time maintenance was run. This should be updated every time
+  // maintenance is run.
+  // Note: the current implementation only uses this to determine the date of
+  // the last log event, but in the future we may want more granular
+  // measurements for this, so we store the timestamp as-is.
+  optional google.protobuf.Timestamp last_maintenance_run_timestamp = 1;
+
+  // File group specific logging state keyed by GroupKey, build id and version
+  // number.
+  repeated FileGroupLoggingState file_group_logging_state = 2;
+
+  // Set to true once the shared preferences migration is complete.
+  // Note: this field isn't strictly necessary at the moment - we could just
+  // check that the file_group_logging_state is empty since no one should write
+  // to the network usage monitor shared prefs since the migration will be
+  // installed at the same cl where the code is removed. However, if we were to
+  // add more fields to FileGroupLoggingState, it would be less straightforward
+  // to check for migration state - so having this boolean makes it a bit safer.
+  optional bool shared_preferences_network_usage_monitor_migration_complete = 3;
+
+  // Info to enable stable sampling. See <internal> for more
+  // info. This field will be set by a migration on first access.
+  optional SamplingInfo sampling_info = 4;
+}
+
+// This proto is used to store state for logging that is specific to a File
+// Group. This includes network usage logging and maybe download tiers (for
+// <internal>).
+message FileGroupLoggingState {
+  optional GroupKey group_key = 1;
+  optional int64 build_id = 2;
+  optional int32 file_group_version_number = 3;
+  optional int64 cellular_usage = 4;
+  optional int64 wifi_usage = 5;
+}
+
+// Next id: 3
+message SamplingInfo {
+  // Random number generated and persisted on device. This number should not
+  // change (unless device storage/mdd is cleared). It is used as a stable
+  // identifier to determine whether MDD should log events.
+  optional int64 stable_log_sampling_salt = 1;
+
+  // When the stable_log_sampling_salt was first set. This will be used during
+  // roll out to determine which devices have enabled stable sampling for a
+  // sufficient time period.
+  optional google.protobuf.Timestamp log_sampling_salt_set_timestamp = 2;
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/util/AndroidSharingUtil.java b/java/com/google/android/libraries/mobiledatadownload/internal/util/AndroidSharingUtil.java
new file mode 100644
index 0000000..96ed7dc
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/util/AndroidSharingUtil.java
@@ -0,0 +1,231 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.util;
+
+import android.content.Context;
+import android.net.Uri;
+import android.text.TextUtils;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.common.LimitExceededException;
+import com.google.android.libraries.mobiledatadownload.file.common.MalformedUriException;
+import com.google.android.libraries.mobiledatadownload.file.common.UnsupportedFileStorageOperation;
+import com.google.android.libraries.mobiledatadownload.file.openers.ReadStreamOpener;
+import com.google.android.libraries.mobiledatadownload.file.openers.WriteStreamOpener;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil;
+import com.google.common.io.ByteStreams;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFile;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+/** Utils for Android file sharing. */
+public final class AndroidSharingUtil {
+
+  private static final String TAG = "AndroidSharingUtil";
+
+  /**
+   * Exception thrown if an eror occurs while trying to share a file with the Android Blob Sharing
+   * Service.
+   */
+  public static final class AndroidSharingException extends Exception {
+    // The error code to be logged.
+    private final int errorCode;
+
+    public AndroidSharingException(int errorCode, String message) {
+      super(message);
+      this.errorCode = errorCode;
+    }
+
+    public int getErrorCode() {
+      return errorCode;
+    }
+  }
+
+  private AndroidSharingUtil() {}
+
+  /** Returns true if a blob with checksum {@code checksum} already exists in the shared storage. */
+  public static boolean blobExists(
+      Context context,
+      String checksum,
+      DataFileGroupInternal fileGroup,
+      DataFile dataFile,
+      SynchronousFileStorage fileStorage)
+      throws AndroidSharingException {
+    int errorCode = -1;
+    boolean exists = false;
+    String message = "";
+    try {
+      Uri blobUri = DirectoryUtil.getBlobUri(context, checksum);
+      exists = fileStorage.exists(blobUri);
+    } catch (UnsupportedFileStorageOperation e) {
+      String msg = TextUtils.isEmpty(e.getMessage()) ? "" : e.getMessage();
+      LogUtil.v(
+          "%s: Failed to share for file %s, file group %s."
+              + " UnsupportedFileStorageOperation was thrown with message \"%s\"",
+          TAG, dataFile.getFileId(), fileGroup.getGroupName(), msg);
+      errorCode = 0;
+      message = "UnsupportedFileStorageOperation was thrown: " + msg;
+    } catch (MalformedUriException e) {
+      LogUtil.e(
+          "%s: Malformed lease uri file %s, file group %s",
+          TAG, dataFile.getFileId(), fileGroup.getGroupName());
+      errorCode = 0;
+      message =
+          String.format(
+              "Malformed blob Uri for file %s, group %s",
+              dataFile.getFileId(), fileGroup.getGroupName());
+    } catch (IOException e) {
+      LogUtil.e(
+          "%s: Failed to check existence in the shared storage for file %s, file group" + " %s",
+          TAG, dataFile.getFileId(), fileGroup.getGroupName());
+      errorCode = 0;
+      message =
+          String.format(
+              "Error while checking if file %s, group %s, exists in the shared blob storage.",
+              dataFile.getFileId(), fileGroup.getGroupName());
+    }
+    if (errorCode != -1) {
+      throw new AndroidSharingException(errorCode, message);
+    }
+    return exists;
+  }
+
+  /**
+   * Copies the local {@code downloadFileOnDeviceUri} to the blob storage.
+   *
+   * @param afterDownload whether this function is called before or after the {@code dataFile}'s
+   *     download.
+   */
+  public static void copyFileToBlobStore(
+      Context context,
+      String checksum,
+      Uri downloadFileOnDeviceUri,
+      DataFileGroupInternal fileGroup,
+      DataFile dataFile,
+      SynchronousFileStorage fileStorage,
+      boolean afterDownload)
+      throws AndroidSharingException {
+    int errorCode = -1;
+    String message = "";
+    try {
+      Uri blobUri = DirectoryUtil.getBlobUri(context, checksum);
+      try (InputStream in = fileStorage.open(downloadFileOnDeviceUri, ReadStreamOpener.create());
+          OutputStream out = fileStorage.open(blobUri, WriteStreamOpener.create())) {
+        ByteStreams.copy(in, out);
+      }
+    } catch (UnsupportedFileStorageOperation e) {
+      String msg = TextUtils.isEmpty(e.getMessage()) ? "" : e.getMessage();
+      LogUtil.v(
+          "%s: Failed to share after download for file %s, file group %s."
+              + " UnsupportedFileStorageOperation was thrown with message \"%s\"",
+          TAG, dataFile.getFileId(), fileGroup.getGroupName(), msg);
+      errorCode = 0;
+      message = "UnsupportedFileStorageOperation was thrown: " + msg;
+    } catch (LimitExceededException e) {
+      LogUtil.e(
+          "%s: Failed to share after download for file %s, file group %s due to"
+              + " LimitExceededException",
+          TAG, dataFile.getFileId(), fileGroup.getGroupName());
+      errorCode = 0;
+      message =
+          String.format(
+              "System limit exceeded for file %s, group %s",
+              dataFile.getFileId(), fileGroup.getGroupName());
+    } catch (MalformedUriException e) {
+      LogUtil.e(
+          "%s: Malformed lease uri file %s, file group %s",
+          TAG, dataFile.getFileId(), fileGroup.getGroupName());
+      errorCode = 0;
+      message =
+          String.format(
+              "Malformed blob Uri for file %s, group %s",
+              dataFile.getFileId(), fileGroup.getGroupName());
+    } catch (IOException e) {
+      LogUtil.e(
+          "%s: Failed to copy to the blobstore after download for file %s, file group" + " %s",
+          TAG, dataFile.getFileId(), fileGroup.getGroupName());
+      errorCode = afterDownload ? 0 : 0;
+      message =
+          String.format(
+              "Error while copying file %s, group %s, to the shared blob storage",
+              dataFile.getFileId(), fileGroup.getGroupName());
+    }
+    if (errorCode != -1) {
+      throw new AndroidSharingException(errorCode, message);
+    }
+  }
+
+  /** Acquires the lease on the shared {@code dataFile}. */
+  public static void acquireLease(
+      Context context,
+      String checksum,
+      long expiryDate,
+      DataFileGroupInternal fileGroup,
+      DataFile dataFile,
+      SynchronousFileStorage fileStorage)
+      throws AndroidSharingException {
+    int errorCode = -1;
+    String message = "";
+    try {
+      Uri leaseUri = DirectoryUtil.getBlobStoreLeaseUri(context, checksum, expiryDate);
+      // Acquires/updates the lease to the blob.
+      // TODO(b/149260496): catch LimitExceededException, thrown when a lease could not be acquired,
+      // such as when the caller is trying to acquire leases on too much data.
+      try (OutputStream out = fileStorage.open(leaseUri, WriteStreamOpener.create())) {}
+
+    } catch (UnsupportedFileStorageOperation e) {
+      String msg = TextUtils.isEmpty(e.getMessage()) ? "" : e.getMessage();
+      LogUtil.v(
+          "%s: Failed to share file %s, file group %s."
+              + " UnsupportedFileStorageOperation was thrown with message \"%s\"",
+          TAG, dataFile.getFileId(), fileGroup.getGroupName(), msg);
+      errorCode = 0;
+      message = "UnsupportedFileStorageOperation was thrown: " + msg;
+    } catch (MalformedUriException e) {
+      LogUtil.e(
+          "%s: Malformed lease uri file %s, file group %s",
+          TAG, dataFile.getFileId(), fileGroup.getGroupName());
+      errorCode = 0;
+      message =
+          String.format(
+              "Malformed lease Uri for file %s, group %s",
+              dataFile.getFileId(), fileGroup.getGroupName());
+    } catch (LimitExceededException e) {
+      LogUtil.e(
+          "%s: Failed to share after download for file %s, file group %s due to"
+              + " LimitExceededException",
+          TAG, dataFile.getFileId(), fileGroup.getGroupName());
+      errorCode = 0;
+      message =
+          String.format(
+              "System limit exceeded for file %s, group %s",
+              dataFile.getFileId(), fileGroup.getGroupName());
+    } catch (IOException e) {
+      LogUtil.e(
+          "%s: Failed to acquire lease for file %s, file group" + " %s",
+          TAG, dataFile.getFileId(), fileGroup.getGroupName());
+      errorCode = 0;
+      message =
+          String.format(
+              "Error while acquiring lease for file %s, group %s",
+              dataFile.getFileId(), fileGroup.getGroupName());
+    }
+    if (errorCode != -1) {
+      throw new AndroidSharingException(errorCode, message);
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/util/BUILD b/java/com/google/android/libraries/mobiledatadownload/internal/util/BUILD
new file mode 100644
index 0000000..de5e844
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/util/BUILD
@@ -0,0 +1,159 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_library")
+
+package(
+    default_visibility = ["//:__subpackages__"],
+    licenses = ["notice"],
+)
+
+android_library(
+    name = "Either",
+    srcs = ["Either.java"],
+    deps = ["@org_checkerframework_qual"],
+)
+
+android_library(
+    name = "SharedPreferencesUtil",
+    srcs = ["SharedPreferencesUtil.java"],
+    deps = [
+        "@com_google_code_findbugs_jsr305",
+        "@com_google_errorprone_error_prone_annotations",
+        "@com_google_guava_guava",
+        "@com_google_protobuf//:protobuf_lite",
+    ],
+)
+
+android_library(
+    name = "AndroidSharingUtil",
+    srcs = ["AndroidSharingUtil.java"],
+    deps = [
+        ":DirectoryUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:stream",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LogUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "FileGroupUtil",
+    srcs = ["FileGroupUtil.java"],
+    deps = [
+        ":DirectoryUtil",
+        "//java/com/google/android/libraries/mobiledatadownload:TimeSource",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:recursive_delete",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:MddConstants",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "//proto:transform_java_proto_lite",
+        "@com_google_code_findbugs_jsr305",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "FileGroupsMetadataUtil",
+    srcs = ["FileGroupsMetadataUtil.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LogUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:ProtoLiteUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:SharedPreferencesUtil",
+        "@com_google_guava_guava",
+        "@com_google_protobuf//:protobuf_lite",
+    ],
+)
+
+android_library(
+    name = "FuturesUtil",
+    srcs = ["FuturesUtil.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/internal/annotations:SequentialControlExecutor",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "DirectoryUtil",
+    srcs = ["DirectoryUtil.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload:SilentFeedback",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:android",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:blob_uri",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LogUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "@androidx_annotation_annotation",
+        "@com_google_code_findbugs_jsr305",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "ProtoLiteUtil",
+    srcs = ["ProtoLiteUtil.java"],
+    deps = [
+        "@androidx_annotation_annotation",
+        "@com_google_protobuf//:protobuf_lite",
+    ],
+)
+
+android_library(
+    name = "ProtoConversionUtil",
+    srcs = ["ProtoConversionUtil.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "//proto:download_config_java_proto_lite",
+        "@com_google_protobuf//:protobuf_lite",
+    ],
+)
+
+android_library(
+    name = "SharedFilesMetadataUtil",
+    srcs = ["SharedFilesMetadataUtil.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload:SilentFeedback",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:MddConstants",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:Migrations",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:SharedPreferencesUtil",
+        "//proto:transform_java_proto_lite",
+        "@com_google_guava_guava",
+        "@com_google_protobuf//:protobuf_lite",
+    ],
+)
+
+android_library(
+    name = "SymlinkUtil",
+    srcs = ["SymlinkUtil.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:android",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:android_adapter",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "@androidx_annotation_annotation",
+    ],
+)
+
+android_library(
+    name = "MddLiteConversionUtil",
+    srcs = ["MddLiteConversionUtil.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload:single_file_interfaces",
+        "//java/com/google/android/libraries/mobiledatadownload/lite",
+        "//java/com/google/android/libraries/mobiledatadownload/lite:DownloadListener",
+        "@com_google_guava_guava",
+    ],
+)
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/util/DirectoryUtil.java b/java/com/google/android/libraries/mobiledatadownload/internal/util/DirectoryUtil.java
new file mode 100644
index 0000000..822b421
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/util/DirectoryUtil.java
@@ -0,0 +1,203 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.util;
+
+import android.content.Context;
+import android.net.Uri;
+import androidx.annotation.VisibleForTesting;
+import com.google.android.libraries.mobiledatadownload.SilentFeedback;
+import com.google.android.libraries.mobiledatadownload.file.backends.AndroidUri;
+import com.google.android.libraries.mobiledatadownload.file.backends.BlobUri;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil;
+import com.google.common.base.Optional;
+import com.google.common.base.Preconditions;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal.AllowedReaders;
+import java.io.IOException;
+import javax.annotation.Nullable;
+
+/** Utils to help with directory manipulation. */
+public class DirectoryUtil {
+
+  private static final String TAG = "DirectoryUtil";
+  // Correspond to MobStore Uri components.
+  public static final String MDD_STORAGE_MODULE = "datadownload";
+  public static final String MDD_MANIFEST_MODULE = "datadownloadmanifest";
+  public static final String MDD_STORAGE_ALL_GOOGLE_APPS = "public";
+  public static final String MDD_STORAGE_ONLY_GOOGLE_PLAY_SERVICES = "private";
+  public static final String MDD_STORAGE_SYMLINKS = "links";
+  @VisibleForTesting static final String MDD_STORAGE_ALL_APPS = "public_3p";
+
+  /**
+   * Returns the top-level directory uri for all MDD downloads. Individual files should not be
+   * placed here; instead, use {@link #getDownloadDirectory(Context, AllowedReaders)}.
+   */
+  public static Uri getBaseDownloadDirectory(Context context, Optional<String> instanceId) {
+    AndroidUri.Builder builder =
+        AndroidUri.builder(context)
+            .setModule(
+                instanceId != null && instanceId.isPresent()
+                    ? instanceId.get()
+                    : MDD_STORAGE_MODULE);
+
+    if (instanceId != null && instanceId.isPresent()) {
+      builder.setRelativePath(MDD_STORAGE_MODULE);
+    }
+
+    return builder.build();
+  }
+
+  /**
+   * Returns the base directory where MDD stores manifest files. If instanceId is absent, a shared
+   * directory is returned; otherwise, a standalone directory with instanceId as its relative path
+   * is returned.
+   */
+  public static Uri getManifestDirectory(Context context, Optional<String> instanceId) {
+    Preconditions.checkNotNull(instanceId);
+
+    return AndroidUri.builder(context)
+        .setModule(MDD_MANIFEST_MODULE)
+        .setRelativePath(instanceId.or(MDD_STORAGE_MODULE))
+        .build();
+  }
+
+  /** Returns the directory uri for mdd download based on the allowed readers. */
+  public static Uri getDownloadDirectory(
+      Context context, AllowedReaders allowedReaders, Optional<String> instanceId) {
+    String subDirectory = getSubDirectory(allowedReaders);
+    return getBaseDownloadDirectory(context, instanceId)
+        .buildUpon()
+        .appendPath(subDirectory)
+        .build();
+  }
+
+  /** Returns the directory uri base for mdd symlinks. */
+  public static Uri getBaseDownloadSymlinkDirectory(Context context, Optional<String> instanceId) {
+    return getBaseDownloadDirectory(context, instanceId)
+        .buildUpon()
+        .appendPath(MDD_STORAGE_SYMLINKS)
+        .build();
+  }
+
+  /** Returns the directory uri for mdd symlinks based on the allowed readers. */
+  public static Uri getDownloadSymlinkDirectory(
+      Context context, AllowedReaders allowedReaders, Optional<String> instanceId) {
+    String subDirectory = getSubDirectory(allowedReaders);
+    return getBaseDownloadSymlinkDirectory(context, instanceId)
+        .buildUpon()
+        .appendPath(subDirectory)
+        .build();
+  }
+
+  /**
+   * Returns the on device uri for the specified file.
+   *
+   * @param androidShared if sets to true, {@code getOnDeviceUri} returns the "blobstore" scheme
+   *     URI, otherwise it returns the "android" scheme URI.
+   */
+  // TODO(b/118137672): getOnDeviceUri shouldn't return null on error.
+  @Nullable
+  public static Uri getOnDeviceUri(
+      Context context,
+      AllowedReaders allowedReaders,
+      String fileName,
+      String checksum,
+      SilentFeedback silentFeedback,
+      Optional<String> instanceId,
+      boolean androidShared) {
+
+    try {
+      if (androidShared) {
+        return getBlobUri(context, checksum);
+      }
+      Uri directoryUri = getDownloadDirectory(context, allowedReaders, instanceId);
+      return directoryUri.buildUpon().appendPath(fileName).build();
+    } catch (Exception e) {
+      // Catch all exceptions here as the above code can throw an exception if
+      // context.getFilesDir returns null.
+      LogUtil.e(e, "%s: Unable to create mobstore uri for file %s.", TAG, fileName);
+      silentFeedback.send(e, "Unable to create mobstore uri for file");
+
+      return null;
+    }
+  }
+
+  /**
+   * Returns the "blobstore" scheme URI of the file with final checksum {@code checksum}.
+   *
+   * <ul>
+   *   In order to be able to access the file in the blob store, the checksum needs to comply to the
+   *   following rules:
+   *   <li>at the moment, only checksums of type SHA256 are accepted.
+   *   <li>the checksum must be the file final checksum, i.e. after the download transforms have
+   *       been applied if any.
+   * </ul>
+   */
+  public static Uri getBlobUri(Context context, String checksum) throws IOException {
+    return BlobUri.builder(context).setBlobParameters(checksum).build();
+  }
+
+  /**
+   * Returns the "blobstore" scheme URI used to acquire a lease on the file with final checksum
+   * {@code checksum}.
+   *
+   * <ul>
+   *   In order to be able to acquire the lease of the file in the blob store, the checksum needs to
+   *   comply to the following rules:
+   *   <li>at the moment, only checksums of type SHA256 are accepted.
+   *   <li>the checksum must be the file final checksum, i.e. for files with download_transform, it
+   *       should contain the transform of the file after the transforms have been applied.
+   * </ul>
+   */
+  public static Uri getBlobStoreLeaseUri(Context context, String checksum, long expiryDateSecs)
+      throws IOException {
+    return BlobUri.builder(context).setLeaseParameters(checksum, expiryDateSecs).build();
+  }
+
+  /**
+   * Returns the "blobstore" scheme URI used to release all the leases owned by the calling package.
+   */
+  public static Uri getBlobStoreAllLeasesUri(Context context) throws IOException {
+    return BlobUri.builder(context).setAllLeasesParameters().build();
+  }
+
+  /**
+   * Returns {@code basename.extension}, with {@code instanceId} appended to basename if present.
+   *
+   * <p>Useful for building filenames that must be distinguished by InstanceId while keeping the
+   * same basename and file extension.
+   */
+  public static String buildFilename(
+      String basename, String extension, Optional<String> instanceId) {
+    String resultBasename = basename;
+    if (instanceId != null && instanceId.isPresent()) {
+      resultBasename += instanceId.get();
+    }
+    return resultBasename + "." + extension;
+  }
+
+  /** Convenience method to get the storage subdirectory based on the allowed readers. */
+  private static String getSubDirectory(AllowedReaders allowedReaders) {
+    switch (allowedReaders) {
+      case ALL_GOOGLE_APPS:
+        return MDD_STORAGE_ALL_GOOGLE_APPS;
+      case ONLY_GOOGLE_PLAY_SERVICES:
+        return MDD_STORAGE_ONLY_GOOGLE_PLAY_SERVICES;
+      case ALL_APPS:
+        return MDD_STORAGE_ALL_APPS;
+    }
+    throw new IllegalArgumentException("invalid allowed readers value");
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/util/Either.java b/java/com/google/android/libraries/mobiledatadownload/internal/util/Either.java
new file mode 100644
index 0000000..e80f79d
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/util/Either.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.util;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/** An object that contains either one of the left field or right field, but not both. */
+public final class Either<A, B> {
+
+  private final boolean hasLeft;
+  private final @Nullable A left;
+  private final @Nullable B right;
+
+  private Either(boolean hasLeft, @Nullable A left, @Nullable B right) {
+    this.hasLeft = hasLeft;
+    this.left = left;
+    this.right = right;
+  }
+
+  public static <A, B> Either<A, B> makeLeft(A left) {
+    return new Either<>(true, left, null);
+  }
+
+  public static <A, B> Either<A, B> makeRight(B right) {
+    return new Either<>(false, null, right);
+  }
+
+  public boolean hasLeft() {
+    return hasLeft;
+  }
+
+  public boolean hasRight() {
+    return !hasLeft;
+  }
+
+  public @Nullable A getLeft() {
+    if (!hasLeft()) {
+      throw new IllegalStateException("Either was not left");
+    }
+    return left;
+  }
+
+  public @Nullable B getRight() {
+    if (!hasRight()) {
+      throw new IllegalStateException("Either was not right");
+    }
+    return right;
+  }
+
+  @Override
+  @SuppressWarnings("unchecked")
+  public boolean equals(@Nullable Object obj) {
+    if (!(obj instanceof Either)) {
+      return false;
+    }
+    Either<A, B> either = (Either<A, B>) obj;
+    if (hasLeft()) {
+      return either.hasLeft() && equals(getLeft(), either.getLeft());
+    } else {
+      return either.hasRight() && equals(getRight(), either.getRight());
+    }
+  }
+
+  /** Compares two List-based Either by (a copy of) their sorted inner List. */
+  public static <A, B> boolean sortedEquals(
+      @Nullable Either<List<A>, B> first,
+      @Nullable Either<List<A>, B> second,
+      Comparator<A> comparatorForSorting) {
+    // Only sort if the Lists will actually be compared. This uses final variable "left" rather than
+    // getLeft() (which "could" change between invocations) to satisfy the Nullness Checker.
+    if (first != null
+        && first.hasLeft()
+        && first.left != null
+        && second != null
+        && second.hasLeft()
+        && second.left != null) {
+      List<A> sortedFirstLeft = new ArrayList<>(first.left);
+      List<A> sortedSecondLeft = new ArrayList<>(second.left);
+      Collections.sort(sortedFirstLeft, comparatorForSorting);
+      Collections.sort(sortedSecondLeft, comparatorForSorting);
+      return sortedFirstLeft.equals(sortedSecondLeft);
+    }
+
+    return equals(first, second);
+  }
+
+  // Inline definition of Objects.equals() since it's not available on all API levels.
+  public static boolean equals(@Nullable Object a, @Nullable Object b) {
+    return (a == b) || (a != null && a.equals(b));
+  }
+
+  @Override
+  public int hashCode() {
+    return Arrays.hashCode(new Object[] {hasLeft, left, right});
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/util/FileGroupUtil.java b/java/com/google/android/libraries/mobiledatadownload/internal/util/FileGroupUtil.java
new file mode 100644
index 0000000..eed5da0
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/util/FileGroupUtil.java
@@ -0,0 +1,296 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.util;
+
+import android.content.Context;
+import android.net.Uri;
+import android.os.Build.VERSION;
+import android.os.Build.VERSION_CODES;
+import com.google.android.libraries.mobiledatadownload.TimeSource;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.openers.RecursiveDeleteOpener;
+import com.google.android.libraries.mobiledatadownload.internal.MddConstants;
+import com.google.common.base.Ascii;
+import com.google.common.base.Optional;
+import com.google.common.base.Preconditions;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableSet;
+import com.google.mobiledatadownload.TransformProto.Transform;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFile;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFile.AndroidSharingType;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupBookkeeping;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal;
+import com.google.mobiledatadownload.internal.MetadataProto.GroupKey;
+import java.io.IOException;
+import java.util.concurrent.TimeUnit;
+import javax.annotation.Nullable;
+
+/** A collection of util methods for interaction with a DataFileGroup proto. */
+public class FileGroupUtil {
+
+  /**
+   * @return the expiration date of this active file group in millis or Long.MAX_VALUE if no
+   *     expiration date is set.
+   */
+  public static long getExpirationDateMillis(DataFileGroupInternal fileGroup) {
+    return (fileGroup.getExpirationDateSecs() == 0)
+        ? Long.MAX_VALUE
+        : TimeUnit.SECONDS.toMillis(fileGroup.getExpirationDateSecs());
+  }
+
+  /**
+   * @return the expiration date of this stale file group in millis
+   */
+  public static long getStaleExpirationDateMillis(DataFileGroupInternal fileGroup) {
+    return TimeUnit.SECONDS.toMillis(fileGroup.getBookkeeping().getStaleExpirationDate());
+  }
+
+  /**
+   * @return if the active group's expiration date has passed. False if no expiration date is set.
+   */
+  public static boolean isActiveGroupExpired(
+      DataFileGroupInternal fileGroup, TimeSource timeSource) {
+    return isExpired(getExpirationDateMillis(fileGroup), timeSource);
+  }
+
+  /**
+   * @param expirationDateMillis the date (in millis since epoch) at which expiration should occur.
+   * @return if expirationDate has passed.
+   */
+  public static boolean isExpired(long expirationDateMillis, TimeSource timeSource) {
+    return expirationDateMillis <= timeSource.currentTimeMillis();
+  }
+
+  /**
+   * Returns file group key which uniquely identify a file group.
+   *
+   * @param groupName The file group name
+   * @param ownerPackage File Group owner package. For legacy reasons, this might not be set in some
+   *     groups.
+   */
+  public static GroupKey createGroupKey(String groupName, @Nullable String ownerPackage) {
+    GroupKey.Builder groupKey = GroupKey.newBuilder().setGroupName(groupName);
+
+    if (Strings.isNullOrEmpty(ownerPackage)) {
+      groupKey.setOwnerPackage(MddConstants.GMS_PACKAGE);
+    } else {
+      groupKey.setOwnerPackage(ownerPackage);
+    }
+
+    return groupKey.build();
+  }
+
+  /**
+   * Returns the DataFile within dataFileGroup with the matching fileId. null if the group no such
+   * DataFile exists.
+   */
+  @Nullable
+  public static DataFile getFileFromGroupWithId(
+      @Nullable DataFileGroupInternal dataFileGroup, String fileId) {
+    if (dataFileGroup == null) {
+      return null;
+    }
+    for (DataFile dataFile : dataFileGroup.getFileList()) {
+      if (fileId.equals(dataFile.getFileId())) {
+        return dataFile;
+      }
+    }
+    return null;
+  }
+
+  public static DataFileGroupInternal setStaleExpirationDate(
+      DataFileGroupInternal dataFileGroup, long timeSinceEpoch) {
+    DataFileGroupBookkeeping bookkeeping =
+        dataFileGroup.getBookkeeping().toBuilder().setStaleExpirationDate(timeSinceEpoch).build();
+    dataFileGroup = dataFileGroup.toBuilder().setBookkeeping(bookkeeping).build();
+    return dataFileGroup;
+  }
+
+  public static DataFileGroupInternal setGroupNewFilesReceivedTimestamp(
+      DataFileGroupInternal dataFileGroup, long timeSinceEpoch) {
+    DataFileGroupBookkeeping bookkeeping =
+        dataFileGroup.getBookkeeping().toBuilder()
+            .setGroupNewFilesReceivedTimestamp(timeSinceEpoch)
+            .build();
+    dataFileGroup = dataFileGroup.toBuilder().setBookkeeping(bookkeeping).build();
+    return dataFileGroup;
+  }
+
+  /** Sets the given downloaded timestamp in the given group */
+  public static DataFileGroupInternal setDownloadedTimestampInMillis(
+      DataFileGroupInternal dataFileGroup, long timeSinceEpochInMillis) {
+    DataFileGroupBookkeeping bookkeeping =
+        dataFileGroup.getBookkeeping().toBuilder()
+            .setGroupDownloadedTimestampInMillis(timeSinceEpochInMillis)
+            .build();
+    dataFileGroup = dataFileGroup.toBuilder().setBookkeeping(bookkeeping).build();
+    return dataFileGroup;
+  }
+
+  /** Sets the given download started timestamp in the given group. */
+  public static DataFileGroupInternal setDownloadStartedTimestampInMillis(
+      DataFileGroupInternal dataFileGroup, long timeSinceEpochInMillis) {
+    DataFileGroupBookkeeping bookkeeping =
+        dataFileGroup.getBookkeeping().toBuilder()
+            .setGroupDownloadStartedTimestampInMillis(timeSinceEpochInMillis)
+            .build();
+    dataFileGroup = dataFileGroup.toBuilder().setBookkeeping(bookkeeping).build();
+    return dataFileGroup;
+  }
+
+  /** Shared method to test whether the given file group supports isolated file structures. */
+  public static boolean isIsolatedStructureAllowed(DataFileGroupInternal dataFileGroupInternal) {
+    if (VERSION.SDK_INT < VERSION_CODES.LOLLIPOP
+        || !dataFileGroupInternal.getPreserveFilenamesAndIsolateFiles()) {
+      return false;
+    }
+
+    // If any data file uses android blob sharing, don't create isolated file structure.
+    for (DataFile dataFile : dataFileGroupInternal.getFileList()) {
+      if (dataFile.getAndroidSharingType() == AndroidSharingType.ANDROID_BLOB_WHEN_AVAILABLE) {
+        return false;
+      }
+    }
+
+    return true;
+  }
+
+  /**
+   * Gets the root directory where isolated files should be when the given file group supports
+   * preserving relative file paths.
+   */
+  public static Uri getIsolatedRootDirectory(
+      Context context, Optional<String> instanceId, DataFileGroupInternal fileGroupInternal) {
+    return DirectoryUtil.getDownloadSymlinkDirectory(
+            context, fileGroupInternal.getAllowedReadersEnum(), instanceId)
+        .buildUpon()
+        .appendPath(fileGroupInternal.getGroupName())
+        .build();
+  }
+
+  /**
+   * Gets the isolated location of a given DataFile when the parent file group supports preserving
+   * relative file paths.
+   */
+  public static Uri getIsolatedFileUri(
+      Context context,
+      Optional<String> instanceId,
+      DataFile dataFile,
+      DataFileGroupInternal parentFileGroup) {
+    Uri.Builder fileUriBuilder =
+        getIsolatedRootDirectory(context, instanceId, parentFileGroup).buildUpon();
+    if (dataFile.getRelativeFilePath().isEmpty()) {
+      // If no relative path specified get the last segment from the
+      // urlToDownload.
+      String urlToDownload = dataFile.getUrlToDownload();
+      fileUriBuilder.appendPath(urlToDownload.substring(urlToDownload.lastIndexOf("/") + 1));
+    } else {
+      // Use give relative path to get parts
+      for (String part : dataFile.getRelativeFilePath().split("/", -1)) {
+        if (!part.isEmpty()) {
+          fileUriBuilder.appendPath(part);
+        }
+      }
+    }
+    return fileUriBuilder.build();
+  }
+
+  /**
+   * Removes the isolated file structure for the given file group.
+   *
+   * <p>If the isolated structure has already been deleted or was never created, this method is a
+   * no-op.
+   */
+  public static void removeIsolatedFileStructure(
+      Context context,
+      Optional<String> instanceId,
+      DataFileGroupInternal dataFileGroup,
+      SynchronousFileStorage fileStorage)
+      throws IOException {
+    Uri isolatedRootDir =
+        FileGroupUtil.getIsolatedRootDirectory(context, instanceId, dataFileGroup);
+    if (fileStorage.exists(isolatedRootDir)) {
+      Void unused = fileStorage.open(isolatedRootDir, RecursiveDeleteOpener.create());
+    }
+  }
+
+  public static boolean hasZipDownloadTransform(DataFile dataFile) {
+    if (dataFile.hasDownloadTransforms()) {
+      for (Transform transform : dataFile.getDownloadTransforms().getTransformList()) {
+        if (transform.hasZip()) {
+          return true;
+        }
+      }
+    }
+    return false;
+  }
+
+  public static boolean hasCompressDownloadTransform(DataFile dataFile) {
+    if (dataFile.hasDownloadTransforms()) {
+      for (Transform transform : dataFile.getDownloadTransforms().getTransformList()) {
+        if (transform.hasCompress()) {
+          return true;
+        }
+      }
+    }
+    return false;
+  }
+
+  public static String getFileChecksum(DataFile dataFile) {
+    return hasZipDownloadTransform(dataFile)
+        ? dataFile.getDownloadedFileChecksum()
+        : dataFile.getChecksum();
+  }
+
+  public static boolean isSideloadedFile(DataFile dataFile) {
+    return isFileWithMatchingScheme(
+        dataFile,
+        ImmutableSet.of(
+            MddConstants.SIDELOAD_FILE_URL_SCHEME, MddConstants.EMBEDDED_ASSET_URL_SCHEME));
+  }
+
+  public static boolean isInlineFile(DataFile dataFile) {
+    return isFileWithMatchingScheme(dataFile, ImmutableSet.of(MddConstants.INLINE_FILE_URL_SCHEME));
+  }
+
+  // Helper method to test whether a DataFile's url scheme is contained in the given scheme set.
+  private static boolean isFileWithMatchingScheme(DataFile dataFile, ImmutableSet<String> schemes) {
+    if (!dataFile.hasUrlToDownload()) {
+      return false;
+    }
+    int colon = dataFile.getUrlToDownload().indexOf(':');
+    // TODO(b/196593240): Ensure this is always handled, or replace with a checked exception
+    Preconditions.checkState(colon > -1, "Invalid url: %s", dataFile.getUrlToDownload());
+    String fileScheme = dataFile.getUrlToDownload().substring(0, colon);
+    for (String scheme : schemes) {
+      if (Ascii.equalsIgnoreCase(fileScheme, scheme)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  public static int getInlineFileCount(DataFileGroupInternal fileGroup) {
+    int inlineFileCount = 0;
+    for (DataFile file : fileGroup.getFileList()) {
+      if (isInlineFile(file)) {
+        inlineFileCount++;
+      }
+    }
+
+    return inlineFileCount;
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/util/FileGroupsMetadataUtil.java b/java/com/google/android/libraries/mobiledatadownload/internal/util/FileGroupsMetadataUtil.java
new file mode 100644
index 0000000..fda3b1e
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/util/FileGroupsMetadataUtil.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.util;
+
+import android.content.Context;
+import android.util.Base64;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil;
+import com.google.common.base.Optional;
+import com.google.common.collect.ImmutableList;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal;
+import com.google.mobiledatadownload.internal.MetadataProto.GroupKey;
+import com.google.protobuf.InvalidProtocolBufferException;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.List;
+
+/** Stores and provides access to file group metadata using SharedPreferences. */
+public final class FileGroupsMetadataUtil {
+
+  private static final String TAG = "FileGroupsMetadataUtil";
+
+  // Name of file groups SharedPreferences.
+  public static final String MDD_FILE_GROUPS = "gms_icing_mdd_groups";
+
+  // Name of file groups group key properties SharedPreferences.
+  public static final String MDD_FILE_GROUP_KEY_PROPERTIES = "gms_icing_mdd_group_key_properties";
+
+  // TODO(b/144033163): Migrate the Garbage Collector File to PDS.
+  public static final String MDD_GARBAGE_COLLECTION_FILE = "gms_icing_mdd_garbage_file";
+
+  /** Group key Deserialization exception. */
+  public static class GroupKeyDeserializationException extends Exception {
+    GroupKeyDeserializationException(String msg, Throwable cause) {
+      super(msg, cause);
+    }
+  }
+
+  // TODO(b/144033163): Migrate the Garbage Collector File to PDS.
+  public static List<DataFileGroupInternal> getAllStaleGroups(File garbageCollectorFile) {
+    FileInputStream inputStream;
+    try {
+      inputStream = new FileInputStream(garbageCollectorFile);
+    } catch (FileNotFoundException e) {
+      LogUtil.d("File %s not found while reading.", garbageCollectorFile.getAbsolutePath());
+      return ImmutableList.of();
+    }
+
+    ByteBuffer buf;
+    try {
+      buf = ByteBuffer.allocate((int) garbageCollectorFile.length());
+    } catch (IllegalArgumentException e) {
+      LogUtil.e(e, "%s: Exception while reading from stale groups into buffer.", TAG);
+      return ImmutableList.of();
+    }
+
+    List<DataFileGroupInternal> fileGroups = null;
+    try {
+      inputStream.getChannel().read(buf);
+      // Rewind so that we can read from the start of the buffer.
+      buf.rewind();
+      // tail_crc == false, means that each message has its own crc
+      fileGroups =
+          ProtoLiteUtil.readFromBuffer(
+              buf, DataFileGroupInternal.class, DataFileGroupInternal.parser(), false /*tail crc*/);
+      inputStream.close();
+    } catch (IOException e) {
+      LogUtil.e(e, "%s: IOException occurred while reading file groups.", TAG);
+    }
+    return fileGroups == null ? ImmutableList.of() : fileGroups;
+  }
+
+  public static File getGarbageCollectorFile(Context context, Optional<String> instanceId) {
+    String fileName =
+        instanceId != null && instanceId.isPresent()
+            ? MDD_GARBAGE_COLLECTION_FILE + instanceId.get()
+            : MDD_GARBAGE_COLLECTION_FILE;
+    return new File(context.getFilesDir(), fileName);
+  }
+
+  // TODO(b/129702287): Move away from proto based serialization.
+  public static String getSerializedGroupKey(GroupKey groupKey, Context context) {
+    byte[] byteValue = groupKey.toByteArray();
+    return Base64.encodeToString(byteValue, Base64.NO_PADDING | Base64.NO_WRAP);
+  }
+
+  /**
+   * Converts a string representing a serialized GroupKey into a GroupKey.
+   *
+   * @return - groupKey if able to parse stringKey properly. null if parsing fails.
+   */
+  // TODO(b/129702287): Move away from proto based deserialization.
+  public static GroupKey deserializeGroupKey(String serializedGroupKey)
+      throws GroupKeyDeserializationException {
+    try {
+      return SharedPreferencesUtil.parseLiteFromEncodedString(
+          serializedGroupKey, GroupKey.parser());
+    } catch (InvalidProtocolBufferException e) {
+      throw new GroupKeyDeserializationException(
+          "Failed to deserialize key:" + serializedGroupKey, e);
+    }
+  }
+
+  private FileGroupsMetadataUtil() {}
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/util/FuturesUtil.java b/java/com/google/android/libraries/mobiledatadownload/internal/util/FuturesUtil.java
new file mode 100644
index 0000000..cdf1ea3
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/util/FuturesUtil.java
@@ -0,0 +1,140 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.util;
+
+import com.google.android.libraries.mobiledatadownload.internal.annotations.SequentialControlExecutor;
+import com.google.common.base.Function;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Executor;
+
+/** Utilities for manipulating futures. */
+public final class FuturesUtil {
+
+  private final Executor sequentialExecutor;
+
+  public FuturesUtil(@SequentialControlExecutor Executor sequentialExecutor) {
+    this.sequentialExecutor = sequentialExecutor;
+  }
+
+  /**
+   * Returns a SequentialFutureChain which can takes a number of asynchronous operation and turns
+   * them into a single asynchronous operation.
+   *
+   * <p>SequentialFutureChain provides a clearer way of writing a common idiom used to sequence a
+   * number of asynchrounous operations. The fragment
+   *
+   * <pre>{@code
+   * ListenableFuture<T> future = immediateFuture(init);
+   * future = transformAsync(future, arg -> asyncOp1, sequentialExecutor);
+   * future = transform(future, arg -> op2, sequentialExecutor);
+   * future = transformAsync(future, arg -> asyncOp3, sequentialExecutor);
+   * return future;
+   * }</pre>
+   *
+   * <p>can be rewritten as
+   *
+   * <pre>{@code
+   * return new FuturesUtil(sequentialExecutor)
+   *     .newSequentialChain(init)
+   *     .chainAsync(arg -> asyncOp1)
+   *     .chain(arg -> op2)
+   *     .chainAsync(arg -> asyncOp3)
+   *     .start();
+   * }</pre>
+   *
+   * <p>If any intermediate operation raises an exception, the whole chain raises an exception.
+   *
+   * <p>Note that sequentialExecutor must be a sequential executor, i.e. provide the sequentiality
+   * guarantees provided by {@link com.google.common.util.concurrent.SequentialExecutor}.
+   */
+  public <T> SequentialFutureChain<T> newSequentialChain(T init) {
+    return new SequentialFutureChain<>(init);
+  }
+
+  /**
+   * Create a SequentialFutureChain that doesn't compute a result.
+   *
+   * <p>If any intermediate operation raises an exception, the whole chain raises an exception.
+   *
+   * <p>Note that sequentialExecutor must be a sequential executor, i.e. provide the sequentiality
+   * guarantees provided by {@link com.google.common.util.concurrent.SequentialExecutor}.
+   */
+  public SequentialFutureChain<Void> newSequentialChain() {
+    return new SequentialFutureChain<>(null);
+  }
+
+  /** Builds a list of Futurse to be executed sequentially. */
+  public final class SequentialFutureChain<T> {
+    private final List<FutureChainElement<T>> operations;
+    private final T init;
+
+    private SequentialFutureChain(T init) {
+      this.operations = new ArrayList<>();
+      this.init = init;
+    }
+
+    public SequentialFutureChain<T> chain(Function<T, T> operation) {
+      operations.add(new DirectFutureChainElement<>(operation));
+      return this;
+    }
+
+    public SequentialFutureChain<T> chainAsync(Function<T, ListenableFuture<T>> operation) {
+      operations.add(new AsyncFutureChainElement<>(operation));
+      return this;
+    }
+
+    public ListenableFuture<T> start() {
+      ListenableFuture<T> result = Futures.immediateFuture(init);
+      for (FutureChainElement<T> operation : operations) {
+        result = operation.apply(result);
+      }
+      return result;
+    }
+  }
+
+  private interface FutureChainElement<T> {
+    abstract ListenableFuture<T> apply(ListenableFuture<T> input);
+  }
+
+  private final class DirectFutureChainElement<T> implements FutureChainElement<T> {
+    private final Function<T, T> operation;
+
+    private DirectFutureChainElement(Function<T, T> operation) {
+      this.operation = operation;
+    }
+
+    @Override
+    public ListenableFuture<T> apply(ListenableFuture<T> input) {
+      return Futures.transform(input, operation::apply, sequentialExecutor);
+    }
+  }
+
+  private final class AsyncFutureChainElement<T> implements FutureChainElement<T> {
+    private final Function<T, ListenableFuture<T>> operation;
+
+    private AsyncFutureChainElement(Function<T, ListenableFuture<T>> operation) {
+      this.operation = operation;
+    }
+
+    @Override
+    public ListenableFuture<T> apply(ListenableFuture<T> input) {
+      return Futures.transformAsync(input, operation::apply, sequentialExecutor);
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/util/MddLiteConversionUtil.java b/java/com/google/android/libraries/mobiledatadownload/internal/util/MddLiteConversionUtil.java
new file mode 100644
index 0000000..f1a7375
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/util/MddLiteConversionUtil.java
@@ -0,0 +1,100 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.util;
+
+import com.google.android.libraries.mobiledatadownload.SingleFileDownloadListener;
+import com.google.android.libraries.mobiledatadownload.SingleFileDownloadRequest;
+import com.google.android.libraries.mobiledatadownload.lite.DownloadListener;
+import com.google.android.libraries.mobiledatadownload.lite.DownloadRequest;
+import com.google.common.base.Optional;
+import com.google.common.util.concurrent.ListenableFuture;
+
+/**
+ * Utility Class to help converting MDD Lib's SingleFile classes/interfaces to the MDD Lite
+ * equivalent.
+ */
+public final class MddLiteConversionUtil {
+
+  private MddLiteConversionUtil() {}
+
+  /** Convert {@link SingleFileDownloadRequest} to MDD Lite's {@link DownloadRequest}. */
+  // TODO(b/176103639): Use AutoConverter if @AutoValue to @AutoValue is supported
+  public static DownloadRequest convertToDownloadRequest(
+      SingleFileDownloadRequest singleFileDownloadRequest) {
+    return DownloadRequest.newBuilder()
+        .setDestinationFileUri(singleFileDownloadRequest.destinationFileUri())
+        .setUrlToDownload(singleFileDownloadRequest.urlToDownload())
+        .setDownloadConstraints(singleFileDownloadRequest.downloadConstraints())
+        .setListenerOptional(
+            convertToDownloadListenerOptional(singleFileDownloadRequest.listenerOptional()))
+        .setTrafficTag(singleFileDownloadRequest.trafficTag())
+        .setExtraHttpHeaders(singleFileDownloadRequest.extraHttpHeaders())
+        .setFileSizeBytes(singleFileDownloadRequest.fileSizeBytes())
+        .setNotificationContentTitle(singleFileDownloadRequest.notificationContentTitle())
+        .setNotificationContentTextOptional(
+            singleFileDownloadRequest.notificationContentTextOptional())
+        .setShowDownloadedNotification(singleFileDownloadRequest.showDownloadedNotification())
+        .build();
+  }
+
+  /** Convenience method for handling optionals. */
+  public static Optional<DownloadListener> convertToDownloadListenerOptional(
+      Optional<SingleFileDownloadListener> singleFileDownloadListenerOptional) {
+    if (!singleFileDownloadListenerOptional.isPresent()) {
+      return Optional.absent();
+    }
+
+    return Optional.of(convertToDownloadListener(singleFileDownloadListenerOptional.get()));
+  }
+
+  /** Convert {@link SingleFileDownloadListener} to MDD Lite's {@link DownloadListener}. */
+  public static DownloadListener convertToDownloadListener(
+      SingleFileDownloadListener singleFileDownloadListener) {
+    return new ConvertedSingleFileDownloadListener(singleFileDownloadListener);
+  }
+
+  /**
+   * Wrapper around given {@link SingleFileDownloadListener} that implements MDD Lite's {@link
+   * DownloadListener} interface.
+   */
+  private static final class ConvertedSingleFileDownloadListener implements DownloadListener {
+    private final SingleFileDownloadListener sourceListener;
+
+    private ConvertedSingleFileDownloadListener(SingleFileDownloadListener sourceListener) {
+      this.sourceListener = sourceListener;
+    }
+
+    @Override
+    public void onProgress(long currentSize) {
+      sourceListener.onProgress(currentSize);
+    }
+
+    @Override
+    public ListenableFuture<Void> onComplete() {
+      return sourceListener.onComplete();
+    }
+
+    @Override
+    public void onFailure(Throwable t) {
+      sourceListener.onFailure(t);
+    }
+
+    @Override
+    public void onPausedForConnectivity() {
+      sourceListener.onPausedForConnectivity();
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/util/ProtoConversionUtil.java b/java/com/google/android/libraries/mobiledatadownload/internal/util/ProtoConversionUtil.java
new file mode 100644
index 0000000..04e3446
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/util/ProtoConversionUtil.java
@@ -0,0 +1,131 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.util;
+
+import com.google.mobiledatadownload.DownloadConfigProto.DataFile;
+import com.google.mobiledatadownload.DownloadConfigProto.DataFileGroup;
+import com.google.mobiledatadownload.DownloadConfigProto.DeltaFile;
+import com.google.mobiledatadownload.DownloadConfigProto.DownloadConditions;
+import com.google.mobiledatadownload.internal.MetadataProto;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal;
+import com.google.protobuf.ExtensionRegistryLite;
+import com.google.protobuf.InvalidProtocolBufferException;
+
+/** The util class that does conversion between protos. */
+public final class ProtoConversionUtil {
+  private ProtoConversionUtil() {}
+
+  /**
+   * Converts external configuration proto {@link DataFileGroup} into internal storage proto {@link
+   * DataFileGroupInternal}.
+   */
+  // TODO(b/176103639): Use automated proto converter instead
+  public static DataFileGroupInternal convert(DataFileGroup group)
+      throws InvalidProtocolBufferException {
+    // Cannot use generated registry here, because it may cause NPE to clients.
+    // For more detail, see b/140135059.
+    return DataFileGroupInternal.parseFrom(
+        group.toByteArray(), ExtensionRegistryLite.getEmptyRegistry());
+  }
+
+  /**
+   * Converts external proto {@link DownloadConditions} into internal proto {@link
+   * MetadataProto.DownloadConditions}.
+   */
+  // TODO(b/176103639): Use automated proto converter instead
+  public static MetadataProto.DownloadConditions convert(DownloadConditions downloadConditions)
+      throws InvalidProtocolBufferException {
+    // Cannot use generated registry here, because it may cause NPE to clients.
+    // For more detail, see b/140135059.
+    return MetadataProto.DownloadConditions.parseFrom(
+        downloadConditions.toByteArray(), ExtensionRegistryLite.getEmptyRegistry());
+  }
+
+  /**
+   * Converts external configuration proto {@link DataFile} to internal storage proto {@link
+   * MetadataProto.DataFile}.
+   */
+  // TODO(b/176103639): Use automated proto converter instead
+  // LINT.IfChange(data_file_convert)
+  public static MetadataProto.DataFile convertDataFile(DataFile dataFile) {
+    MetadataProto.DataFile.Builder dataFileBuilder =
+        MetadataProto.DataFile.newBuilder()
+            .setFileId(dataFile.getFileId())
+            .setUrlToDownload(dataFile.getUrlToDownload())
+            .setByteSize(dataFile.getByteSize())
+            .setChecksumType(
+                MetadataProto.DataFile.ChecksumType.forNumber(
+                    dataFile.getChecksumType().getNumber()))
+            .setChecksum(dataFile.getChecksum())
+            .setDownloadedFileChecksum(dataFile.getDownloadedFileChecksum())
+            .setDownloadedFileByteSize(dataFile.getDownloadedFileByteSize())
+            .setAndroidSharingType(
+                MetadataProto.DataFile.AndroidSharingType.forNumber(
+                    dataFile.getAndroidSharingType().getNumber()))
+            .setAndroidSharingChecksumType(
+                MetadataProto.DataFile.AndroidSharingChecksumType.forNumber(
+                    dataFile.getAndroidSharingChecksumType().getNumber()))
+            .setAndroidSharingChecksum(dataFile.getAndroidSharingChecksum())
+            .setRelativeFilePath(dataFile.getRelativeFilePath());
+
+    if (dataFile.hasCustomMetadata()) {
+      dataFileBuilder.setCustomMetadata(dataFile.getCustomMetadata());
+    }
+    if (dataFile.hasDownloadTransforms()) {
+      dataFileBuilder.setDownloadTransforms(dataFile.getDownloadTransforms());
+    }
+
+    if (dataFile.hasReadTransforms()) {
+      dataFileBuilder.setReadTransforms(dataFile.getReadTransforms());
+    }
+
+    for (DeltaFile deltaFile : dataFile.getDeltaFileList()) {
+      dataFileBuilder.addDeltaFile(convertDeltaFile(deltaFile));
+    }
+
+    return dataFileBuilder.build();
+  }
+  // LINT.ThenChange(
+  //
+  // <internal>,
+  //
+  // <internal>)
+
+  /**
+   * Converts external configuration proto {@link DeltaFile} to internal storage proto {@link
+   * DeltaFile}.
+   */
+  // TODO(b/176103639): Use automated proto converter instead
+  // LINT.IfChange(delta_file_convert)
+  public static MetadataProto.DeltaFile convertDeltaFile(DeltaFile deltaFile) {
+    return MetadataProto.DeltaFile.newBuilder()
+        .setUrlToDownload(deltaFile.getUrlToDownload())
+        .setByteSize(deltaFile.getByteSize())
+        .setChecksum(deltaFile.getChecksum())
+        .setDiffDecoder(
+            MetadataProto.DeltaFile.DiffDecoder.forNumber(deltaFile.getDiffDecoder().getNumber()))
+        .setBaseFile(
+            MetadataProto.BaseFile.newBuilder()
+                .setChecksum(deltaFile.getBaseFile().getChecksum())
+                .build())
+        .build();
+  }
+  // LINT.ThenChange(
+  //
+  // <internal>,
+  //
+  // <internal>)
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/util/ProtoLiteUtil.java b/java/com/google/android/libraries/mobiledatadownload/internal/util/ProtoLiteUtil.java
new file mode 100644
index 0000000..9f02cad
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/util/ProtoLiteUtil.java
@@ -0,0 +1,346 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.util;
+
+import android.text.TextUtils;
+import android.util.Log;
+import androidx.annotation.Nullable;
+import com.google.protobuf.CodedOutputStream;
+import com.google.protobuf.ExtensionRegistryLite;
+import com.google.protobuf.InvalidProtocolBufferException;
+import com.google.protobuf.MessageLite;
+import com.google.protobuf.Parser;
+import java.io.IOException;
+import java.nio.BufferOverflowException;
+import java.nio.BufferUnderflowException;
+import java.nio.ByteBuffer;
+import java.nio.charset.Charset;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.zip.CRC32;
+
+/** Utils for moving Protobuf messages in and out of ByteBuffers. */
+// LINT.IfChange
+public class ProtoLiteUtil {
+  public static final String TAG = "ProtoLiteUtil";
+
+  /*
+   * File format (with tail crc):
+   * (
+   *    int: number of bytes in message;
+   *    byte[number of bytes in message]: bytes of message;
+   * )...: message blocks;
+   * long: CRC of the above data
+   *
+   * File format (without tail crc):
+   * (
+   *    int: number of bytes in message;
+   *    byte[number of bytes in message]: bytes of message;
+   *    long: the CRC of the bytes of the message above
+   * )...: message blocks;
+   */
+
+  private static final byte INT_BYTE_SIZE = Integer.SIZE / Byte.SIZE;
+  private static final byte LONG_BYTE_SIZE = Long.SIZE / Byte.SIZE;
+  private static final byte CRC_LEN = LONG_BYTE_SIZE;
+
+  // Used to help guess a good initial capacity for the ArrayList
+  private static final int EXPECTED_MESS_SIZE = 1000;
+
+  /**
+   * @param buf MUST be a 0 based array buffer (aka, buf.arrayOffset() must return 0) and be mutable
+   *     (aka, buf.isReadOnly() must return false)
+   * @param messageType The type of the proto
+   * @param tailCrc True if there is a single CRC at the end of the file for the whole content,
+   *     false if there is a CRC after every record.
+   * @return A list of proto messages read from the buffer, or null on failure.
+   */
+  @Nullable
+  public static <T extends MessageLite> List<T> readFromBuffer(
+      ByteBuffer buf, Class<T> messageType, Parser<T> messageParser, boolean tailCrc) {
+    // assert buf.arrayOffset() == 0;
+    // annoyingly, ByteBuffer#array() throws an exception if the ByteBuffer was readonly
+    // assert !buf.isReadOnly();
+    String typename = messageType.toString();
+
+    if (tailCrc) {
+      // Validate the tail CRC before reading any messages.
+      int crcPos = buf.limit() - CRC_LEN;
+      if (crcPos < 0) { // Equivalently, buf.limit() < CRC_LEN
+        Log.e(TAG, "Protobuf data too short to be valid");
+        return null;
+      }
+      // First off, check the crc
+      long crc = buf.getLong(crcPos);
+      // Position should still be at the beginning;
+      // the read and write operations that take an index explicitly do not touch the
+      // position
+      if (!validateCRC(buf.array(), buf.arrayOffset(), crcPos, crc)) {
+        Log.e(TAG, "Ignoring corrupt protobuf data");
+        return null;
+      }
+      if (crcPos == 0) { // If the only thing in there was the CRC, then there are no messages
+        return new ArrayList<T>(0);
+      }
+    }
+
+    int end = tailCrc ? buf.limit() - CRC_LEN : buf.limit();
+
+    List<T> toReturn = new ArrayList<T>((buf.limit() / EXPECTED_MESS_SIZE) + 1);
+    while (buf.position() < end) {
+      T dest;
+      int bytesInMessage;
+      try {
+        bytesInMessage = buf.getInt();
+      } catch (BufferUnderflowException ex) {
+        handleBufferUnderflow(ex, typename);
+        return null;
+      }
+      if (bytesInMessage < 0) {
+        // This actually can happen even if the CRC check passed,
+        // if the user gave the wrong MessageLite type.
+        // Same goes for all of the other exceptions that can be thrown.
+        Log.e(
+            TAG,
+            String.format(
+                "Invalid message size: %d. May have given the wrong message type: %s",
+                bytesInMessage, typename));
+        return null;
+      }
+
+      if (!tailCrc) {
+        // May have read a garbage size. Read carefully.
+        if (end < buf.position() + bytesInMessage + CRC_LEN) {
+          Log.e(
+              TAG,
+              String.format("Invalid message size: %d (buffer end is %d)", bytesInMessage, end));
+          return toReturn;
+        }
+        long crc = buf.getLong(buf.position() + bytesInMessage);
+        if (!validateCRC(buf.array(), buf.arrayOffset() + buf.position(), bytesInMessage, crc)) {
+          // Return the valid messages we have read so far.
+          return toReturn;
+        }
+      }
+
+      // According to ByteBuffer#array()'s spec, this should not copy the backing array
+      dest =
+          tryCreate(
+              buf.array(),
+              buf.arrayOffset() + buf.position(),
+              bytesInMessage,
+              messageType,
+              messageParser);
+      if (dest == null) {
+        // Something is seriously hosed at this point, return nothing.
+        return null;
+      }
+      toReturn.add(dest);
+      // Advance the buffer manually, since we read from it "raw" from the array above
+      buf.position(buf.position() + bytesInMessage + (tailCrc ? 0 : CRC_LEN));
+    }
+    return toReturn;
+  }
+
+  @Nullable
+  private static <T extends MessageLite> T tryCreate(
+      byte[] arr, int pos, int len, Class<T> type, Parser<T> parser) {
+    try {
+      // Cannot use generated registry here, because it may cause NPE to clients.
+      // For more detail, see b/140135059.
+      return parser.parseFrom(arr, pos, len, ExtensionRegistryLite.getEmptyRegistry());
+    } catch (InvalidProtocolBufferException ex) {
+      Log.e(TAG, "Cannot deserialize message of type " + type, ex);
+      return null;
+    }
+  }
+
+  /**
+   * Serializes the given MessageLite messages into a ByteBuffer, with either a CRC of the whole
+   * content at the end of the buffer (tail CRC) or a CRC of every message at the end of the
+   * message.
+   *
+   * @param coll The messages to write.
+   * @param tailCrc true to use a tail CRC, false to put a CRC after every message.
+   * @return A ByteBuffer containing the serialized messages.
+   */
+  @Nullable
+  public static <T extends MessageLite> ByteBuffer dumpIntoBuffer(
+      Iterable<T> coll, boolean tailCrc) {
+    int count = 0;
+    long toWriteOut = tailCrc ? CRC_LEN : 0;
+
+    final int extraBytesPerMessage = tailCrc ? INT_BYTE_SIZE : INT_BYTE_SIZE + CRC_LEN;
+    // First, get the size of how much will be written out
+    // TODO find out if there is a adder util thingy I can use (could be parallel)
+    for (MessageLite mess : coll) {
+      toWriteOut += extraBytesPerMessage + mess.getSerializedSize();
+      ++count;
+    }
+    if (count == 0) {
+      // If there are no counters to write, don't even bother with the checksum.
+      return ByteBuffer.allocate(0);
+    }
+    // Now we got this, make a ByteBuffer to hold all that we need to
+    ByteBuffer buff = null;
+    try {
+      buff = ByteBuffer.allocate((int) toWriteOut);
+    } catch (IllegalArgumentException ex) {
+      Log.e(TAG, String.format("Too big to serialize, %s", prettyPrintBytes(toWriteOut)), ex);
+      return null;
+    }
+
+    // According to ByteBuffer#array()'s spec, this should not copy the backing array
+    byte[] arr = buff.array();
+    // Also conveniently is where we need to write next
+    int writtenSoFar = 0;
+    // Now add in the serialized forms
+    for (MessageLite mess : coll) {
+      // As we called getSerializedSize above, this is assured to give us a non-bogus answer
+      int bytesInMessage = mess.getSerializedSize();
+      try {
+        buff.putInt(bytesInMessage);
+      } catch (BufferOverflowException ex) {
+        handleBufferOverflow(ex);
+        return null;
+      }
+      writtenSoFar += INT_BYTE_SIZE;
+      // We are writing past the end of where buff is currently "looking at",
+      // So reusing the backing array here should be fine.
+      try {
+        mess.writeTo(CodedOutputStream.newInstance(arr, writtenSoFar, bytesInMessage));
+      } catch (IOException e) {
+        Log.e(TAG, "Exception while writing to buffer.", e);
+      }
+
+      // Same as above, but reading past the end this time.
+      try {
+        buff.put(arr, writtenSoFar, bytesInMessage);
+      } catch (BufferOverflowException ex) {
+        handleBufferOverflow(ex);
+        return null;
+      }
+      writtenSoFar += bytesInMessage;
+      if (!tailCrc) {
+        appendCRC(buff, arr, writtenSoFar - bytesInMessage, bytesInMessage);
+        writtenSoFar += CRC_LEN;
+      }
+    }
+    if (tailCrc) {
+      try {
+        appendCRC(buff, arr, 0, writtenSoFar);
+      } catch (BufferOverflowException ex) {
+        handleBufferOverflow(ex);
+        return null;
+      }
+    }
+    buff.rewind();
+    return buff;
+  }
+
+  /** Return string from proto bytes when we know bytes are UTF-8. */
+  public static String getDataString(byte[] data) {
+    return new String(data, Charset.forName("UTF-8"));
+  }
+
+  /** Return null if input is empty (or null). */
+  @Nullable
+  public static String nullIfEmpty(String input) {
+    return TextUtils.isEmpty(input) ? null : input;
+  }
+
+  /** Return null if input array is empty (or null). */
+  @Nullable
+  public static <T> T[] nullIfEmpty(T[] input) {
+    return input == null || input.length == 0 ? null : input;
+  }
+
+  /** Similar to Objects.equal but available pre-kitkat. */
+  public static boolean safeEqual(Object a, Object b) {
+    return a == null ? b == null : a.equals(b);
+  }
+
+  /** Wraps MessageLite.toByteArray to check for null and return null if that's the case. */
+  @Nullable
+  public static final byte[] safeToByteArray(MessageLite msg) {
+    return msg == null ? null : msg.toByteArray();
+  }
+
+  private static void handleBufferUnderflow(BufferUnderflowException ex, String typename) {
+    Log.e(
+        TAG,
+        String.format("Buffer underflow. May have given the wrong message type: %s", typename),
+        ex);
+  }
+
+  private static void handleBufferOverflow(BufferOverflowException ex) {
+    Log.e(
+        TAG,
+        "Buffer underflow. A message may have an invalid serialized form"
+            + " or has been concurrently modified.",
+        ex);
+  }
+
+  /**
+   * Reads the bytes given in an array and appends the CRC32 checksum to the ByteBuffer. The
+   * location the CRC32 checksum will be written is the {@link ByteBuffer#position() current
+   * position} in the ByteBuffer. The given ByteBuffer must have must have enough room (starting at
+   * its position) to fit an additonal {@link #CRC_LEN} bytes.
+   *
+   * @param dest where to write the CRC32 checksum; must have enough room to fit an additonal {@link
+   *     #CRC_LEN} bytes
+   * @param src the array of bytes containing the data to checksum
+   * @param off offset of where to start reading the array
+   * @param len number of bytes to read in the array
+   */
+  private static void appendCRC(ByteBuffer dest, byte[] src, int off, int len) {
+    CRC32 crc = new CRC32();
+    crc.update(src, off, len);
+    dest.putLong(crc.getValue());
+  }
+
+  private static boolean validateCRC(byte[] arr, int off, int len, long expectedCRC) {
+    CRC32 crc = new CRC32();
+    crc.update(arr, off, len);
+    long computedCRC = crc.getValue();
+    boolean matched = computedCRC == expectedCRC;
+    if (!matched) {
+      Log.e(
+          TAG,
+          String.format(
+              "Corrupt protobuf data, expected CRC: %d computed CRC: %d",
+              expectedCRC, computedCRC));
+    }
+    return matched;
+  }
+
+  private ProtoLiteUtil() {
+    // No instantiation.
+  }
+
+  private static String prettyPrintBytes(long bytes) {
+    if (bytes > 1024L * 1024 * 1024) {
+      return String.format(Locale.US, "%.2fGB", (double) bytes / (1024L * 1024 * 1024));
+    } else if (bytes > 1024 * 1024) {
+      return String.format(Locale.US, "%.2fMB", (double) bytes / (1024 * 1024));
+    } else if (bytes > 1024) {
+      return String.format(Locale.US, "%.2fKB", (double) bytes / 1024);
+    }
+    return String.format(Locale.US, "%d Bytes", bytes);
+  }
+}
+// LINT.ThenChange(<internal>)
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/util/SharedFilesMetadataUtil.java b/java/com/google/android/libraries/mobiledatadownload/internal/util/SharedFilesMetadataUtil.java
new file mode 100644
index 0000000..323819b
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/util/SharedFilesMetadataUtil.java
@@ -0,0 +1,154 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.util;
+
+import static com.google.android.libraries.mobiledatadownload.internal.MddConstants.SPLIT_CHAR;
+
+import android.content.Context;
+import com.google.android.libraries.mobiledatadownload.SilentFeedback;
+import com.google.android.libraries.mobiledatadownload.internal.Migrations;
+import com.google.common.base.Splitter;
+import com.google.mobiledatadownload.TransformProto.Transforms;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal.AllowedReaders;
+import com.google.mobiledatadownload.internal.MetadataProto.NewFileKey;
+import com.google.protobuf.InvalidProtocolBufferException;
+import java.util.List;
+
+/** Utilities needed by multiple implementations of {@link SharedFilesMetadata}. */
+public final class SharedFilesMetadataUtil {
+
+  private static final String TAG = "SharedFilesMetadataUtil";
+
+  // Stores the mapping from FileKey:SharedFile.
+  public static final String MDD_SHARED_FILES = "gms_icing_mdd_shared_files";
+
+  /** File key Deserialization exception. */
+  public static class FileKeyDeserializationException extends Exception {
+    FileKeyDeserializationException(String msg) {
+      super(msg);
+    }
+
+    FileKeyDeserializationException(String msg, Throwable cause) {
+      super(msg, cause);
+    }
+  }
+
+  public static String getSerializedFileKey(
+      NewFileKey newFileKey, Context context, SilentFeedback silentFeedback) {
+    switch (Migrations.getCurrentVersion(context, silentFeedback)) {
+      case NEW_FILE_KEY:
+        return serializeNewFileKey(newFileKey);
+      case ADD_DOWNLOAD_TRANSFORM:
+        return serializeNewFileKeyWithDownloadTransform(newFileKey);
+      case USE_CHECKSUM_ONLY:
+        return serializeNewFileKeyWithChecksumOnly(newFileKey);
+    }
+    return serializeNewFileKey(newFileKey);
+  }
+
+  public static String serializeNewFileKey(NewFileKey newFileKey) {
+    return new StringBuilder(newFileKey.getUrlToDownload())
+        .append(SPLIT_CHAR)
+        .append(newFileKey.getByteSize())
+        .append(SPLIT_CHAR)
+        .append(newFileKey.getChecksum())
+        .append(SPLIT_CHAR)
+        .append(newFileKey.getAllowedReaders().getNumber())
+        .toString();
+  }
+
+  public static String serializeNewFileKeyWithDownloadTransform(NewFileKey newFileKey) {
+    return new StringBuilder(newFileKey.getUrlToDownload())
+        .append(SPLIT_CHAR)
+        .append(newFileKey.getByteSize())
+        .append(SPLIT_CHAR)
+        .append(newFileKey.getChecksum())
+        .append(SPLIT_CHAR)
+        .append(newFileKey.getAllowedReaders().getNumber())
+        .append(SPLIT_CHAR)
+        .append(
+            newFileKey.hasDownloadTransforms()
+                ? SharedPreferencesUtil.serializeProto(newFileKey.getDownloadTransforms())
+                : "")
+        .toString();
+  }
+
+  public static String serializeNewFileKeyWithChecksumOnly(NewFileKey newFileKey) {
+    return new StringBuilder(newFileKey.getChecksum())
+        .append(SPLIT_CHAR)
+        .append(newFileKey.getAllowedReaders().getNumber())
+        .toString();
+  }
+
+  public static NewFileKey deserializeNewFileKey(
+      String serializedFileKey, Context context, SilentFeedback silentFeedback)
+      throws FileKeyDeserializationException {
+    List<String> fileKeyComponents = Splitter.on(SPLIT_CHAR).splitToList(serializedFileKey);
+    NewFileKey.Builder newFileKey;
+
+    switch (Migrations.getCurrentVersion(context, silentFeedback)) {
+      case ADD_DOWNLOAD_TRANSFORM:
+        if (fileKeyComponents.size() != 5) {
+          throw new FileKeyDeserializationException(
+              "Bad-format" + " serializedFileKey" + " = " + serializedFileKey);
+        }
+        newFileKey =
+            NewFileKey.newBuilder()
+                .setUrlToDownload(fileKeyComponents.get(0))
+                .setByteSize(Integer.parseInt(fileKeyComponents.get(1)))
+                .setChecksum(fileKeyComponents.get(2))
+                .setAllowedReaders(
+                    AllowedReaders.forNumber(Integer.parseInt(fileKeyComponents.get(3))));
+        if (fileKeyComponents.get(4) != null && !fileKeyComponents.get(4).isEmpty()) {
+          try {
+            newFileKey.setDownloadTransforms(
+                SharedPreferencesUtil.parseLiteFromEncodedString(
+                    fileKeyComponents.get(4), Transforms.parser()));
+          } catch (InvalidProtocolBufferException e) {
+            throw new FileKeyDeserializationException(
+                "Failed to deserialize key:" + serializedFileKey, e);
+          }
+        }
+        break;
+      case USE_CHECKSUM_ONLY:
+        if (fileKeyComponents.size() != 2) {
+          throw new FileKeyDeserializationException(
+              "Bad-format" + " serializedFileKey" + " = s" + serializedFileKey);
+        }
+        newFileKey =
+            NewFileKey.newBuilder()
+                .setChecksum(fileKeyComponents.get(0))
+                .setAllowedReaders(
+                    AllowedReaders.forNumber(Integer.parseInt(fileKeyComponents.get(1))));
+        break;
+      default: // Fall through
+        if (fileKeyComponents.size() != 4) {
+          throw new FileKeyDeserializationException(
+              "Bad-format" + " serializedFileKey" + " = " + serializedFileKey);
+        }
+        newFileKey =
+            NewFileKey.newBuilder()
+                .setUrlToDownload(fileKeyComponents.get(0))
+                .setByteSize(Integer.parseInt(fileKeyComponents.get(1)))
+                .setChecksum(fileKeyComponents.get(2))
+                .setAllowedReaders(
+                    AllowedReaders.forNumber(Integer.parseInt(fileKeyComponents.get(3))));
+    }
+    return newFileKey.build();
+  }
+
+  private SharedFilesMetadataUtil() {}
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/util/SharedPreferencesUtil.java b/java/com/google/android/libraries/mobiledatadownload/internal/util/SharedPreferencesUtil.java
new file mode 100644
index 0000000..54341a2
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/util/SharedPreferencesUtil.java
@@ -0,0 +1,164 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.util;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.util.Base64;
+import com.google.common.base.Optional;
+import com.google.errorprone.annotations.CheckReturnValue;
+import com.google.protobuf.ExtensionRegistryLite;
+import com.google.protobuf.InvalidProtocolBufferException;
+import com.google.protobuf.MessageLite;
+import com.google.protobuf.Parser;
+import java.io.IOException;
+import javax.annotation.Nullable;
+
+/**
+ * Simple util to read/write protos from/to {@link SharedPreferences}.
+ *
+ * <p>Protos are serialized, and the binary value is base-64 encoded without padding or wrapping.
+ */
+@CheckReturnValue
+public class SharedPreferencesUtil {
+
+  /**
+   * Reads the shared pref value corresponding to the specified key as a lite proto of type 'T'. The
+   * read value is populated to 'protoValue' which should already be constructed by the caller.
+   *
+   * @return the proto or null if no such element was found or could not be parsed.
+   */
+  @Nullable
+  public static <K extends MessageLite, V extends MessageLite> V readProto(
+      SharedPreferences prefs, K liteKey, Parser<V> parser) {
+    return readProto(prefs, serializeProto(liteKey), parser);
+  }
+
+  /**
+   * Reads the shared pref value corresponding to the specified key as a lite proto of type 'T'. The
+   * read value is populated to 'protoValue' which should already be constructed by the caller.
+   *
+   * @return the proto or null if no such element was found or could not be parse.
+   */
+  @Nullable
+  public static <T extends MessageLite> T readProto(
+      SharedPreferences prefs, String key, Parser<T> parser) {
+    String encodedLiteString = prefs.getString(key, null);
+    if (encodedLiteString == null) {
+      return null;
+    }
+    try {
+      return parseLiteFromEncodedString(encodedLiteString, parser);
+    } catch (InvalidProtocolBufferException e) {
+      return null;
+    }
+  }
+
+  /**
+   * Write and commit the serialized form of the proto into pref, corresponding to the give proto
+   * key.
+   */
+  public static <T extends MessageLite> boolean writeProto(
+      SharedPreferences prefs, final String key, final T protoValue) {
+    SharedPreferences.Editor editor = prefs.edit();
+    writeProto(editor, key, protoValue);
+    return editor.commit();
+  }
+
+  /**
+   * Write and commit the serialized form of the proto into pref, corresponding to the give proto
+   * key.
+   */
+  public static <K extends MessageLite, T extends MessageLite> boolean writeProto(
+      SharedPreferences prefs, final K protoKey, final T protoValue) {
+    SharedPreferences.Editor editor = prefs.edit();
+    writeProto(editor, protoKey, protoValue);
+    return editor.commit();
+  }
+
+  /**
+   * Write the serialized form of the proto into shared pref, corresponding to the given proto key.
+   */
+  public static <K extends MessageLite, T extends MessageLite> void writeProto(
+      SharedPreferences.Editor editor, final K protoKey, final T protoValue) {
+    writeProto(editor, serializeProto(protoKey), protoValue);
+  }
+
+  /**
+   * Write the serialized form of the proto into shared pref, corresponding to the given string key.
+   */
+  public static <T extends MessageLite> void writeProto(
+      SharedPreferences.Editor editor, final String key, final T protoValue) {
+    editor.putString(key, serializeProto(protoValue));
+  }
+
+  /** Removes whatever value corresponds the protoKey from shared prefs. */
+  public static <K extends MessageLite> boolean removeProto(
+      SharedPreferences prefs, String protoKey) {
+    return prefs.edit().remove(protoKey).commit();
+  }
+
+  /** Removes whatever value corresponds the protoKey from shared prefs. */
+  public static <K extends MessageLite> void removeProto(
+      SharedPreferences.Editor editor, final K protoKey) {
+    editor.remove(serializeProto(protoKey));
+  }
+
+  /** Removes whatever value corresponds the protoKey from shared prefs. */
+  public static void removeProto(SharedPreferences.Editor editor, String protoKey) {
+    editor.remove(protoKey);
+  }
+
+  /** Converts a MessageLite to a string that can be used as a key in shared prefs. */
+  public static String serializeProto(MessageLite lite) {
+    byte[] byteValue = lite.toByteArray();
+    return Base64.encodeToString(byteValue, Base64.NO_PADDING | Base64.NO_WRAP);
+  }
+
+  /**
+   * Parses a MessageLite from the base64 encoded string.
+   *
+   * @return the proto.
+   */
+  public static <T extends MessageLite> T parseLiteFromEncodedString(
+      String base64Encoded, Parser<T> parser) throws InvalidProtocolBufferException {
+    byte[] byteValue;
+    try {
+      byteValue = Base64.decode(base64Encoded, Base64.NO_PADDING | Base64.NO_WRAP);
+    } catch (IllegalArgumentException e) {
+      throw new InvalidProtocolBufferException(
+          "Unable to decode to byte array", new IOException(e));
+    }
+
+    // Cannot use generated registry here, because it may cause NPE to clients.
+    // For more detail, see b/140135059.
+    return parser.parseFrom(byteValue, ExtensionRegistryLite.getEmptyRegistry());
+  }
+
+  /** Returns the SharedPreferences name for {@code instanceId}. */
+  // TODO(b/204094591): determine whether instanceId is ever actually null.
+  public static String getSharedPreferencesName(String baseName, Optional<String> instanceId) {
+    return instanceId != null && instanceId.isPresent() ? baseName + instanceId.get() : baseName;
+  }
+
+  /** Return the SharedPreferences for InstanceId */
+  // TODO(b/204094591): determine whether instanceId is ever actually null.
+  public static SharedPreferences getSharedPreferences(
+      Context context, String baseName, Optional<String> instanceId) {
+    return context.getSharedPreferences(
+        getSharedPreferencesName(baseName, instanceId), Context.MODE_PRIVATE);
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/internal/util/SymlinkUtil.java b/java/com/google/android/libraries/mobiledatadownload/internal/util/SymlinkUtil.java
new file mode 100644
index 0000000..9ec91e8
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/internal/util/SymlinkUtil.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.util;
+
+import static android.system.Os.readlink;
+import static android.system.Os.symlink;
+
+import android.content.Context;
+import android.net.Uri;
+import android.os.Build.VERSION_CODES;
+import android.system.ErrnoException;
+import androidx.annotation.RequiresApi;
+import com.google.android.libraries.mobiledatadownload.file.backends.AndroidUri;
+import com.google.android.libraries.mobiledatadownload.file.backends.AndroidUriAdapter;
+import com.google.android.libraries.mobiledatadownload.file.common.MalformedUriException;
+import java.io.IOException;
+
+/** Utility class to create symlinks (if supported). */
+public final class SymlinkUtil {
+  private SymlinkUtil() {}
+
+  /**
+   * Creates a symlink at the given link Uri to the given target Uri.
+   *
+   * <p>This method will only work for API level 21+ since this is when Android added a platform
+   * level symlink function.
+   *
+   * @param context the caller's context
+   * @param linkUri location to create the symlink
+   * @param targetUri location where the symlink should point
+   * @throws IOException when symlink could not be created
+   */
+  @RequiresApi(VERSION_CODES.LOLLIPOP)
+  public static void createSymlink(Context context, Uri linkUri, Uri targetUri) throws IOException {
+    try {
+      AndroidUriAdapter adapter = AndroidUriAdapter.forContext(context);
+      symlink(
+          adapter.toFile(targetUri).getAbsolutePath(), adapter.toFile(linkUri).getAbsolutePath());
+    } catch (MalformedUriException | ErrnoException e) {
+      // wrap the exception so it isn't explicitly referenced at higher levels that run on
+      // API level 21 or below.
+      throw new IOException("Unable to create symlink", e);
+    }
+  }
+
+  /**
+   * Reads the given symlink Uri and returns its target Uri.
+   *
+   * <p>This method will only work for API level 21+ since this is when Android added a platform
+   * level readlink function. It wraps around Android's readlink function and exposes it in a
+   * version safe way behind the @RequiresApi annotation.
+   *
+   * <p>NOTE: This method only verifies the given input as a valid symlink and points to a target
+   * uri. However, it makes no guarantees about the target uri (i.e. whether the target exists).
+   *
+   * @param context the caller's context
+   * @param symlinkUri the symlink location that should be checked
+   * @return Uri to the target location of the symlink
+   * @throws IOException when symlink is unable to be checked
+   */
+  @RequiresApi(VERSION_CODES.LOLLIPOP)
+  public static Uri readSymlink(Context context, Uri symlinkUri) throws IOException {
+    try {
+      AndroidUriAdapter adapter = AndroidUriAdapter.forContext(context);
+      String targetPath = readlink(adapter.toFile(symlinkUri).getAbsolutePath());
+
+      if (targetPath == null) {
+        throw new IOException("Unable to read symlink");
+      }
+
+      return AndroidUri.builder(context)
+          .fromAbsolutePath(targetPath, /* accountManager= */ null)
+          .build();
+    } catch (MalformedUriException | ErrnoException e) {
+      // wrap the exception so it isn't explicitly referenced at higher levels that run on
+      // API level 21 or below.
+      throw new IOException("Unable to read symlink", e);
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/lite/BUILD b/java/com/google/android/libraries/mobiledatadownload/lite/BUILD
new file mode 100644
index 0000000..c1eb8fb
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/lite/BUILD
@@ -0,0 +1,74 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_library")
+
+# MDD Lite visibility is restricted to the following set of packages. Any
+# new clients must be added to this list in order to grant build visibility.
+package(
+    default_visibility = [
+        "//visibility:public",
+    ],
+    licenses = ["notice"],
+)
+
+android_library(
+    name = "lite",
+    srcs = glob(
+        ["*.java"],
+        exclude = [
+            "DownloadListener.java",
+            "DownloadProgressMonitor.java",
+            "SingleFileDownloadProgressMonitor.java",
+        ],
+    ),
+    deps = [
+        ":DownloadListener",
+        ":DownloadProgressMonitor",
+        "//java/com/google/android/libraries/mobiledatadownload:DownloadException",
+        "//java/com/google/android/libraries/mobiledatadownload/downloader:FileDownloader",
+        "//java/com/google/android/libraries/mobiledatadownload/foreground:NotificationUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LogUtil",
+        "@androidx_core_core",
+        "@com_google_auto_value",
+        "@com_google_dagger",
+        "@com_google_guava_guava",
+        "@org_checkerframework_qual",
+    ],
+)
+
+android_library(
+    name = "DownloadListener",
+    srcs = ["DownloadListener.java"],
+    deps = [
+        "//proto:client_config_java_proto_lite",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "DownloadProgressMonitor",
+    srcs = [
+        "DownloadProgressMonitor.java",
+        "SingleFileDownloadProgressMonitor.java",
+    ],
+    deps = [
+        ":DownloadListener",
+        "//java/com/google/android/libraries/mobiledatadownload:TimeSource",
+        "//java/com/google/android/libraries/mobiledatadownload/file/spi",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LogUtil",
+        "@androidx_annotation_annotation",
+        "@com_google_code_findbugs_jsr305",
+        "@com_google_guava_guava",
+    ],
+)
diff --git a/java/com/google/android/libraries/mobiledatadownload/lite/DownloadListener.java b/java/com/google/android/libraries/mobiledatadownload/lite/DownloadListener.java
new file mode 100644
index 0000000..ec00839
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/lite/DownloadListener.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.lite;
+
+import com.google.common.util.concurrent.ListenableFuture;
+
+/**
+ * Supports registering for download progress update. Callbacks will be executed
+ * on @MddControlExecutor. If you need to do heavy work, please offload to a background task.
+ */
+public interface DownloadListener {
+  String TAG = "DownloadListener";
+
+  /**
+   * Will be triggered periodically with the current downloaded size of the being downloaded file.
+   * This could be used to show progressbar to users.
+   */
+  void onProgress(long currentSize);
+
+  /**
+   * This will be called when the download is completed successfully. MDD will keep the Foreground
+   * Download Service alive so that the onComplete can finish without being killed by Android.
+   */
+  ListenableFuture<Void> onComplete();
+
+  /** This will be called when the download failed. */
+  void onFailure(Throwable t);
+
+  /**
+   * Callback triggered when all downloads are in a state waiting for connectivity, and no download
+   * progress is happening until connectivity resumes.
+   */
+  void onPausedForConnectivity();
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/lite/DownloadProgressMonitor.java b/java/com/google/android/libraries/mobiledatadownload/lite/DownloadProgressMonitor.java
new file mode 100644
index 0000000..d0fd6fa
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/lite/DownloadProgressMonitor.java
@@ -0,0 +1,167 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.lite;
+
+import android.net.Uri;
+import androidx.annotation.VisibleForTesting;
+import com.google.android.libraries.mobiledatadownload.TimeSource;
+import com.google.android.libraries.mobiledatadownload.file.spi.Monitor;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil;
+import com.google.common.util.concurrent.MoreExecutors;
+import java.util.HashMap;
+import java.util.concurrent.Executor;
+import java.util.concurrent.atomic.AtomicLong;
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.GuardedBy;
+import javax.annotation.concurrent.ThreadSafe;
+
+/** A Download Progress Monitor to support {@link DownloadListener}. */
+@ThreadSafe
+public class DownloadProgressMonitor implements Monitor, SingleFileDownloadProgressMonitor {
+
+  private static final String TAG = "DownloadProgressMonitor";
+
+  private final TimeSource timeSource;
+  private final Executor sequentialControlExecutor;
+
+  // NOTE: GuardRails prohibits multiple public constructors
+  private DownloadProgressMonitor(TimeSource timeSource, Executor controlExecutor) {
+    this.timeSource = timeSource;
+
+    // We want onProgress to be executed in order otherwise clients will observe out of order
+    // updates (bigger current size update appears before smaller current size update).
+    // We use Sequential Executor to ensure the onProgress will be processed sequentially.
+    this.sequentialControlExecutor = MoreExecutors.newSequentialExecutor(controlExecutor);
+  }
+
+  /** Constructor overload with {@link TimeSource}. */
+  // NOTE: this is necessary for use by other MDD components.
+  public static DownloadProgressMonitor create(TimeSource timeSource, Executor controlExecutor) {
+    return new DownloadProgressMonitor(timeSource, controlExecutor);
+  }
+
+  // We will only broadcast on progress notification at most once in this time frame.
+  // Currently MobStore Monitor notify every 8KB of downloaded bytes. This may be too chatty on
+  // fast network.
+  // 1000 was chosen arbitrarily.
+  @VisibleForTesting static final long BUFFERED_TIME_MS = 1000;
+
+  @GuardedBy("DownloadProgressMonitor.class")
+  private final HashMap<Uri, DownloadedBytesCounter> uriToDownloadedBytesCounter = new HashMap<>();
+
+  @Override
+  @Nullable
+  public Monitor.InputMonitor monitorRead(Uri uri) {
+    return null;
+  }
+
+  @Override
+  @Nullable
+  public Monitor.OutputMonitor monitorWrite(Uri uri) {
+    synchronized (DownloadProgressMonitor.class) {
+      if (uriToDownloadedBytesCounter.get(uri) == null) {
+        // All monitors for a shared FileStorage will be invoked for all file accesses through this
+        // shared FileStorage. So this monitor can receive non-MDD-Lite Uri.
+        return null;
+      }
+      return uriToDownloadedBytesCounter.get(uri);
+    }
+  }
+
+  @Override
+  @Nullable
+  public Monitor.OutputMonitor monitorAppend(Uri uri) {
+    return monitorWrite(uri);
+  }
+
+  public void pausedForConnectivity() {
+    synchronized (DownloadProgressMonitor.class) {
+      for (DownloadedBytesCounter downloadedBytesCounter : uriToDownloadedBytesCounter.values()) {
+        downloadedBytesCounter.pausedForConnectivity();
+      }
+    }
+  }
+
+  @Override
+  public void addDownloadListener(Uri uri, DownloadListener downloadListener) {
+    synchronized (DownloadProgressMonitor.class) {
+      if (!uriToDownloadedBytesCounter.containsKey(uri)) {
+        uriToDownloadedBytesCounter.put(uri, new DownloadedBytesCounter(uri, downloadListener));
+      }
+    }
+  }
+
+  @Override
+  public void removeDownloadListener(Uri uri) {
+    synchronized (DownloadProgressMonitor.class) {
+      uriToDownloadedBytesCounter.remove(uri);
+    }
+  }
+
+  // A counter for bytes downloaded.
+  private final class DownloadedBytesCounter implements Monitor.OutputMonitor {
+    private final Uri uri;
+    private final DownloadListener downloadListener;
+
+    private final AtomicLong byteCounter = new AtomicLong();
+
+    // Last timestamp that we broadcast on progress.
+    private long lastBroadcastOnProgressTimestampMs;
+
+    DownloadedBytesCounter(Uri uri, DownloadListener downloadListener) {
+      this.uri = uri;
+      this.downloadListener = downloadListener;
+      lastBroadcastOnProgressTimestampMs = timeSource.currentTimeMillis();
+    }
+
+    @Override
+    public void bytesWritten(byte[] b, int off, int len) {
+      notifyProgress(len);
+    }
+
+    private void notifyProgress(long len) {
+      // Only broadcast progress update every BUFFERED_TIME_MS.
+      // It will be fast (no locking) when there is no need to broadcast progress.
+      // When there is a need to broadcast progress, we need to obtain the lock due to 2 reasons:
+      // 1- Concurrent access to uriToDownloadedBytesCounter.
+      // 2- Prevent out of order progress update.
+      if (timeSource.currentTimeMillis() - lastBroadcastOnProgressTimestampMs < BUFFERED_TIME_MS) {
+        byteCounter.getAndAdd(len);
+        LogUtil.v(
+            "%s: Received data for uri = %s, len = %d, Counter = %d",
+            TAG, uri, len, byteCounter.get());
+      } else {
+        synchronized (DownloadProgressMonitor.class) {
+          // Reset timestamp.
+          lastBroadcastOnProgressTimestampMs = timeSource.currentTimeMillis();
+
+          byteCounter.getAndAdd(len);
+          LogUtil.v(
+              "%s: Received data for uri = %s, len = %d, Counter = %d",
+              TAG, uri, len, byteCounter.get());
+
+          if (uriToDownloadedBytesCounter.containsKey(uri)) {
+            sequentialControlExecutor.execute(() -> downloadListener.onProgress(byteCounter.get()));
+          }
+        }
+      }
+    }
+
+    public void pausedForConnectivity() {
+      downloadListener.onPausedForConnectivity();
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/lite/DownloadRequest.java b/java/com/google/android/libraries/mobiledatadownload/lite/DownloadRequest.java
new file mode 100644
index 0000000..842ac55
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/lite/DownloadRequest.java
@@ -0,0 +1,149 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.lite;
+
+import android.net.Uri;
+import android.util.Pair;
+import com.google.android.libraries.mobiledatadownload.downloader.DownloadConstraints;
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Optional;
+import com.google.common.collect.ImmutableList;
+
+/** Request to download a file. */
+@AutoValue
+public abstract class DownloadRequest {
+
+  // Default value for Traffic Tag if not set by clients.
+  // MDDLite will not tag the traffic if the TrafficTag is not set to a valid value (>0).
+  private static final int UNSPECIFIED_TRAFFIC_TAG = -1;
+
+  DownloadRequest() {}
+
+  // The Destination File Uri to download the file at.
+  public abstract Uri destinationFileUri();
+
+  // The url to download the file from.
+  public abstract String urlToDownload();
+
+  // Conditions under which this file should be downloaded.
+  public abstract DownloadConstraints downloadConstraints();
+
+  /** If present, will receive download progress update. */
+  public abstract Optional<DownloadListener> listenerOptional();
+
+  // Traffic tag used for this request.
+  // If not set, it will take the default value of UNSPECIFIED_TRAFFIC_TAG and MDD will not tag the
+  // traffic.
+  public abstract int trafficTag();
+
+  // The extra HTTP headers for this request.
+  public abstract ImmutableList<Pair<String, String>> extraHttpHeaders();
+
+  // The size of the being downloaded file in bytes.
+  // This is used to display the progressbar.
+  // If not specified, an indeterminate progressbar will be displayed.
+  // https://developer.android.com/reference/android/app/Notification.Builder.html#setProgress(int,%20int,%20boolean)
+  public abstract int fileSizeBytes();
+
+  // Used only by Foreground download.
+  // The Content Title of the associated Notification for this download.
+  public abstract String notificationContentTitle();
+
+  // Used only by Foreground download.
+  // If Present, the Content Text (description) of the associated Notification for this download.
+  // Otherwise, the Content Text will be the url to download.
+  public abstract Optional<String> notificationContentTextOptional();
+
+  // Whether to show the downloaded notification. If false, MDD will automatically remove this
+  // notification when the download finished.
+  public abstract boolean showDownloadedNotification();
+
+  public static Builder newBuilder() {
+    return new AutoValue_DownloadRequest.Builder()
+        .setTrafficTag(UNSPECIFIED_TRAFFIC_TAG)
+        .setExtraHttpHeaders(ImmutableList.of())
+        .setFileSizeBytes(0)
+        .setShowDownloadedNotification(true);
+  }
+
+  /** Builder for {@link DownloadRequest}. */
+  @AutoValue.Builder
+  public abstract static class Builder {
+    Builder() {}
+
+    /** Sets the destination file uri. */
+    public abstract Builder setDestinationFileUri(Uri fileUri);
+
+    /** Sets the url to download. */
+    public abstract Builder setUrlToDownload(String urlToDownload);
+
+    /** Sets the DowloadConstraints. */
+    public abstract Builder setDownloadConstraints(DownloadConstraints downloadConstraints);
+
+    /** Sets the optional download listener. If present, will receive download progress update. */
+    public abstract Builder setListenerOptional(Optional<DownloadListener> listenerOptional);
+
+    /** Sets the traffic tag for this request. */
+    public abstract Builder setTrafficTag(int trafficTag);
+
+    /** Sets the extra HTTP headers for this request. */
+    public abstract Builder setExtraHttpHeaders(
+        ImmutableList<Pair<String, String>> extraHttpHeaders);
+
+    /**
+     * The size of the being downloaded file in bytes. This is used to display the progressbar. If
+     * not specified, a indeterminate progressbar will be displayed.
+     * https://developer.android.com/reference/android/app/Notification.Builder.html#setProgress(int,%20int,%20boolean)
+     */
+    public abstract Builder setFileSizeBytes(int fileSizeBytes);
+
+    /** Sets the Notification Content Tile which will be used for foreground download */
+    public abstract Builder setNotificationContentTitle(String notificationContentTitle);
+
+    /**
+     * Sets the Notification Context Text which will be used for foreground downloads.
+     *
+     * <p>If not set, the url to download will be used instead.
+     */
+    public abstract Builder setNotificationContentTextOptional(
+        Optional<String> notificationContentTextOptional);
+
+    /**
+     * Sets to show Downloaded Notification after the download finished successfully. This is only
+     * be used for foreground download. Default value is to show the downloaded notification.
+     */
+    public abstract Builder setShowDownloadedNotification(boolean showDownloadedNotification);
+
+    /** Builds {@link DownloadRequest}. */
+    public final DownloadRequest build() {
+      // If notification content title is not provided, use urlToDownload as a fallback
+      if (!notificationContentTitle().isPresent()) {
+        setNotificationContentTitle(urlToDownload());
+      }
+      // Use AutoValue's generated build to finish building.
+      return autoBuild();
+    }
+
+    // private getter generated by AutoValue for access in build().
+    abstract String urlToDownload();
+
+    // private getter generated by AutoValue for access in build().
+    abstract Optional<String> notificationContentTitle();
+
+    // private build method to be generated by AutoValue.
+    abstract DownloadRequest autoBuild();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/lite/Downloader.java b/java/com/google/android/libraries/mobiledatadownload/lite/Downloader.java
new file mode 100644
index 0000000..208132c
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/lite/Downloader.java
@@ -0,0 +1,155 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.lite;
+
+import android.content.Context;
+import com.google.android.libraries.mobiledatadownload.downloader.FileDownloader;
+import com.google.common.base.Optional;
+import com.google.common.base.Preconditions;
+import com.google.common.base.Supplier;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.errorprone.annotations.CheckReturnValue;
+import java.util.concurrent.Executor;
+
+/** The root object and entry point for the MDDLite (<internal>). */
+public interface Downloader {
+
+  /**
+   * Downloads a file with the given {@link DownloadRequest}.
+   *
+   * <p>This method will not create a notification and will not run in a ForegroundService.
+   *
+   * <p>NOTE: The caller is responsible for keeping the download alive. This is typically used by
+   * clients who use a service the platform binds with, so a notification is not needed. If you are
+   * unsure whether to use this method or {@link #downloadWithForegroundService}, contact the MDD
+   * team (<internal>@ or via <a href="<internal>">yaqs</a>.
+   */
+  @CheckReturnValue
+  ListenableFuture<Void> download(DownloadRequest downloadRequest);
+
+  /**
+   * Download a file and show foreground download progress in a notification. User can cancel the
+   * download from the notification menu.
+   *
+   * <p>NOTE: Calling downloadWithForegroundService without a provided ForegroundService will return
+   * a failed future.
+   *
+   * <p>The cancel action in the notification menu requires the ForegroundService to be registered
+   * with the application (via the AndroidManifest.xml). This allows the cancellation intents to be
+   * properly picked up. To register the service, the following lines must be included in the app's
+   * {@code AndroidManifest.xml}:
+   *
+   * <pre>{@code
+   * <!-- Needed by foreground download service -->
+   * <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
+   *
+   * <!-- Service for MDD Lite foreground downloads -->
+   * <service
+   *   android:name="com.google.android.libraries.mobiledatadownload.lite.sting.ForegroundDownloadService"
+   *   android:exported="false" />
+   * }</pre>
+   *
+   * <p>NOTE: The above excerpt is for Framework and Sting apps. Dagger apps should use the same
+   * excerpt, but change the {@code android:name} property to:
+   *
+   * <pre>{@code
+   * android:name="com.google.android.libraries.mobiledatadownload.lite.dagger.ForegroundDownloadService"
+   * }</pre>
+   */
+  @CheckReturnValue
+  ListenableFuture<Void> downloadWithForegroundService(DownloadRequest downloadRequest);
+
+  /** Cancel an on-going foreground download. */
+  void cancelForegroundDownload(String destinationFileUri);
+
+  static Downloader.Builder newBuilder() {
+    return new Downloader.Builder();
+  }
+
+  /** A Builder for the {@link Downloader}. */
+  final class Builder {
+
+    private static final String TAG = "Builder";
+    private Executor sequentialControlExecutor;
+
+    private Context context;
+    private Supplier<FileDownloader> fileDownloaderSupplier;
+    private Optional<SingleFileDownloadProgressMonitor> downloadMonitorOptional = Optional.absent();
+    private Optional<Class<?>> foregroundDownloadServiceClassOptional = Optional.absent();
+
+    public Builder setContext(Context context) {
+      this.context = context.getApplicationContext();
+      return this;
+    }
+
+    /** Set the Control Executor which will run MDDLite control flow. */
+    public Builder setControlExecutor(Executor controlExecutor) {
+      Preconditions.checkNotNull(controlExecutor);
+      // Executor that will execute tasks sequentially.
+      this.sequentialControlExecutor = MoreExecutors.newSequentialExecutor(controlExecutor);
+      return this;
+    }
+
+    /**
+     * Set the SingleFileDownloadProgressMonitor. This instance must be the same instance that is
+     * registered with SynchronousFileStorage.
+     *
+     * <p>This is required to use {@link Downloader#downloadWithForegroundService}. Not providing
+     * this will result in a failed future when calling downloadWithForegroundService.
+     *
+     * <p>This is required to track progress updates and network pauses when passing a {@link
+     * DownloadListener} to {@link Downloader#download}. The DownloadListener's {@code onFailure}
+     * and {@code onComplete} will be invoked regardless of whether this is set.
+     */
+    public Builder setDownloadMonitor(SingleFileDownloadProgressMonitor downloadMonitor) {
+      this.downloadMonitorOptional = Optional.of(downloadMonitor);
+      return this;
+    }
+
+    /**
+     * Set the Foreground Download Service. This foreground service will keep the download alive
+     * even if the user navigates away from the host app. This ensures long download can finish.
+     *
+     * <p>This is required to use {@link Downloader#downloadWithForegroundService}. Not providing
+     * this will result in a failed future when calling downloadWithForegroundService.
+     */
+    public Builder setForegroundDownloadService(Class<?> foregroundDownloadServiceClass) {
+      this.foregroundDownloadServiceClassOptional = Optional.of(foregroundDownloadServiceClass);
+      return this;
+    }
+
+    /**
+     * Set the FileDownloader Supplier. MDDLite takes in a Supplier of FileDownload to support lazy
+     * instantiation of the FileDownloader
+     */
+    public Builder setFileDownloaderSupplier(Supplier<FileDownloader> fileDownloaderSupplier) {
+      this.fileDownloaderSupplier = fileDownloaderSupplier;
+      return this;
+    }
+
+    Builder() {}
+
+    public Downloader build() {
+      return new DownloaderImpl(
+          context,
+          foregroundDownloadServiceClassOptional,
+          sequentialControlExecutor,
+          downloadMonitorOptional,
+          fileDownloaderSupplier);
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/lite/DownloaderImpl.java b/java/com/google/android/libraries/mobiledatadownload/lite/DownloaderImpl.java
new file mode 100644
index 0000000..1c0cb49
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/lite/DownloaderImpl.java
@@ -0,0 +1,441 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.lite;
+
+import android.content.Context;
+import androidx.annotation.VisibleForTesting;
+import androidx.core.app.NotificationCompat;
+import androidx.core.app.NotificationManagerCompat;
+import com.google.android.libraries.mobiledatadownload.DownloadException;
+import com.google.android.libraries.mobiledatadownload.DownloadException.DownloadResultCode;
+import com.google.android.libraries.mobiledatadownload.downloader.FileDownloader;
+import com.google.android.libraries.mobiledatadownload.foreground.NotificationUtil;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil;
+import com.google.common.base.Optional;
+import com.google.common.base.Preconditions;
+import com.google.common.base.Supplier;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.Executor;
+import org.checkerframework.checker.nullness.compatqual.NullableDecl;
+
+final class DownloaderImpl implements Downloader {
+  private static final String TAG = "DownloaderImp";
+
+  private final Context context;
+  private final Optional<Class<?>> foregroundDownloadServiceClassOptional;
+  // This executor will execute tasks sequentially.
+  private final Executor sequentialControlExecutor;
+  private final Optional<SingleFileDownloadProgressMonitor> downloadMonitorOptional;
+  private final Supplier<FileDownloader> fileDownloaderSupplier;
+
+  // Synchronization will be done through sequentialControlExecutor
+  @VisibleForTesting
+  final Map<String, ListenableFuture<Void>> keyToListenableFuture = new HashMap<>();
+
+  DownloaderImpl(
+      Context context,
+      Optional<Class<?>> foregroundDownloadServiceClassOptional,
+      Executor sequentialControlExecutor,
+      Optional<SingleFileDownloadProgressMonitor> downloadMonitorOptional,
+      Supplier<FileDownloader> fileDownloaderSupplier) {
+    this.context = context;
+    this.sequentialControlExecutor = sequentialControlExecutor;
+    this.foregroundDownloadServiceClassOptional = foregroundDownloadServiceClassOptional;
+    this.downloadMonitorOptional = downloadMonitorOptional;
+    this.fileDownloaderSupplier = fileDownloaderSupplier;
+  }
+
+  @Override
+  public ListenableFuture<Void> download(DownloadRequest downloadRequest) {
+    LogUtil.d("%s: download for Uri = %s", TAG, downloadRequest.destinationFileUri().toString());
+    return Futures.submitAsync(
+        () -> {
+          // if there is the same on-going request, return that one.
+          if (keyToListenableFuture.containsKey(downloadRequest.destinationFileUri().toString())) {
+            // uriToListenableFuture.get must return Non-null since we check the containsKey above.
+            // checkNotNull is to suppress false alarm about @Nullable result.
+            return Preconditions.checkNotNull(
+                keyToListenableFuture.get(downloadRequest.destinationFileUri().toString()));
+          }
+
+          // Register listener with monitor if present
+          if (downloadRequest.listenerOptional().isPresent()) {
+            if (downloadMonitorOptional.isPresent()) {
+              downloadMonitorOptional
+                  .get()
+                  .addDownloadListener(
+                      downloadRequest.destinationFileUri(),
+                      downloadRequest.listenerOptional().get());
+            } else {
+              LogUtil.w(
+                  "%s: download request included DownloadListener, but DownloadMonitor is not"
+                      + " present! DownloadListener will only be invoked for complete/failure.");
+            }
+          }
+
+          ListenableFuture<Void> downloadFuture = startDownload(downloadRequest);
+
+          Futures.addCallback(
+              downloadFuture,
+              new FutureCallback<Void>() {
+                @Override
+                public void onSuccess(Void result) {
+                  // Currently the MobStore monitor does not support onSuccess so we have to add
+                  // callback to the download future here.
+
+                  // Remove download listener and remove download future from map after listener
+                  // completes
+                  if (downloadRequest.listenerOptional().isPresent()) {
+                    Futures.addCallback(
+                        downloadRequest.listenerOptional().get().onComplete(),
+                        new FutureCallback<Void>() {
+                          @Override
+                          public void onSuccess(@NullableDecl Void result) {
+                            keyToListenableFuture.remove(
+                                downloadRequest.destinationFileUri().toString());
+                            if (downloadMonitorOptional.isPresent()) {
+                              downloadMonitorOptional
+                                  .get()
+                                  .removeDownloadListener(downloadRequest.destinationFileUri());
+                            }
+                          }
+
+                          @Override
+                          public void onFailure(Throwable t) {
+                            LogUtil.e(t, "%s: Failed to run client onComplete", TAG);
+                            keyToListenableFuture.remove(
+                                downloadRequest.destinationFileUri().toString());
+                            if (downloadMonitorOptional.isPresent()) {
+                              downloadMonitorOptional
+                                  .get()
+                                  .removeDownloadListener(downloadRequest.destinationFileUri());
+                            }
+                          }
+                        },
+                        sequentialControlExecutor);
+                  } else {
+                    // remove from future map immediately
+                    keyToListenableFuture.remove(downloadRequest.destinationFileUri().toString());
+                  }
+                }
+
+                @Override
+                public void onFailure(Throwable t) {
+                  LogUtil.e(t, "%s: Download Future failed", TAG);
+
+                  // Currently the MobStore monitor does not support onFailure so we have to add
+                  // callback to the download future here.
+                  if (downloadRequest.listenerOptional().isPresent()) {
+                    downloadRequest.listenerOptional().get().onFailure(t);
+                    if (downloadMonitorOptional.isPresent()) {
+                      downloadMonitorOptional
+                          .get()
+                          .removeDownloadListener(downloadRequest.destinationFileUri());
+                    }
+                  }
+                  keyToListenableFuture.remove(downloadRequest.destinationFileUri().toString());
+                }
+              },
+              MoreExecutors.directExecutor());
+
+          keyToListenableFuture.put(
+              downloadRequest.destinationFileUri().toString(), downloadFuture);
+          return downloadFuture;
+        },
+        sequentialControlExecutor);
+  }
+
+  private ListenableFuture<Void> startDownload(DownloadRequest downloadRequest) {
+    // Translate from MDDLite DownloadRequest to MDDDownloader DownloadRequest.
+    com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest
+        fileDownloaderRequest =
+            com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest.newBuilder()
+                .setFileUri(downloadRequest.destinationFileUri())
+                .setDownloadConstraints(downloadRequest.downloadConstraints())
+                .setUrlToDownload(downloadRequest.urlToDownload())
+                .setExtraHttpHeaders(downloadRequest.extraHttpHeaders())
+                .setTrafficTag(downloadRequest.trafficTag())
+                .build();
+    try {
+      return fileDownloaderSupplier.get().startDownloading(fileDownloaderRequest);
+    } catch (RuntimeException e) {
+      // Catch any unchecked exceptions that prevented the download from starting.
+      return Futures.immediateFailedFuture(
+          DownloadException.builder()
+              .setDownloadResultCode(DownloadResultCode.UNKNOWN_ERROR)
+              .setCause(e)
+              .build());
+    }
+  }
+
+  @Override
+  public ListenableFuture<Void> downloadWithForegroundService(DownloadRequest downloadRequest) {
+    LogUtil.d(
+        "%s: downloadWithForegroundService for Uri = %s",
+        TAG, downloadRequest.destinationFileUri().toString());
+    if (!downloadMonitorOptional.isPresent()) {
+      return Futures.immediateFailedFuture(
+          new IllegalStateException(
+              "downloadWithForegroundService: DownloadMonitor is not provided!"));
+    }
+    if (!foregroundDownloadServiceClassOptional.isPresent()) {
+      return Futures.immediateFailedFuture(
+          new IllegalStateException(
+              "downloadWithForegroundService: ForegroundDownloadService is not provided!"));
+    }
+    return Futures.submitAsync(
+        () -> {
+          // if there is the same on-going request, return that one.
+          if (keyToListenableFuture.containsKey(downloadRequest.destinationFileUri().toString())) {
+            // uriToListenableFuture.get must return Non-null since we check the containsKey above.
+            // checkNotNull is to suppress false alarm about @Nullable result.
+            return Preconditions.checkNotNull(
+                keyToListenableFuture.get(downloadRequest.destinationFileUri().toString()));
+          }
+
+          // It's OK to recreate the NotificationChannel since it can also be used to restore a
+          // deleted channel and to update an existing channel's name, description, group, and/or
+          // importance.
+          NotificationUtil.createNotificationChannel(context);
+
+          // Only start the foreground download service when there is the first download request.
+          if (keyToListenableFuture.isEmpty()) {
+            NotificationUtil.startForegroundDownloadService(
+                context,
+                foregroundDownloadServiceClassOptional.get(),
+                downloadRequest.destinationFileUri().toString());
+          }
+
+          DownloadListener downloadListenerWithNotification =
+              createDownloadListenerWithNotification(downloadRequest);
+
+          // The downloadMonitor will trigger the DownloadListener.
+          downloadMonitorOptional
+              .get()
+              .addDownloadListener(
+                  downloadRequest.destinationFileUri(), downloadListenerWithNotification);
+
+          ListenableFuture<Void> downloadFuture = startDownload(downloadRequest);
+
+          Futures.addCallback(
+              downloadFuture,
+              new FutureCallback<Void>() {
+                @Override
+                public void onSuccess(Void result) {
+                  // Currently the MobStore monitor does not support onSuccess so we have to add
+                  // callback to the download future here.
+
+                  Futures.addCallback(
+                      downloadListenerWithNotification.onComplete(),
+                      new FutureCallback<Void>() {
+                        @Override
+                        public void onSuccess(@NullableDecl Void result) {}
+
+                        @Override
+                        public void onFailure(Throwable t) {
+                          LogUtil.e(t, "%s: Failed to run client onComplete", TAG);
+                        }
+                      },
+                      sequentialControlExecutor);
+                }
+
+                @Override
+                public void onFailure(Throwable t) {
+                  // Currently the MobStore monitor does not support onFailure so we have to add
+                  // callback to the download future here.
+                  LogUtil.e(t, "%s: Download Future failed", TAG);
+                  downloadListenerWithNotification.onFailure(t);
+                }
+              },
+              MoreExecutors.directExecutor());
+
+          keyToListenableFuture.put(
+              downloadRequest.destinationFileUri().toString(), downloadFuture);
+          return downloadFuture;
+        },
+        sequentialControlExecutor);
+  }
+
+  // Assertion: foregroundDownloadService and downloadMonitor are present
+  private DownloadListener createDownloadListenerWithNotification(DownloadRequest downloadRequest) {
+    NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
+    NotificationCompat.Builder notification =
+        NotificationUtil.createNotificationBuilder(
+            context,
+            downloadRequest.fileSizeBytes(),
+            downloadRequest.notificationContentTitle(),
+            downloadRequest.notificationContentTextOptional().or(downloadRequest.urlToDownload()));
+
+    int notificationKey =
+        NotificationUtil.notificationKeyForKey(downloadRequest.destinationFileUri().toString());
+
+    // Attach the Cancel action to the notification.
+    NotificationUtil.createCancelAction(
+        context,
+        foregroundDownloadServiceClassOptional.get(),
+        downloadRequest.destinationFileUri().toString(),
+        notification,
+        notificationKey);
+    notificationManager.notify(notificationKey, notification.build());
+
+    return new DownloadListener() {
+      @Override
+      public void onProgress(long currentSize) {
+        sequentialControlExecutor.execute(
+            () -> {
+              // There can be a race condition, where onPausedForConnectivity can be called
+              // after onComplete or onFailure which removes the future and the notification.
+              if (keyToListenableFuture.containsKey(
+                  downloadRequest.destinationFileUri().toString())) {
+                notification
+                    .setCategory(NotificationCompat.CATEGORY_PROGRESS)
+                    .setSmallIcon(android.R.drawable.stat_sys_download)
+                    .setProgress(
+                        downloadRequest.fileSizeBytes(),
+                        (int) currentSize,
+                        /* indeterminate = */ downloadRequest.fileSizeBytes() <= 0);
+                notificationManager.notify(notificationKey, notification.build());
+              }
+              if (downloadRequest.listenerOptional().isPresent()) {
+                downloadRequest.listenerOptional().get().onProgress(currentSize);
+              }
+            });
+      }
+
+      @Override
+      public void onPausedForConnectivity() {
+        sequentialControlExecutor.execute(
+            () -> {
+              // There can be a race condition, where onPausedForConnectivity can be called
+              // after onComplete or onFailure which removes the future and the notification.
+              if (keyToListenableFuture.containsKey(
+                  downloadRequest.destinationFileUri().toString())) {
+                notification
+                    .setCategory(NotificationCompat.CATEGORY_STATUS)
+                    .setContentText(NotificationUtil.getDownloadPausedMessage(context))
+                    .setSmallIcon(android.R.drawable.stat_sys_download)
+                    .setOngoing(true)
+                    // hide progress bar.
+                    .setProgress(0, 0, false);
+                notificationManager.notify(notificationKey, notification.build());
+              }
+
+              if (downloadRequest.listenerOptional().isPresent()) {
+                downloadRequest.listenerOptional().get().onPausedForConnectivity();
+              }
+            });
+      }
+
+      @Override
+      public ListenableFuture<Void> onComplete() {
+        // We want to keep the Foreground Download Service alive until client's onComplete finishes.
+        ListenableFuture<Void> clientOnCompleteFuture =
+            downloadRequest.listenerOptional().isPresent()
+                ? downloadRequest.listenerOptional().get().onComplete()
+                : Futures.immediateVoidFuture();
+
+        // Logic to shutdown Foreground Download Service after the client's provided onComplete
+        // finished
+        clientOnCompleteFuture.addListener(
+            () -> {
+              // Clear the notification action.
+              notification.mActions.clear();
+
+              if (downloadRequest.showDownloadedNotification()) {
+                notification
+                    .setCategory(NotificationCompat.CATEGORY_STATUS)
+                    .setContentText(NotificationUtil.getDownloadSuccessMessage(context))
+                    .setOngoing(false)
+                    .setSmallIcon(android.R.drawable.stat_sys_download_done)
+                    // hide progress bar.
+                    .setProgress(0, 0, false);
+
+                notificationManager.notify(notificationKey, notification.build());
+              } else {
+                NotificationUtil.cancelNotificationForKey(
+                    context, downloadRequest.destinationFileUri().toString());
+              }
+
+              keyToListenableFuture.remove(downloadRequest.destinationFileUri().toString());
+              // If there is no other on-going foreground download, shutdown the
+              // ForegroundDownloadService
+              if (keyToListenableFuture.isEmpty()) {
+                NotificationUtil.stopForegroundDownloadService(
+                    context, foregroundDownloadServiceClassOptional.get());
+              }
+
+              downloadMonitorOptional
+                  .get()
+                  .removeDownloadListener(downloadRequest.destinationFileUri());
+            },
+            sequentialControlExecutor);
+        return clientOnCompleteFuture;
+      }
+
+      @Override
+      public void onFailure(Throwable t) {
+        sequentialControlExecutor.execute(
+            () -> {
+              // Clear the notification action.
+              notification.mActions.clear();
+
+              // Show download failed in notification.
+              notification
+                  .setCategory(NotificationCompat.CATEGORY_STATUS)
+                  .setContentText(NotificationUtil.getDownloadFailedMessage(context))
+                  .setOngoing(false)
+                  .setSmallIcon(android.R.drawable.stat_sys_warning)
+                  // hide progress bar.
+                  .setProgress(0, 0, false);
+
+              notificationManager.notify(notificationKey, notification.build());
+
+              keyToListenableFuture.remove(downloadRequest.destinationFileUri().toString());
+
+              // If there is no other on-going foreground download, shutdown the
+              // ForegroundDownloadService
+              if (keyToListenableFuture.isEmpty()) {
+                NotificationUtil.stopForegroundDownloadService(
+                    context, foregroundDownloadServiceClassOptional.get());
+              }
+
+              if (downloadRequest.listenerOptional().isPresent()) {
+                downloadRequest.listenerOptional().get().onFailure(t);
+              }
+              downloadMonitorOptional
+                  .get()
+                  .removeDownloadListener(downloadRequest.destinationFileUri());
+            });
+      }
+    };
+  }
+
+  @Override
+  public void cancelForegroundDownload(String destinationFileUri) {
+    LogUtil.d("%s: CancelForegroundDownload for Uri = %s", TAG, destinationFileUri);
+    sequentialControlExecutor.execute(
+        () -> {
+          if (keyToListenableFuture.containsKey(destinationFileUri)) {
+            keyToListenableFuture.get(destinationFileUri).cancel(true);
+          }
+        });
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/lite/SingleFileDownloadProgressMonitor.java b/java/com/google/android/libraries/mobiledatadownload/lite/SingleFileDownloadProgressMonitor.java
new file mode 100644
index 0000000..10e6c8c
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/lite/SingleFileDownloadProgressMonitor.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.lite;
+
+import android.net.Uri;
+
+/** Interface for Monitoring Single File Downloads for MDD Lite. */
+public interface SingleFileDownloadProgressMonitor {
+
+  /**
+   * Add a DownloadListener that client can use to receive download progress update. Currently we
+   * only support 1 listener per file uri. Calling addDownloadListener to add another listener would
+   * be no-op.
+   *
+   * @param uri The destination file uri that the DownloadListener will receive download progress
+   *     update.
+   * @param downloadListener the DownloadListener to add.
+   */
+  public void addDownloadListener(Uri uri, DownloadListener downloadListener);
+
+  /**
+   * Remove a DownloadListener.
+   *
+   * @param uri The uri that the DownloadListener receive download progress update.
+   */
+  public void removeDownloadListener(Uri uri);
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/lite/annotations/BUILD b/java/com/google/android/libraries/mobiledatadownload/lite/annotations/BUILD
new file mode 100644
index 0000000..fd00b3b
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/lite/annotations/BUILD
@@ -0,0 +1,27 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_library")
+
+package(
+    default_visibility = [
+        "//visibility:public",
+    ],
+    licenses = ["notice"],
+)
+
+android_library(
+    name = "annotations",
+    srcs = glob(["*.java"]),
+    deps = ["@javax_inject"],
+)
diff --git a/java/com/google/android/libraries/mobiledatadownload/lite/annotations/MddLite.java b/java/com/google/android/libraries/mobiledatadownload/lite/annotations/MddLite.java
new file mode 100644
index 0000000..e5c586a
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/lite/annotations/MddLite.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.lite.annotations;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Target;
+import javax.inject.Qualifier;
+
+/** Qualifier for MDDLite. */
+@Target({ElementType.PARAMETER, ElementType.METHOD, ElementType.FIELD})
+@Qualifier
+public @interface MddLite {}
diff --git a/java/com/google/android/libraries/mobiledatadownload/lite/annotations/MddLiteControlExecutor.java b/java/com/google/android/libraries/mobiledatadownload/lite/annotations/MddLiteControlExecutor.java
new file mode 100644
index 0000000..5cffa38
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/lite/annotations/MddLiteControlExecutor.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.lite.annotations;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Target;
+import javax.inject.Qualifier;
+
+/** An executor on which MDDLite runs control execution flow which will touch I/O. */
+@Target({ElementType.PARAMETER, ElementType.METHOD, ElementType.FIELD})
+@Qualifier
+public @interface MddLiteControlExecutor {}
diff --git a/java/com/google/android/libraries/mobiledatadownload/lite/annotations/MddLiteDownloadExecutor.java b/java/com/google/android/libraries/mobiledatadownload/lite/annotations/MddLiteDownloadExecutor.java
new file mode 100644
index 0000000..653aea4
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/lite/annotations/MddLiteDownloadExecutor.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.lite.annotations;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Target;
+import javax.inject.Qualifier;
+
+/** An executor on which MDDLite runs downloading tasks which will touch I/O. */
+@Target({ElementType.PARAMETER, ElementType.METHOD, ElementType.FIELD})
+@Qualifier
+public @interface MddLiteDownloadExecutor {}
diff --git a/java/com/google/android/libraries/mobiledatadownload/logger/BUILD b/java/com/google/android/libraries/mobiledatadownload/logger/BUILD
new file mode 100644
index 0000000..d8a3560
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/logger/BUILD
@@ -0,0 +1,31 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_library")
+
+package(
+    default_visibility = [
+        "//visibility:public",
+    ],
+    licenses = ["notice"],
+)
+
+android_library(
+    name = "FileGroupPopulatorLogger",
+    srcs = ["FileGroupPopulatorLogger.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload:Flags",
+        "//java/com/google/android/libraries/mobiledatadownload:Logger",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LogUtil",
+    ],
+)
diff --git a/java/com/google/android/libraries/mobiledatadownload/logger/FileGroupPopulatorLogger.java b/java/com/google/android/libraries/mobiledatadownload/logger/FileGroupPopulatorLogger.java
new file mode 100644
index 0000000..435f3b3
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/logger/FileGroupPopulatorLogger.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.logger;
+
+import com.google.android.libraries.mobiledatadownload.Flags;
+import com.google.android.libraries.mobiledatadownload.Logger;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil;
+
+/** The event logger for {@code FileGroupPopulator}'s. */
+public final class FileGroupPopulatorLogger {
+
+  private final Logger logger;
+  private final Flags flags;
+
+  public FileGroupPopulatorLogger(Logger logger, Flags flags) {
+    this.logger = logger;
+    this.flags = flags;
+  }
+
+  /** Logs the refresh result of {@code ManifestFileGroupPopulator}. */
+  public void logManifestFileGroupPopulatorRefreshResult(
+      int code, String manifestId, String ownerPackageName, String manifestFileUrl) {
+    int sampleInterval = flags.mddDefaultSampleInterval();
+    if (!LogUtil.shouldSampleInterval(sampleInterval)) {
+      return;
+    }
+    Void logData = null;
+  }
+
+  /** Logs the refresh result of {@code GellerFileGroupPopulator}. */
+  public void logGddFileGroupPopulatorRefreshResult(
+      int code, String configurationId, String ownerPackageName, String corpus) {
+    int sampleInterval = flags.mddDefaultSampleInterval();
+    if (!LogUtil.shouldSampleInterval(sampleInterval)) {
+      return;
+    }
+    Void logData = null;
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/monitor/BUILD b/java/com/google/android/libraries/mobiledatadownload/monitor/BUILD
new file mode 100644
index 0000000..caeaa3c
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/monitor/BUILD
@@ -0,0 +1,55 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_library")
+
+package(
+    default_visibility = [
+        "//visibility:public",
+    ],
+    licenses = ["notice"],
+)
+
+android_library(
+    name = "NetworkUsageMonitor",
+    srcs = ["NetworkUsageMonitor.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload:TimeSource",
+        "//java/com/google/android/libraries/mobiledatadownload/file/monitors",
+        "//java/com/google/android/libraries/mobiledatadownload/file/spi",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LogUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LoggingStateStore",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "//java/com/google/android/libraries/mobiledatadownload/tracing",
+        "@androidx_annotation_annotation",
+        "@com_google_code_findbugs_jsr305",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "DownloadProgressMonitor",
+    srcs = ["DownloadProgressMonitor.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload:DownloadListener",
+        "//java/com/google/android/libraries/mobiledatadownload:TimeSource",
+        "//java/com/google/android/libraries/mobiledatadownload/file/monitors",
+        "//java/com/google/android/libraries/mobiledatadownload/file/spi",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LogUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/lite:DownloadListener",
+        "//java/com/google/android/libraries/mobiledatadownload/lite:DownloadProgressMonitor",
+        "@androidx_annotation_annotation",
+        "@com_google_code_findbugs_jsr305",
+        "@com_google_guava_guava",
+    ],
+)
diff --git a/java/com/google/android/libraries/mobiledatadownload/monitor/DownloadProgressMonitor.java b/java/com/google/android/libraries/mobiledatadownload/monitor/DownloadProgressMonitor.java
new file mode 100644
index 0000000..5dcbf6a
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/monitor/DownloadProgressMonitor.java
@@ -0,0 +1,257 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.monitor;
+
+import android.net.Uri;
+import androidx.annotation.VisibleForTesting;
+import com.google.android.libraries.mobiledatadownload.DownloadListener;
+import com.google.android.libraries.mobiledatadownload.TimeSource;
+import com.google.android.libraries.mobiledatadownload.file.monitors.ByteCountingOutputMonitor;
+import com.google.android.libraries.mobiledatadownload.file.spi.Monitor;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil;
+import com.google.android.libraries.mobiledatadownload.lite.SingleFileDownloadProgressMonitor;
+import com.google.common.util.concurrent.MoreExecutors;
+import java.util.HashMap;
+import java.util.concurrent.Executor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.GuardedBy;
+import javax.annotation.concurrent.ThreadSafe;
+
+/**
+ * A Download Progress Monitor to support {@link DownloadListener}.
+ *
+ * <p>Before monitoring an Uri, one needs to call monitorUri to record the file group that Uri
+ * belongs to at the downloading time.
+ *
+ * <p>Currently we only support 1 DownloadListener per File Group.
+ */
+@ThreadSafe
+public class DownloadProgressMonitor implements Monitor, SingleFileDownloadProgressMonitor {
+
+  private static final String TAG = "DownloadProgressMonitor";
+
+  private final TimeSource timeSource;
+  private final Executor sequentialControlExecutor;
+  private final com.google.android.libraries.mobiledatadownload.lite.DownloadProgressMonitor
+      liteDownloadProgressMonitor;
+
+  public DownloadProgressMonitor(TimeSource timeSource, Executor controlExecutor) {
+    this.timeSource = timeSource;
+
+    // We want onProgress to be executed in order otherwise clients will observe out of order
+    // updates (bigger current size update appears before smaller current size update).
+    // We use Sequential Executor to ensure the onProgress will be processed sequentially.
+    this.sequentialControlExecutor = MoreExecutors.newSequentialExecutor(controlExecutor);
+
+    // Construct internal instance of MDD Lite's DownloadProgressMonitor. methods of
+    // SingleFileDownloadProgressMonitor will delegate to this instance.
+    this.liteDownloadProgressMonitor =
+        com.google.android.libraries.mobiledatadownload.lite.DownloadProgressMonitor.create(
+            this.timeSource, controlExecutor);
+  }
+
+  // We will only broadcast on progress notification at most once in this time frame.
+  // Currently MobStore Monitor notify every 8KB of downloaded bytes. This may be too chatty on
+  // fast network.
+  // 1 second was chosen arbitrarily.
+  @VisibleForTesting static final long LOG_FREQUENCY = 1000L;
+
+  // MobStore Monitor works at file level. We want to monitor at FileGroup level. This map will
+  // help to map from a fileUri to a file group.
+  @GuardedBy("DownloadProgressMonitor.class")
+  private final HashMap<Uri, String> uriToFileGroup = new HashMap<>();
+
+  @GuardedBy("DownloadProgressMonitor.class")
+  private final HashMap<String, ByteCountingOutputMonitor> fileGroupToByteCountingOutputMonitor =
+      new HashMap<>();
+
+  @Override
+  @Nullable
+  public Monitor.InputMonitor monitorRead(Uri uri) {
+    return null;
+  }
+
+  @Override
+  @Nullable
+  public Monitor.OutputMonitor monitorWrite(Uri uri) {
+    synchronized (DownloadProgressMonitor.class) {
+      String groupName = uriToFileGroup.get(uri);
+      if (groupName == null) {
+        // MobStore will call all monitors for all files it handles.
+        // In this case, we receive a call from MobStore for an Uri that we did not register for.
+        //
+        // This may be for a single file download, so delegate to liteDownloadProgressMonitor. This
+        // will check its internal map and return null if not found.
+        return liteDownloadProgressMonitor.monitorWrite(uri);
+      }
+
+      if (fileGroupToByteCountingOutputMonitor.get(groupName) == null) {
+        // This is a real error. We register for this Uri but does ot have a counter for it.
+        LogUtil.e("%s: Can't find file group for uri: %s", TAG, uri);
+        return null;
+      }
+      return fileGroupToByteCountingOutputMonitor.get(groupName);
+    }
+  }
+
+  @Override
+  @Nullable
+  public Monitor.OutputMonitor monitorAppend(Uri uri) {
+    return monitorWrite(uri);
+  }
+
+  public void pausedForConnectivity() {
+    synchronized (DownloadProgressMonitor.class) {
+      for (ByteCountingOutputMonitor byteCountingOutputMonitor :
+          fileGroupToByteCountingOutputMonitor.values()) {
+        DownloadedBytesCounter counter =
+            (DownloadedBytesCounter) byteCountingOutputMonitor.getCounter();
+        counter.pausedForConnectivity();
+      }
+
+      // Delegate to liteDownloadProgressMonitor as well so single file downloads can be updated
+      liteDownloadProgressMonitor.pausedForConnectivity();
+    }
+  }
+
+  /**
+   * Add a File Group DownloadListener that client can use to receive download progress update.
+   *
+   * <p>Currently we only support 1 listener per file group. Calling addDownloadListener to add
+   * another listener would be no-op.
+   *
+   * @param groupName The groupName that the DownloadListener will receive download progress update.
+   * @param downloadListener the DownloadListener to add.
+   */
+  public void addDownloadListener(String groupName, DownloadListener downloadListener) {
+    synchronized (DownloadProgressMonitor.class) {
+      if (!fileGroupToByteCountingOutputMonitor.containsKey(groupName)) {
+        fileGroupToByteCountingOutputMonitor.put(
+            groupName,
+            new ByteCountingOutputMonitor(
+                new DownloadedBytesCounter(groupName, downloadListener),
+                timeSource::currentTimeMillis,
+                LOG_FREQUENCY,
+                TimeUnit.MILLISECONDS));
+      }
+    }
+  }
+
+  /**
+   * Add a Single File DownloadListener.
+   *
+   * <p>This listener allows clients to receive on progress updates for single file downloads.
+   *
+   * @param uri the uri for which the DownloadListener should receive updates
+   * @param downloadListener the MDD Lite DownloadListener to add
+   */
+  @Override
+  public void addDownloadListener(
+      Uri uri,
+      com.google.android.libraries.mobiledatadownload.lite.DownloadListener downloadListener) {
+    liteDownloadProgressMonitor.addDownloadListener(uri, downloadListener);
+  }
+
+  /**
+   * Remove a File Group DownloadListener.
+   *
+   * @param groupName The groupName that the DownloadListener receive download progress update.
+   */
+  public void removeDownloadListener(String groupName) {
+    synchronized (DownloadProgressMonitor.class) {
+      fileGroupToByteCountingOutputMonitor.remove(groupName);
+    }
+  }
+
+  /**
+   * Remove a Single File DownloadListener.
+   *
+   * @param uri the uri which should be cleared of any registered DownloadListener
+   */
+  @Override
+  public void removeDownloadListener(Uri uri) {
+    liteDownloadProgressMonitor.removeDownloadListener(uri);
+  }
+
+  /**
+   * Record that the Uri belong to the FileGroup represented by groupName. We need to record this at
+   * downloading time since the files in FileGroup could change due to config change. monitorUri
+   * should only be called for files that have not been downloaded.
+   *
+   * @param uri the FileUri to be monitored.
+   * @param groupName the group name to be monitored.
+   */
+  public void monitorUri(Uri uri, String groupName) {
+    synchronized (DownloadProgressMonitor.class) {
+      uriToFileGroup.put(uri, groupName);
+    }
+  }
+
+  /**
+   * Add the current size of a downloaded or partially downloaded file to a file group counter.
+   *
+   * @param groupName the group name to add the current size.
+   * @param currentSize the current size of the file.
+   */
+  public void notifyCurrentFileSize(String groupName, long currentSize) {
+    synchronized (DownloadProgressMonitor.class) {
+      // Update the counter with the current size.
+      if (fileGroupToByteCountingOutputMonitor.containsKey(groupName)) {
+        fileGroupToByteCountingOutputMonitor
+            .get(groupName)
+            .getCounter()
+            .bufferCounter((int) currentSize);
+      }
+    }
+  }
+
+  // A counter for bytes downloaded.
+  private final class DownloadedBytesCounter implements ByteCountingOutputMonitor.Counter {
+    private final String groupName;
+    private final DownloadListener downloadListener;
+
+    private final AtomicLong byteCounter = new AtomicLong();
+
+    DownloadedBytesCounter(String groupName, DownloadListener downloadListener) {
+      this.groupName = groupName;
+      this.downloadListener = downloadListener;
+    }
+
+    @Override
+    public void bufferCounter(int len) {
+      byteCounter.getAndAdd(len);
+      LogUtil.v(
+          "%s: Received data for groupName = %s, len = %d, Counter = %d",
+          TAG, groupName, len, byteCounter.get());
+    }
+
+    @Override
+    public void flushCounter() {
+      // Check if the DownloadListener is still being used before calling its onProgress.
+      synchronized (DownloadProgressMonitor.class) {
+        if (fileGroupToByteCountingOutputMonitor.containsKey(groupName)) {
+          sequentialControlExecutor.execute(() -> downloadListener.onProgress(byteCounter.get()));
+        }
+      }
+    }
+
+    public void pausedForConnectivity() {
+      downloadListener.pausedForConnectivity();
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/monitor/NetworkUsageMonitor.java b/java/com/google/android/libraries/mobiledatadownload/monitor/NetworkUsageMonitor.java
new file mode 100644
index 0000000..413a2d1
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/monitor/NetworkUsageMonitor.java
@@ -0,0 +1,256 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.monitor;
+
+import static com.google.android.libraries.mobiledatadownload.tracing.TracePropagation.propagateFutureCallback;
+import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import android.content.Context;
+import android.net.ConnectivityManager;
+import android.net.NetworkInfo;
+import android.net.Uri;
+import android.os.Build;
+import androidx.annotation.GuardedBy;
+import androidx.annotation.VisibleForTesting;
+import com.google.android.libraries.mobiledatadownload.TimeSource;
+import com.google.android.libraries.mobiledatadownload.file.monitors.ByteCountingOutputMonitor;
+import com.google.android.libraries.mobiledatadownload.file.spi.Monitor;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LoggingStateStore;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.mobiledatadownload.internal.MetadataProto.FileGroupLoggingState;
+import com.google.mobiledatadownload.internal.MetadataProto.GroupKey;
+import java.util.HashMap;
+import java.util.concurrent.atomic.AtomicLong;
+import javax.annotation.Nullable;
+
+/**
+ * A network usage monitor that counts bytes downloaded for each FileGroup. Before monitoring an
+ * Uri, one needs to call monitorUri to record the file group that Uri belongs to at the downloading
+ * time. Failing to do so will result in no network usage being counted.
+ */
+public class NetworkUsageMonitor implements Monitor {
+  private static final String TAG = "NetworkUsageMonitor";
+
+  // We will only flush counters to SharedPreference at most once in this time frame.
+  // 10 seconds were chosen arbitrarily.
+  @VisibleForTesting static final long LOG_FREQUENCY_SECONDS = 10L;
+
+  private final TimeSource timeSource;
+  private final Context context;
+
+  private final Object lock = new Object();
+
+  // Key is FileGroupLoggingState with GroupKey, build id, version number populated.
+  @GuardedBy("lock")
+  private final HashMap<FileGroupLoggingState, ByteCountingOutputMonitor>
+      fileGroupLoggingStateToOutputMonitor = new HashMap<>();
+
+  @GuardedBy("lock")
+  private final HashMap<Uri, ByteCountingOutputMonitor> uriToOutputMonitor = new HashMap<>();
+
+  public NetworkUsageMonitor(Context context, TimeSource timeSource) {
+    this.context = context;
+    this.timeSource = timeSource;
+  }
+
+  @Override
+  @Nullable
+  public Monitor.InputMonitor monitorRead(Uri uri) {
+    return null;
+  }
+
+  @Override
+  @Nullable
+  public Monitor.OutputMonitor monitorWrite(Uri uri) {
+    synchronized (lock) {
+      return uriToOutputMonitor.get(uri);
+    }
+  }
+
+  @Override
+  @Nullable
+  public Monitor.OutputMonitor monitorAppend(Uri uri) {
+    return monitorWrite(uri);
+  }
+
+  /**
+   * Record that the Uri belong to the FileGroup represented by ownerPackage and groupName. We need
+   * to record this at downloading time since the files in FileGroup could change due to config
+   * change.
+   *
+   * @param uri The Uri of the data file.
+   * @param groupKey The groupKey part of the file group.
+   * @param buildId The build id of the file group.
+   * @param versionNumber The version number of the file group.
+   * @param loggingStateStore The storage for the network usage logs
+   */
+  public void monitorUri(
+      Uri uri,
+      GroupKey groupKey,
+      long buildId,
+      int versionNumber,
+      LoggingStateStore loggingStateStore) {
+    FileGroupLoggingState fileGroupLoggingStateKey =
+        FileGroupLoggingState.newBuilder()
+            .setGroupKey(groupKey)
+            .setBuildId(buildId)
+            .setFileGroupVersionNumber(versionNumber)
+            .build();
+
+    // If we haven't seen this file group, create a output monitor for it and register it to this
+    // file group key.
+    synchronized (lock) {
+      if (!fileGroupLoggingStateToOutputMonitor.containsKey(fileGroupLoggingStateKey)) {
+        fileGroupLoggingStateToOutputMonitor.put(
+            fileGroupLoggingStateKey,
+            new ByteCountingOutputMonitor(
+                new DownloadedBytesCounter(context, loggingStateStore, fileGroupLoggingStateKey),
+                timeSource::currentTimeMillis,
+                LOG_FREQUENCY_SECONDS,
+                SECONDS));
+      }
+
+      // Register the mapping from this uri to the output monitor we created.
+      // NOTE: It's possible the URI is associated with another monitor here (e.g. if a
+      // uri is shared by file groups), but we don't have a way to dedupe that at the moment, so we
+      // just overwrite it.
+      uriToOutputMonitor.put(
+          uri, fileGroupLoggingStateToOutputMonitor.get(fileGroupLoggingStateKey));
+    }
+  }
+
+  // A counter for bytes downloaded on wifi and cellular.
+  // It will keep in-memory counters to reduce the write to SharedPreference.
+  // When updating the in-memory counters, it will only save and reset them if the time since the
+  // last save is at least LOG_FREQUENCY.
+  private static final class DownloadedBytesCounter implements ByteCountingOutputMonitor.Counter {
+    private final Context context;
+    private final LoggingStateStore loggingStateStore;
+    private final FileGroupLoggingState fileGroupLoggingStateKey;
+
+    // In-memory counters before saving to SharedPreference.
+    private final AtomicLong wifiCounter = new AtomicLong();
+    private final AtomicLong cellularCounter = new AtomicLong();
+
+    DownloadedBytesCounter(
+        Context context,
+        LoggingStateStore loggingStateStore,
+        FileGroupLoggingState fileGroupLoggingStateKey) {
+      this.context = context;
+      this.loggingStateStore = loggingStateStore;
+      this.fileGroupLoggingStateKey = fileGroupLoggingStateKey;
+    }
+
+    @Override
+    public void bufferCounter(int len) {
+
+      boolean isCellular = isCellular(context);
+
+      if (isCellular) {
+        cellularCounter.getAndAdd(len);
+      } else {
+        wifiCounter.getAndAdd(len);
+      }
+
+      LogUtil.v(
+          "%s: Received data (%s) for fileGroup = %s, len = %d, wifiCounter = %d,"
+              + " cellularCounter = %d",
+          TAG,
+          isCellular ? "cellular" : "wifi",
+          fileGroupLoggingStateKey.getGroupKey().getGroupName(),
+          len,
+          wifiCounter.get(),
+          cellularCounter.get());
+    }
+
+    @Override
+    public void flushCounter() {
+      ListenableFuture<Void> incrementDataUsage =
+          loggingStateStore.incrementDataUsage(
+              fileGroupLoggingStateKey.toBuilder()
+                  .setCellularUsage(cellularCounter.getAndSet(0))
+                  .setWifiUsage(wifiCounter.getAndSet(0))
+                  .build());
+
+      Futures.addCallback(
+          incrementDataUsage,
+          propagateFutureCallback(
+              new FutureCallback<Void>() {
+                @Override
+                public void onSuccess(Void unused) {
+                  LogUtil.d(
+                      "%s: Successfully incremented LoggingStateStore network usage for %s",
+                      TAG, fileGroupLoggingStateKey.getGroupKey().getGroupName());
+                }
+
+                @Override
+                public void onFailure(Throwable t) {
+                  LogUtil.e(
+                      t,
+                      "%s: Unable to increment LoggingStateStore network usage for %s",
+                      TAG,
+                      fileGroupLoggingStateKey.getGroupKey().getGroupName());
+                }
+              }),
+          directExecutor());
+    }
+  }
+
+  @Nullable
+  public static ConnectivityManager getConnectivityManager(Context context) {
+    try {
+      return (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
+    } catch (SecurityException e) {
+      LogUtil.e("%s: Couldn't retrieve ConnectivityManager.", TAG);
+    }
+    return null;
+  }
+
+  @Nullable
+  public static NetworkInfo getActiveNetworkInfo(Context context) {
+    ConnectivityManager cm = getConnectivityManager(context);
+    return (cm == null) ? null : cm.getActiveNetworkInfo();
+  }
+
+  /**
+   * Returns true if the current network connectivity type is cellular. If the network connectivity
+   * type is not cellular or if there is an error in getting NetworkInfo return false.
+   *
+   * <p>The logic here is similar to OffroadDownloader.
+   */
+  @VisibleForTesting
+  public static boolean isCellular(Context context) {
+    NetworkInfo networkInfo = getActiveNetworkInfo(context);
+    if (networkInfo == null) {
+      LogUtil.e("%s: Fail to get network type ", TAG);
+      // We return false when we fail to get NetworkInfo.
+      return false;
+    }
+
+    if ((networkInfo.getType() == ConnectivityManager.TYPE_WIFI
+        || networkInfo.getType() == ConnectivityManager.TYPE_ETHERNET
+        || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP
+            && networkInfo.getType() == ConnectivityManager.TYPE_VPN))) {
+      return false;
+    } else {
+      return true;
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/populator/BUILD b/java/com/google/android/libraries/mobiledatadownload/populator/BUILD
new file mode 100644
index 0000000..42f7e73
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/populator/BUILD
@@ -0,0 +1,189 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_library")
+
+package(
+    default_visibility = [
+        "//visibility:public",
+    ],
+    licenses = ["notice"],
+)
+
+android_library(
+    name = "DataFileGroupOverrider",
+    srcs = ["DataFileGroupOverrider.java"],
+    deps = [
+        "//proto:download_config_java_proto_lite",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "SingleDataFileGroupPopulator",
+    srcs = ["SingleDataFileGroupPopulator.java"],
+    deps = [
+        ":DataFileGroupOverrider",
+        "//java/com/google/android/libraries/mobiledatadownload",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LogUtil",
+        "//proto:download_config_java_proto_lite",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "MigrationProxyPopulator",
+    srcs = ["MigrationProxyPopulator.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "LocationProvider",
+    srcs = [
+        "LocationProvider.java",
+        "LocationProviderImpl.java",
+    ],
+    deps = [
+        ":LocationProviderOverride",
+        "//java/com/google/android/libraries/mobiledatadownload/annotations",
+        "@androidx_appcompat_appcompat",
+        "@com_google_dagger",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "LocationProviderOverride",
+    srcs = [
+        "LocationProviderOverride.java",
+    ],
+    deps = [
+        "@javax_inject",
+    ],
+)
+
+android_library(
+    name = "ManifestConfigHelper",
+    srcs = ["ManifestConfigHelper.java"],
+    deps = [
+        ":ManifestConfigOverrider",
+        "//java/com/google/android/libraries/mobiledatadownload",
+        "//java/com/google/android/libraries/mobiledatadownload/tracing:concurrent",
+        "//proto:download_config_java_proto_lite",
+        "@androidx_annotation_annotation",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "ManifestConfigOverrider",
+    srcs = ["ManifestConfigOverrider.java"],
+    deps = [
+        "//proto:download_config_java_proto_lite",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "ManifestConfigFlagPopulator",
+    srcs = ["ManifestConfigFlagPopulator.java"],
+    deps = [
+        ":ManifestConfigHelper",
+        ":ManifestConfigOverrider",
+        "//java/com/google/android/libraries/mobiledatadownload",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LogUtil",
+        "//proto:download_config_java_proto_lite",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "ManifestFileGroupPopulator",
+    srcs = ["ManifestFileGroupPopulator.java"],
+    deps = [
+        ":ManifestConfigHelper",
+        ":ManifestConfigOverrider",
+        ":ManifestFileMetadataStore",
+        "//java/com/google/android/libraries/mobiledatadownload",
+        "//java/com/google/android/libraries/mobiledatadownload:AggregateException",
+        "//java/com/google/android/libraries/mobiledatadownload:DownloadException",
+        "//java/com/google/android/libraries/mobiledatadownload:Flags",
+        "//java/com/google/android/libraries/mobiledatadownload:Logger",
+        "//java/com/google/android/libraries/mobiledatadownload/downloader:FileDownloader",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LogUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:DirectoryUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/logger:FileGroupPopulatorLogger",
+        "//java/com/google/android/libraries/mobiledatadownload/populator/proto:metadata_java_proto_lite",
+        "//java/com/google/android/libraries/mobiledatadownload/tracing",
+        "//java/com/google/android/libraries/mobiledatadownload/tracing:concurrent",
+        "//proto:download_config_java_proto_lite",
+        "@androidx_annotation_annotation",
+        "@com_google_code_findbugs_jsr305",
+        "@com_google_guava_guava",
+        "@javax_inject",
+    ],
+)
+
+android_library(
+    name = "ManifestFileMetadataStore",
+    srcs = [
+        "ManifestFileMetadataStore.java",
+    ],
+    # DO NOT ADD VISIBILITY: this isn't an open interface for clients to implement.
+    visibility = ["//visibility:private"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/populator/proto:metadata_java_proto_lite",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "SharedPreferencesManifestFileMetadata",
+    srcs = [
+        "SharedPreferencesManifestFileMetadata.java",
+    ],
+    deps = [
+        ":ManifestFileMetadataStore",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:SharedPreferencesUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/populator/proto:metadata_java_proto_lite",
+        "//java/com/google/android/libraries/mobiledatadownload/tracing:concurrent",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "LocaleOverrider",
+    srcs = ["LocaleOverrider.java"],
+    deps = [
+        ":ManifestConfigOverrider",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LogUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/tracing:concurrent",
+        "//proto:download_config_java_proto_lite",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "MigrationProxyLocaleOverrider",
+    srcs = ["MigrationProxyLocaleOverrider.java"],
+    deps = [
+        ":LocaleOverrider",
+        ":ManifestConfigOverrider",
+        "//proto:download_config_java_proto_lite",
+        "@com_google_guava_guava",
+    ],
+)
diff --git a/java/com/google/android/libraries/mobiledatadownload/populator/DataFileGroupOverrider.java b/java/com/google/android/libraries/mobiledatadownload/populator/DataFileGroupOverrider.java
new file mode 100644
index 0000000..9a548ef
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/populator/DataFileGroupOverrider.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.populator;
+
+import com.google.common.base.Optional;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.mobiledatadownload.DownloadConfigProto.DataFileGroup;
+
+/**
+ * Client provided overrider which optionally overrides a {@link DataFileGroup}.
+ *
+ * <p>This could be used to alter the input dataFileGroup or drop the dataFileGroup altogether.
+ * Dropping here could be used for on device targeting.
+ */
+public interface DataFileGroupOverrider {
+
+  /** Overrides the input {@link DataFileGroup}. */
+  ListenableFuture<Optional<DataFileGroup>> override(DataFileGroup dataFileGroup);
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/populator/LocaleOverrider.java b/java/com/google/android/libraries/mobiledatadownload/populator/LocaleOverrider.java
new file mode 100644
index 0000000..1985caa
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/populator/LocaleOverrider.java
@@ -0,0 +1,211 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.populator;
+
+import static java.util.stream.Collectors.groupingBy;
+import static java.util.stream.Collectors.mapping;
+import static java.util.stream.Collectors.toCollection;
+
+import android.annotation.TargetApi;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil;
+import com.google.android.libraries.mobiledatadownload.tracing.PropagatedFutures;
+import com.google.common.base.Optional;
+import com.google.common.base.Preconditions;
+import com.google.common.base.Supplier;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.mobiledatadownload.DownloadConfigProto.DataFileGroup;
+import com.google.mobiledatadownload.DownloadConfigProto.ManifestConfig;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.Executor;
+import java.util.function.BiFunction;
+
+/**
+ * An Overrider that finds matching {@link DataFileGroup} within {@link ManifestConfig} based on
+ * supplied locale.
+ *
+ * <p>We will first group the {@link DataFileGroup} by {@code group_name}, then apply {@code
+ * matchStrategy} to get 0 or 1 {@link DataFileGroup} for each of the group, then combine them and
+ * returns a list.
+ *
+ * <p>{@code localeSupplier} supplies the locale to get matches
+ *
+ * <p>NOTE: By default we use {@link LOCALE_MATCHER_STRATEGY}, which could fallback to a different
+ * locale to supplied one.
+ *
+ * <p>WARNING: It's UNDEFINED behavior if more than one {locale, group_name} pair exists.
+ */
+@TargetApi(24)
+@SuppressWarnings("AndroidJdkLibsChecker")
+public final class LocaleOverrider implements ManifestConfigOverrider {
+
+  private static final String TAG = "LocaleOverrider";
+
+  /** Builder for {@link FilteringPopulator}. */
+  public static final class Builder {
+
+    private Supplier<ListenableFuture<Locale>> localeSupplier;
+    private BiFunction<Locale, Set<Locale>, Optional<Locale>> matchStrategy;
+    private Executor lightweightExecutor;
+
+    /** only one of setLocaleSupplier or setLocaleFutureSupplier is required */
+    public Builder setLocaleSupplier(Supplier<Locale> localeSupplier) {
+      this.localeSupplier = () -> Futures.immediateFuture(localeSupplier.get());
+      this.lightweightExecutor =
+          MoreExecutors.directExecutor(); // use directExecutor if locale is provided sync.
+      return this;
+    }
+
+    public Builder setLocaleFutureSupplier(
+        Supplier<ListenableFuture<Locale>> localeSupplier, Executor lightweightExecutor) {
+      this.localeSupplier = localeSupplier;
+      this.lightweightExecutor = lightweightExecutor;
+      return this;
+    }
+
+    /**
+     * A function that decides a match based on provided Locale and a set of available Locales in
+     * the config. The set of Locale should be related to ONE {@code group_name} of {@link
+     * DataFilegroup}.
+     */
+    public Builder setMatchStrategy(
+        BiFunction<Locale, Set<Locale>, Optional<Locale>> matchStrategy) {
+      this.matchStrategy = matchStrategy;
+      return this;
+    }
+
+    public LocaleOverrider build() {
+      Preconditions.checkState(
+          localeSupplier != null,
+          "Must call setLocaleSupplier() or setLocaleFutureSupplier() before build().");
+      if (matchStrategy == null) {
+        LogUtil.d("%s: Applying LANG_FALLBACK_STRATEGY", TAG);
+        matchStrategy = LANG_FALLBACK_STRATEGY;
+      }
+
+      return new LocaleOverrider(this);
+    }
+  }
+
+  private final Supplier<ListenableFuture<Locale>> localeSupplier;
+  private final BiFunction<Locale, Set<Locale>, Optional<Locale>> matchStrategy;
+  private final Executor lightweightExecutor;
+
+  /** Returns a Builder for {@link LocaleOverrider}. */
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  private LocaleOverrider(Builder builder) {
+    this.localeSupplier = builder.localeSupplier;
+    this.matchStrategy = builder.matchStrategy;
+    this.lightweightExecutor = builder.lightweightExecutor;
+  }
+
+  /**
+   * Returns the {@link Locale} if it's present in {@link Set<Locale>}, or {@link Optional#absent}
+   */
+  public static final BiFunction<Locale, Set<Locale>, Optional<Locale>> EQUAL_STRATEGY =
+      (locale, localeSet) -> localeSet.contains(locale) ? Optional.of(locale) : Optional.absent();
+
+  /**
+   * Returns an exact matching {@link Locale}, or fallback to only matching lang when not available.
+   */
+  public static final BiFunction<Locale, Set<Locale>, Optional<Locale>> LANG_FALLBACK_STRATEGY =
+      (locale, localeSet) -> {
+        Optional<Locale> exactMatch = EQUAL_STRATEGY.apply(locale, localeSet);
+        if (exactMatch.isPresent()) {
+          return exactMatch;
+        } else {
+          // Match on lang part.
+          return EQUAL_STRATEGY.apply(new Locale(locale.getLanguage()), localeSet);
+        }
+      };
+
+  @Override
+  public ListenableFuture<List<DataFileGroup>> override(ManifestConfig manifestConfig) {
+    // Groups Entries by GroupName.
+    Map<String, List<ManifestConfig.Entry>> groupToEntries =
+        manifestConfig.getEntryList().stream()
+            .collect(
+                groupingBy(
+                    entry -> entry.getDataFileGroup().getGroupName(),
+                    HashMap<String, List<ManifestConfig.Entry>>::new,
+                    mapping(entry -> entry, toCollection(ArrayList<ManifestConfig.Entry>::new))));
+
+    // Finds a DataFileGroup for every GroupName.
+    List<ListenableFuture<Optional<DataFileGroup>>> matchedFileGroupsFuture = new ArrayList<>();
+    for (List<ManifestConfig.Entry> entries : groupToEntries.values()) {
+      matchedFileGroupsFuture.add(getFileGroupWithMatchStrategy(entries));
+    }
+
+    return PropagatedFutures.transform(
+        Futures.successfulAsList(matchedFileGroupsFuture),
+        fileGroups -> {
+          List<DataFileGroup> matchedFileGroups = new ArrayList<>();
+          for (Optional<DataFileGroup> fileGroup : fileGroups) {
+            if (fileGroup != null && fileGroup.isPresent()) {
+              matchedFileGroups.add(fileGroup.get());
+            }
+          }
+          return matchedFileGroups;
+        },
+        lightweightExecutor);
+  }
+
+  /** Returns an optional {@link DataFileGroup} by applying {@code matchStrategy}. */
+  private ListenableFuture<Optional<DataFileGroup>> getFileGroupWithMatchStrategy(
+      List<ManifestConfig.Entry> entries) {
+    Map<Locale, DataFileGroup> localeToFileGroup = new HashMap<>();
+
+    for (ManifestConfig.Entry entry : entries) {
+      for (String localeString : entry.getModifier().getLocaleList()) {
+        DataFileGroup dataFileGroup;
+        if (entry.getDataFileGroup().getLocaleList().contains(localeString)) {
+          dataFileGroup = entry.getDataFileGroup();
+        } else {
+          dataFileGroup = entry.getDataFileGroup().toBuilder().addLocale(localeString).build();
+        }
+        localeToFileGroup.put(Locale.forLanguageTag(localeString), dataFileGroup);
+      }
+    }
+
+    return PropagatedFutures.transform(
+        localeSupplier.get(),
+        locale -> {
+          Optional<Locale> chosenLocaleOptional =
+              matchStrategy.apply(locale, localeToFileGroup.keySet());
+          if (chosenLocaleOptional.isPresent()) {
+            Locale chosenLocale = chosenLocaleOptional.get();
+            LogUtil.d("%s: chosenLocale: %s", TAG, chosenLocale);
+            if (localeToFileGroup.containsKey(chosenLocale)) {
+              LogUtil.v("%s: matched groups %s", TAG, localeToFileGroup.get(chosenLocale));
+              return Optional.of(localeToFileGroup.get(chosenLocale));
+            } else {
+              LogUtil.e("%s: Strategy applied retured invalid locale: : %s", TAG, chosenLocale);
+            }
+          }
+          return Optional.absent();
+        },
+        lightweightExecutor);
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/populator/LocationProvider.java b/java/com/google/android/libraries/mobiledatadownload/populator/LocationProvider.java
new file mode 100644
index 0000000..251c7bc
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/populator/LocationProvider.java
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.populator;
+
+import android.location.Location;
+import com.google.common.base.Optional;
+import com.google.common.base.Supplier;
+
+/** An interface to provide the device location to the Webref populator. */
+public interface LocationProvider extends Supplier<Optional<Location>> {}
diff --git a/java/com/google/android/libraries/mobiledatadownload/populator/LocationProviderImpl.java b/java/com/google/android/libraries/mobiledatadownload/populator/LocationProviderImpl.java
new file mode 100644
index 0000000..7463585
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/populator/LocationProviderImpl.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.populator;
+
+import android.Manifest.permission;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.location.Location;
+import android.location.LocationManager;
+import androidx.core.content.ContextCompat;
+import com.google.common.base.Optional;
+
+/**
+ * This common class defines a function that provides the device location to the Webref populator.
+ */
+final class LocationProviderImpl implements LocationProvider {
+  private final Context context;
+  private final LocationManager locationManager;
+
+  LocationProviderImpl(Context context, LocationManager locationManager) {
+    this.context = context;
+    this.locationManager = locationManager;
+  }
+
+  /**
+   * Returns the location according to network or GPS provider or returns absent if app doesn't have
+   * the permission to request the location.
+   */
+  @Override
+  public Optional<Location> get() {
+    if (ContextCompat.checkSelfPermission(context, permission.ACCESS_FINE_LOCATION)
+            == PackageManager.PERMISSION_DENIED
+        && ContextCompat.checkSelfPermission(context, permission.ACCESS_COARSE_LOCATION)
+            == PackageManager.PERMISSION_DENIED) {
+      return Optional.absent();
+    }
+
+    Location networkProviderLocation =
+        locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER);
+    if (networkProviderLocation != null && networkProviderLocation.hasAccuracy()) {
+      return Optional.of(networkProviderLocation);
+    }
+    Location gpsProviderLocation =
+        locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER);
+    if (gpsProviderLocation != null && gpsProviderLocation.hasAccuracy()) {
+      return Optional.of(gpsProviderLocation);
+    }
+    return Optional.absent();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/populator/LocationProviderOverride.java b/java/com/google/android/libraries/mobiledatadownload/populator/LocationProviderOverride.java
new file mode 100644
index 0000000..ab5744f
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/populator/LocationProviderOverride.java
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.populator;
+
+import javax.inject.Qualifier;
+
+/** Allows overriding the default binding for {@link LocationProvider}. */
+@Qualifier
+public @interface LocationProviderOverride {}
diff --git a/java/com/google/android/libraries/mobiledatadownload/populator/ManifestConfigFlagPopulator.java b/java/com/google/android/libraries/mobiledatadownload/populator/ManifestConfigFlagPopulator.java
new file mode 100644
index 0000000..37ffbc7
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/populator/ManifestConfigFlagPopulator.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.populator;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.android.libraries.mobiledatadownload.FileGroupPopulator;
+import com.google.android.libraries.mobiledatadownload.MobileDataDownload;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil;
+import com.google.common.base.Joiner;
+import com.google.common.base.Optional;
+import com.google.common.base.Supplier;
+import com.google.common.collect.Lists;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.mobiledatadownload.DownloadConfigProto.DataFileGroup;
+import com.google.mobiledatadownload.DownloadConfigProto.ManifestConfig;
+
+/**
+ * FileGroupPopulator that can process the ManifestConfig Flag from phenotype.
+ *
+ * <p>The {@link ManifestConfigFlagPopulator#manifestConfigFlagName} should point to a PH flag of
+ * type {@link ManifestConfig}.
+ *
+ * <p>Client can set an optional ManifestConfigOverrider to return a list of {@link DataFileGroup}
+ * which will be added to MDD. The Overrider will enable the on device targeting.
+ *
+ * <p>NOTE: if an app uses Flag Name Obfuscation, then the passed in flag name must be an obfuscated
+ * name. For more info, see <internal>
+ */
+public final class ManifestConfigFlagPopulator implements FileGroupPopulator {
+
+  private static final String TAG = "ManifestConfigFlagPopulator";
+
+  /**
+   * Builder for {@link ManifestConfigFlagPopulator}.
+   *
+   * <p>Either {@code manifestConfigSupplier} or both {@code phPackageName} and {@code
+   * manifestConfigFlagName} should be set.
+   */
+  public static final class Builder {
+    private Supplier<ManifestConfig> manifestConfigSupplier;
+
+    private Optional<ManifestConfigOverrider> overriderOptional = Optional.absent();
+
+    /** Set the ManifestConfig supplier. */
+    public Builder setManifestConfigSupplier(Supplier<ManifestConfig> manifestConfigSupplier) {
+      this.manifestConfigSupplier = manifestConfigSupplier;
+      return this;
+    }
+
+    /**
+     * Sets the optional Overrider that takes a {@link ManifestConfig} and returns a list of {@link
+     * DataFileGroup} which will be added to MDD. The Overrider will enable the on device targeting.
+     */
+    public Builder setOverriderOptional(Optional<ManifestConfigOverrider> overriderOptional) {
+      this.overriderOptional = overriderOptional;
+      return this;
+    }
+
+    public ManifestConfigFlagPopulator build() {
+      checkArgument(manifestConfigSupplier != null, "Supplier should be provided.");
+      return new ManifestConfigFlagPopulator(manifestConfigSupplier, overriderOptional);
+    }
+  }
+
+  private final Supplier<ManifestConfig> manifestConfigSupplier;
+  private final Optional<ManifestConfigOverrider> overriderOptional;
+
+  /** Returns a Builder for ManifestConfigFlagPopulator. */
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  private ManifestConfigFlagPopulator(
+      Supplier<ManifestConfig> manifestConfigSupplier,
+      Optional<ManifestConfigOverrider> overriderOptional) {
+    this.manifestConfigSupplier = manifestConfigSupplier;
+    this.overriderOptional = overriderOptional;
+  }
+
+  @Override
+  public ListenableFuture<Void> refreshFileGroups(MobileDataDownload mobileDataDownload) {
+    ManifestConfig manifestConfig = manifestConfigSupplier.get();
+
+    String groups =
+        Joiner.on(",")
+            .join(
+                Lists.transform(
+                    manifestConfig.getEntryList(),
+                    entry -> entry.getDataFileGroup().getGroupName()));
+    LogUtil.d("%s: Add groups [%s] from ManifestConfig to MDD.", TAG, groups);
+
+    return ManifestConfigHelper.refreshFromManifestConfig(
+        mobileDataDownload, manifestConfigSupplier.get(), overriderOptional);
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/populator/ManifestConfigHelper.java b/java/com/google/android/libraries/mobiledatadownload/populator/ManifestConfigHelper.java
new file mode 100644
index 0000000..d2c8722
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/populator/ManifestConfigHelper.java
@@ -0,0 +1,188 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.populator;
+
+import android.util.Log;
+import com.google.android.libraries.mobiledatadownload.AddFileGroupRequest;
+import com.google.android.libraries.mobiledatadownload.MobileDataDownload;
+import com.google.android.libraries.mobiledatadownload.tracing.PropagatedFluentFuture;
+import com.google.android.libraries.mobiledatadownload.tracing.PropagatedFutures;
+import com.google.common.base.Optional;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.mobiledatadownload.DownloadConfigProto.DataFile;
+import com.google.mobiledatadownload.DownloadConfigProto.DataFileGroup;
+import com.google.mobiledatadownload.DownloadConfigProto.DeltaFile;
+import com.google.mobiledatadownload.DownloadConfigProto.ManifestConfig;
+import java.util.ArrayList;
+import java.util.List;
+
+/** Shared functions for ManifestConfig. */
+public final class ManifestConfigHelper {
+  public static final String URL_TEMPLATE_CHECKSUM_PLACEHOLDER = "{checksum}";
+
+  private static final String TAG = "ManifestConfigHelper";
+
+  private final MobileDataDownload mobileDataDownload;
+  private final Optional<ManifestConfigOverrider> overriderOptional;
+
+  /** Creates a new helper for converting manifest configs into data file groups. */
+  ManifestConfigHelper(
+      MobileDataDownload mobileDataDownload, Optional<ManifestConfigOverrider> overriderOptional) {
+    this.mobileDataDownload = mobileDataDownload;
+    this.overriderOptional = overriderOptional;
+  }
+
+  /**
+   * Reads file groups from {@link ManifestConfig} and adds to MDD after applying the {@link
+   * ManifestConfigOverrider} if it's present. This static method is shared with {@link
+   * ManifestFileGroupPopulator}.
+   *
+   * @param mobileDataDownload The MDD instance.
+   * @param manifestConfig The proto that contains configs for file groups and modifiers.
+   * @param overriderOptional An optional overrider that takes manifest config and returns a list of
+   *     file groups to be added to MDD.
+   */
+  static ListenableFuture<Void> refreshFromManifestConfig(
+      MobileDataDownload mobileDataDownload,
+      ManifestConfig manifestConfig,
+      Optional<ManifestConfigOverrider> overriderOptional) {
+    ManifestConfigHelper helper = new ManifestConfigHelper(mobileDataDownload, overriderOptional);
+    return PropagatedFluentFuture.from(helper.applyOverrider(manifestConfig))
+        .transformAsync(helper::addAllFileGroups, MoreExecutors.directExecutor());
+  }
+
+  /** Adds the specified list of file groups to MDD. */
+  ListenableFuture<Void> addAllFileGroups(List<DataFileGroup> fileGroups) {
+    List<ListenableFuture<Boolean>> addFileGroupFutures = new ArrayList<>();
+
+    for (DataFileGroup dataFileGroup : fileGroups) {
+      if (dataFileGroup == null || dataFileGroup.getGroupName().isEmpty()) {
+        continue;
+      }
+
+      ListenableFuture<Boolean> addFileGroupFuture =
+          mobileDataDownload.addFileGroup(
+              AddFileGroupRequest.newBuilder().setDataFileGroup(dataFileGroup).build());
+
+      PropagatedFutures.addCallback(
+          addFileGroupFuture,
+          new FutureCallback<Boolean>() {
+            @Override
+            public void onSuccess(Boolean result) {
+              String groupName = dataFileGroup.getGroupName();
+              if (result.booleanValue()) {
+                Log.d(TAG, "Added file groups " + groupName);
+              } else {
+                Log.d(TAG, "Failed to add file group " + groupName);
+              }
+            }
+
+            @Override
+            public void onFailure(Throwable t) {
+              Log.e(TAG, "Failed to add file group", t);
+            }
+          },
+          MoreExecutors.directExecutor());
+      addFileGroupFutures.add(addFileGroupFuture);
+    }
+    return PropagatedFutures.whenAllComplete(addFileGroupFutures)
+        .call(() -> null, MoreExecutors.directExecutor());
+  }
+
+  /** Applies the overrider to the manifest config to generate a list of file groups for adding. */
+  ListenableFuture<List<DataFileGroup>> applyOverrider(ManifestConfig manifestConfig) {
+    if (overriderOptional.isPresent()) {
+      return overriderOptional.get().override(maybeApplyFileUrlTemplate(manifestConfig));
+    }
+    List<DataFileGroup> results = new ArrayList<>();
+    for (ManifestConfig.Entry entry : maybeApplyFileUrlTemplate(manifestConfig).getEntryList()) {
+      results.add(entry.getDataFileGroup());
+    }
+    return Futures.immediateFuture(results);
+  }
+
+  /**
+   * If file_url_template is populated and file url_to_download field is empty in the {@code
+   * ManifestConfig} manifestConfig then construct the url_to_download field using the template.
+   *
+   * <p>NOTE: If file_url_template is empty then the files are expected to have the complete
+   * download URL, validate and throw an {@link IllegalArgumentException} if url_to_download is not
+   * populated.
+   */
+  public static ManifestConfig maybeApplyFileUrlTemplate(ManifestConfig manifestConfig) {
+    if (!manifestConfig.hasUrlTemplate()
+        || manifestConfig.getUrlTemplate().getFileUrlTemplate().isEmpty()) {
+      return validateManifestConfigFileUrls(manifestConfig);
+    }
+    String fileDownloadUrlTemplate = manifestConfig.getUrlTemplate().getFileUrlTemplate();
+    ManifestConfig.Builder updatedManifestConfigBuilder = manifestConfig.toBuilder().clearEntry();
+
+    for (ManifestConfig.Entry entry : manifestConfig.getEntryList()) {
+      DataFileGroup.Builder dataFileGroupBuilder = entry.getDataFileGroup().toBuilder().clearFile();
+      for (DataFile dataFile : entry.getDataFileGroup().getFileList()) {
+        DataFile.Builder dataFileBuilder = dataFile.toBuilder().clearDeltaFile();
+
+        if (dataFile.getUrlToDownload().isEmpty()) {
+          dataFileBuilder.setUrlToDownload(
+              fileDownloadUrlTemplate.replace(
+                  URL_TEMPLATE_CHECKSUM_PLACEHOLDER, dataFile.getChecksum()));
+        }
+
+        for (DeltaFile deltaFile : dataFile.getDeltaFileList()) {
+          dataFileBuilder.addDeltaFile(
+              deltaFile.getUrlToDownload().isEmpty()
+                  ? deltaFile.toBuilder()
+                      .setUrlToDownload(
+                          fileDownloadUrlTemplate.replace(
+                              URL_TEMPLATE_CHECKSUM_PLACEHOLDER, deltaFile.getChecksum()))
+                      .build()
+                  : deltaFile);
+        }
+
+        dataFileGroupBuilder.addFile(dataFileBuilder);
+      }
+      updatedManifestConfigBuilder.addEntry(
+          entry.toBuilder().setDataFileGroup(dataFileGroupBuilder));
+    }
+    return updatedManifestConfigBuilder.build();
+  }
+
+  /**
+   * Validates that all the files in {@code ManifestConfig} manifestConfig have the url_to_download
+   * populated.
+   */
+  private static ManifestConfig validateManifestConfigFileUrls(ManifestConfig manifestConfig) {
+    for (ManifestConfig.Entry entry : manifestConfig.getEntryList()) {
+      for (DataFile dataFile : entry.getDataFileGroup().getFileList()) {
+        if (dataFile.getUrlToDownload().isEmpty()) {
+          throw new IllegalArgumentException(
+              String.format("DataFile %s url_to_download is missing.", dataFile.getFileId()));
+        }
+        for (DeltaFile deltaFile : dataFile.getDeltaFileList()) {
+          if (deltaFile.getUrlToDownload().isEmpty()) {
+            throw new IllegalArgumentException(
+                String.format(
+                    "DeltaFile for file %s url_to_download is missing.", dataFile.getFileId()));
+          }
+        }
+      }
+    }
+    return manifestConfig;
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/populator/ManifestConfigOverrider.java b/java/com/google/android/libraries/mobiledatadownload/populator/ManifestConfigOverrider.java
new file mode 100644
index 0000000..8a31f60
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/populator/ManifestConfigOverrider.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.populator;
+
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.mobiledatadownload.DownloadConfigProto.DataFileGroup;
+import com.google.mobiledatadownload.DownloadConfigProto.ManifestConfig;
+import java.util.List;
+
+/**
+ * Client provided Overrider which optionally overrides a {@link ManifestConfig}. This could be used
+ * for on device targeting by altering or dropping the input ManifestConfig.DataFileGroup.
+ */
+public interface ManifestConfigOverrider {
+
+  /** Optionally override the input ManifestConfig. */
+  ListenableFuture<List<DataFileGroup>> override(ManifestConfig manifestConfig);
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/populator/ManifestFileGroupPopulator.java b/java/com/google/android/libraries/mobiledatadownload/populator/ManifestFileGroupPopulator.java
new file mode 100644
index 0000000..66d26ab
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/populator/ManifestFileGroupPopulator.java
@@ -0,0 +1,680 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.populator;
+
+import static com.google.android.libraries.mobiledatadownload.tracing.TracePropagation.propagateAsyncCallable;
+import static com.google.common.util.concurrent.Futures.immediateFailedFuture;
+import static com.google.common.util.concurrent.Futures.immediateVoidFuture;
+
+import android.content.Context;
+import android.net.Uri;
+import androidx.annotation.VisibleForTesting;
+import com.google.mobiledatadownload.populator.MetadataProto.ManifestFileBookkeeping;
+import com.google.mobiledatadownload.populator.MetadataProto.ManifestFileBookkeeping.Status;
+import com.google.android.libraries.mobiledatadownload.AggregateException;
+import com.google.android.libraries.mobiledatadownload.DownloadException;
+import com.google.android.libraries.mobiledatadownload.DownloadException.DownloadResultCode;
+import com.google.android.libraries.mobiledatadownload.FileGroupPopulator;
+import com.google.android.libraries.mobiledatadownload.Flags;
+import com.google.android.libraries.mobiledatadownload.Logger;
+import com.google.android.libraries.mobiledatadownload.MobileDataDownload;
+import com.google.android.libraries.mobiledatadownload.downloader.CheckContentChangeRequest;
+import com.google.android.libraries.mobiledatadownload.downloader.CheckContentChangeResponse;
+import com.google.android.libraries.mobiledatadownload.downloader.DownloadConstraints;
+import com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest;
+import com.google.android.libraries.mobiledatadownload.downloader.FileDownloader;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil;
+import com.google.android.libraries.mobiledatadownload.internal.util.DirectoryUtil;
+import com.google.android.libraries.mobiledatadownload.logger.FileGroupPopulatorLogger;
+import com.google.android.libraries.mobiledatadownload.tracing.PropagatedFluentFuture;
+import com.google.android.libraries.mobiledatadownload.tracing.PropagatedFutures;
+import com.google.common.base.Optional;
+import com.google.common.base.Preconditions;
+import com.google.common.base.Supplier;
+import com.google.common.collect.ImmutableList;
+import com.google.common.util.concurrent.ExecutionSequencer;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.mobiledatadownload.DownloadConfigProto.DataFileGroup;
+import com.google.mobiledatadownload.DownloadConfigProto.ManifestConfig;
+import com.google.mobiledatadownload.DownloadConfigProto.ManifestFileFlag;
+import java.io.IOException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.atomic.AtomicReference;
+import javax.annotation.Nullable;
+import javax.inject.Singleton;
+
+/**
+ * File group populator that gets {@link ManifestFileFlag} from the caller, downloads the
+ * corresponding manifest file, parses the file into {@link ManifestConfig}, and processes {@link
+ * ManifestConfig}.
+ *
+ * <p>Client can set an optional {@link ManifestConfigOverrider} to return a list of {@link
+ * DataFileGroup}'s to be added to MDD. The overrider will enable the on device targeting.
+ *
+ * <p>Client is responsible of reading {@link ManifestFileFlag} from P/H, and this populator would
+ * get the flag via {@link Supplier<ManifestFileFlag>}.
+ *
+ * <p>On calling {@link #refreshFileGroups(MobileDataDownload)}, this populator would sync up with
+ * server to verify if the manifest file on server has changed since last download. It would
+ * re-download the file if a newer version is available. More specifically, there are 3 scenarios:
+ *
+ * <ul>
+ *   <li>1. Current file up-to-date, status PENDING. Resume download.
+ *   <li>2. Current file up-to-date, status (DOWNLOADED | COMMITTED). No download will happen.
+ *   <li>3. Current file outdated. Delete the outdated file and re-download.
+ * </ul>
+ *
+ * <p>To ensure that each time we download the most up-to-date manifest file correctly, we will
+ * check for {@link FileDownloader#isContentChanged(CheckContentChangeRequest)} twice:
+ *
+ * <ul>
+ *   <li>1. Before the download to check if the new download is necessary.
+ *   <li>2. After the download to make sure that the content is not out of date.
+ * </ul>
+ *
+ * <p>Note that the current prerequisite of using {@link ManifestFileGroupPopulator} is that, the
+ * hosting service needs to support ETag (e.g. Lorry), otherwise the behavior will be unexpected.
+ * Talk to <internal>@ if you are not sure if the hosting service supports ETag.
+ *
+ * <p>Note that {@link SynchronousFileStorage} and {@link ProtoDataStoreFactory} passed to builder
+ * must be @Singleton.
+ *
+ * <p>This class is @Singleton, because it provides the guarantee that all the operations are
+ * serialized correctly by {@link ExecutionSequencer}.
+ */
+@Singleton
+public final class ManifestFileGroupPopulator implements FileGroupPopulator {
+
+  private static final String TAG = "ManifestFileGroupPopulator";
+
+  /** The parser of the manifest file. */
+  public interface ManifestConfigParser {
+
+    /** Parses the input file and returns the {@link ManifestConfig}. */
+    ListenableFuture<ManifestConfig> parse(Uri fileUri);
+  }
+
+  /** Builder for {@link ManifestFileGroupPopulator}. */
+  public static final class Builder {
+    private boolean allowsInsecureHttp = false;
+    private boolean dedupDownloadWithEtag = true;
+    private Context context;
+    private Supplier<ManifestFileFlag> manifestFileFlagSupplier;
+    private Supplier<FileDownloader> fileDownloader;
+    private ManifestConfigParser manifestConfigParser;
+    private SynchronousFileStorage fileStorage;
+    private Executor backgroundExecutor;
+    private ManifestFileMetadataStore manifestFileMetadataStore;
+    private Logger logger;
+    private Optional<ManifestConfigOverrider> overriderOptional = Optional.absent();
+    private Optional<String> instanceIdOptional = Optional.absent();
+    private Flags flags = new Flags() {};
+
+    /**
+     * Sets the flag that allows insecure http.
+     *
+     * <p>For testing only.
+     */
+    @VisibleForTesting
+    Builder setAllowsInsecureHttp(boolean allowsInsecureHttp) {
+      this.allowsInsecureHttp = allowsInsecureHttp;
+      return this;
+    }
+
+    /**
+     * By default, an HTTP HEAD request is made to avoid duplicate downloads of the manifest file.
+     * Setting this to false disables that behavior.
+     */
+    public Builder setDedupDownloadWithEtag(boolean dedup) {
+      this.dedupDownloadWithEtag = dedup;
+      return this;
+    }
+
+    /** Sets the context. */
+    public Builder setContext(Context context) {
+      this.context = context.getApplicationContext();
+      return this;
+    }
+
+    /** Sets the manifest file flag. */
+    public Builder setManifestFileFlagSupplier(
+        Supplier<ManifestFileFlag> manifestFileFlagSupplier) {
+      this.manifestFileFlagSupplier = manifestFileFlagSupplier;
+      return this;
+    }
+
+    /** Sets the file downloader. */
+    public Builder setFileDownloader(Supplier<FileDownloader> fileDownloader) {
+      this.fileDownloader = fileDownloader;
+      return this;
+    }
+
+    /** Sets the manifest config parser that takes file uri and returns {@link ManifestConfig}. */
+    public Builder setManifestConfigParser(ManifestConfigParser manifestConfigParser) {
+      this.manifestConfigParser = manifestConfigParser;
+      return this;
+    }
+
+    /** Sets the mobstore file storage. Mobstore file storage must be singleton. */
+    public Builder setFileStorage(SynchronousFileStorage fileStorage) {
+      this.fileStorage = fileStorage;
+      return this;
+    }
+
+    /** Sets the background executor that executes populator's tasks sequentially. */
+    public Builder setBackgroundExecutor(Executor backgroundExecutor) {
+      this.backgroundExecutor = backgroundExecutor;
+      return this;
+    }
+
+    /** Sets the ManifestFileMetadataStore. */
+    public Builder setMetadataStore(ManifestFileMetadataStore manifestFileMetadataStore) {
+      this.manifestFileMetadataStore = manifestFileMetadataStore;
+      return this;
+    }
+
+    /** Sets the MDD logger. */
+    public Builder setLogger(Logger logger) {
+      this.logger = logger;
+      return this;
+    }
+
+    /** Sets the optional manifest config overrider. */
+    public Builder setOverriderOptional(Optional<ManifestConfigOverrider> overriderOptional) {
+      this.overriderOptional = overriderOptional;
+      return this;
+    }
+
+    /** Sets the optional instance ID. */
+    public Builder setInstanceIdOptional(Optional<String> instanceIdOptional) {
+      this.instanceIdOptional = instanceIdOptional;
+      return this;
+    }
+
+    public Builder setFlags(Flags flags) {
+      this.flags = flags;
+      return this;
+    }
+
+    public ManifestFileGroupPopulator build() {
+      Preconditions.checkNotNull(context, "Must call setContext() before build().");
+      Preconditions.checkNotNull(
+          manifestFileFlagSupplier, "Must call setManifestFileFlagSupplier() before build().");
+      Preconditions.checkNotNull(fileDownloader, "Must call setFileDownloader() before build().");
+      Preconditions.checkNotNull(
+          manifestConfigParser, "Must call setManifestConfigParser() before build().");
+      Preconditions.checkNotNull(fileStorage, "Must call setFileStorage() before build().");
+      Preconditions.checkNotNull(
+          backgroundExecutor, "Must call setBackgroundExecutor() before build().");
+      Preconditions.checkNotNull(
+          manifestFileMetadataStore, "Must call manifestFileMetadataStore() before build().");
+      Preconditions.checkNotNull(logger, "Must call setLogger() before build().");
+      return new ManifestFileGroupPopulator(this);
+    }
+  }
+
+  private final boolean allowsInsecureHttp;
+  private final boolean dedupDownloadWithEtag;
+  private final Context context;
+  private final Uri manifestDirectoryUri;
+  private final Supplier<ManifestFileFlag> manifestFileFlagSupplier;
+  private final Supplier<FileDownloader> fileDownloader;
+  private final ManifestConfigParser manifestConfigParser;
+  private final SynchronousFileStorage fileStorage;
+  private final Executor backgroundExecutor;
+  private final Optional<ManifestConfigOverrider> overriderOptional;
+  private final ManifestFileMetadataStore manifestFileMetadataStore;
+  private final FileGroupPopulatorLogger eventLogger;
+  // We use futureSerializer for synchronization.
+  private final ExecutionSequencer futureSerializer = ExecutionSequencer.create();
+
+  /** Returns a Builder for {@link ManifestFileGroupPopulator}. */
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  private ManifestFileGroupPopulator(Builder builder) {
+    this.allowsInsecureHttp = builder.allowsInsecureHttp;
+    this.dedupDownloadWithEtag = builder.dedupDownloadWithEtag;
+    this.context = builder.context;
+    this.manifestDirectoryUri =
+        DirectoryUtil.getManifestDirectory(builder.context, builder.instanceIdOptional);
+    this.manifestFileFlagSupplier = builder.manifestFileFlagSupplier;
+    this.fileDownloader = builder.fileDownloader;
+    this.manifestConfigParser = builder.manifestConfigParser;
+    this.fileStorage = builder.fileStorage;
+    this.backgroundExecutor = builder.backgroundExecutor;
+    this.overriderOptional = builder.overriderOptional;
+    this.eventLogger = new FileGroupPopulatorLogger(builder.logger, builder.flags);
+    this.manifestFileMetadataStore = builder.manifestFileMetadataStore;
+  }
+
+  @Override
+  public ListenableFuture<Void> refreshFileGroups(MobileDataDownload mobileDataDownload) {
+    return futureSerializer.submitAsync(
+        propagateAsyncCallable(
+            () -> {
+              LogUtil.d("%s: Add groups from ManifestFileFlag to MDD.", TAG);
+
+              // We will return immediately if the flag is null or empty. This could happen if P/H
+              // has not synced the flag or we fail to parse the flag.
+              ManifestFileFlag manifestFileFlag = manifestFileFlagSupplier.get();
+              if (manifestFileFlag == null
+                  || manifestFileFlag.equals(ManifestFileFlag.getDefaultInstance())) {
+                LogUtil.w("%s: The ManifestFileFlag is empty.", TAG);
+                logRefreshResult(0, ManifestFileFlag.getDefaultInstance());
+                return immediateVoidFuture();
+              }
+
+              return refreshFileGroups(mobileDataDownload, manifestFileFlag);
+            }),
+        backgroundExecutor);
+  }
+
+  private ListenableFuture<Void> refreshFileGroups(
+      MobileDataDownload mobileDataDownload, ManifestFileFlag manifestFileFlag) {
+
+    if (!validate(manifestFileFlag)) {
+      logRefreshResult(0, manifestFileFlag);
+      LogUtil.e("%s: Invalid manifest config from manifest flag.", TAG);
+      return immediateFailedFuture(new IllegalArgumentException("Invalid manifest flag."));
+    }
+
+    String manifestFileUrl = manifestFileFlag.getManifestFileUrl();
+
+    // Manifest files are named and identified with their manifest ID.
+    Uri manifestFileUri =
+        manifestDirectoryUri.buildUpon().appendPath(manifestFileFlag.getManifestId()).build();
+
+    // Represents the internal state of the metadata. Using AtomicReference here because the
+    // variable captured by lambda needs to be final.
+    final AtomicReference<ManifestFileBookkeeping> bookkeepingRef =
+        new AtomicReference<>(createDefaultManifestFileBookkeeping(manifestFileUrl));
+
+    ListenableFuture<Void> checkFuture =
+        PropagatedFluentFuture.from(readBookeeping(manifestFileFlag.getManifestId()))
+            .transform(
+                (final Optional<ManifestFileBookkeeping> bookkeepingOptional) -> {
+                  if (bookkeepingOptional.isPresent()) {
+                    bookkeepingRef.set(bookkeepingOptional.get());
+                  }
+                  return (Void) null;
+                },
+                backgroundExecutor)
+            .transformAsync(
+                voidArg ->
+                    // We need to call checkForContentChangeBeforeDownload to sync back the latest
+                    // ETag, even when there is no entry for bookkeeping.
+                    checkForContentChangeBeforeDownload(
+                        manifestFileUrl, manifestFileUri, bookkeepingRef),
+                backgroundExecutor);
+
+    ListenableFuture<Optional<Throwable>> transformCheckFuture =
+        PropagatedFluentFuture.from(checkFuture)
+            .transform(voidArg -> Optional.<Throwable>absent(), backgroundExecutor)
+            .catching(Throwable.class, Optional::of, backgroundExecutor);
+
+    ListenableFuture<Void> processFuture =
+        PropagatedFluentFuture.from(transformCheckFuture)
+            .transformAsync(
+                (final Optional<Throwable> throwableOptional) -> {
+                  // We do not want to proceed if transformCheckFuture contains failures, so return
+                  // early.
+                  if (throwableOptional.isPresent()) {
+                    return immediateVoidFuture();
+                  }
+
+                  ManifestFileBookkeeping bookkeeping = bookkeepingRef.get();
+
+                  if (bookkeeping.getStatus() == Status.COMMITTED) {
+                    LogUtil.d("%s: Manifest file was committed.", TAG);
+                    if (!overriderOptional.isPresent()) {
+                      return immediateVoidFuture();
+                    }
+
+                    // When the overrider is present, it may produce different configs each time the
+                    // caller triggers refresh. Therefore, we need to recommit to MDD.
+                    LogUtil.d("%s: Overrider is present, commit again.", TAG);
+                    return parseAndCommitManifestFile(
+                        mobileDataDownload, manifestFileUri, bookkeepingRef);
+                  }
+
+                  if (bookkeeping.getStatus() == Status.DOWNLOADED) {
+                    LogUtil.d("%s: Manifest file was downloaded.", TAG);
+                    return parseAndCommitManifestFile(
+                        mobileDataDownload, manifestFileUri, bookkeepingRef);
+                  }
+
+                  return PropagatedFluentFuture.from(
+                          downloadManifestFile(manifestFileUrl, manifestFileUri))
+                      .transformAsync(
+                          voidArgInner ->
+                              checkForContentChangeAfterDownload(
+                                  manifestFileUrl, manifestFileUri, bookkeepingRef),
+                          backgroundExecutor)
+                      .transformAsync(
+                          voidArgInner ->
+                              parseAndCommitManifestFile(
+                                  mobileDataDownload, manifestFileUri, bookkeepingRef),
+                          backgroundExecutor);
+                },
+                backgroundExecutor);
+
+    ListenableFuture<Void> catchingProcessFuture =
+        PropagatedFutures.catchingAsync(
+            processFuture,
+            Throwable.class,
+            (Throwable unused) -> {
+              ManifestFileBookkeeping bookkeeping = bookkeepingRef.get();
+              bookkeepingRef.set(bookkeeping.toBuilder().setStatus(Status.PENDING).build());
+              deleteManifestFileChecked(manifestFileUri);
+              return immediateVoidFuture();
+            },
+            backgroundExecutor);
+
+    ListenableFuture<Void> updateFuture =
+        PropagatedFutures.transformAsync(
+            catchingProcessFuture,
+            voidArg -> writeBookkeeping(manifestFileFlag.getManifestId(), bookkeepingRef.get()),
+            backgroundExecutor);
+
+    return PropagatedFutures.transformAsync(
+        updateFuture,
+        voidArg -> {
+          logAndThrowIfFailed(
+              ImmutableList.of(checkFuture, processFuture, updateFuture),
+              "Failed to refresh file groups",
+              manifestFileFlag);
+          // If there is any failure, it should have been thrown already. Therefore, we log refresh
+          // success here.
+          logRefreshResult(0, manifestFileFlag);
+          return immediateVoidFuture();
+        },
+        backgroundExecutor);
+  }
+
+  private boolean validate(@Nullable ManifestFileFlag manifestFileFlag) {
+    if (manifestFileFlag == null) {
+      return false;
+    }
+    if (!manifestFileFlag.hasManifestId() || manifestFileFlag.getManifestId().isEmpty()) {
+      return false;
+    }
+    if (!manifestFileFlag.hasManifestFileUrl()
+        || (!allowsInsecureHttp && !manifestFileFlag.getManifestFileUrl().startsWith("https"))) {
+      return false;
+    }
+    return true;
+  }
+
+  private ListenableFuture<Void> parseAndCommitManifestFile(
+      MobileDataDownload mobileDataDownload,
+      Uri manifestFileUri,
+      AtomicReference<ManifestFileBookkeeping> bookkeepingRef) {
+    return PropagatedFluentFuture.from(parseManifestFile(manifestFileUri))
+        .transformAsync(
+            (final ManifestConfig manifestConfig) ->
+                ManifestConfigHelper.refreshFromManifestConfig(
+                    mobileDataDownload, manifestConfig, overriderOptional),
+            backgroundExecutor)
+        .transformAsync(
+            voidArg -> {
+              ManifestFileBookkeeping bookkeeping = bookkeepingRef.get();
+              bookkeepingRef.set(bookkeeping.toBuilder().setStatus(Status.COMMITTED).build());
+              return immediateVoidFuture();
+            },
+            backgroundExecutor);
+  }
+
+  private ListenableFuture<Void> downloadManifestFile(String urlToDownload, Uri destinationUri) {
+    LogUtil.d(
+        "%s: Start downloading the manifest file from %s to %s.",
+        TAG, urlToDownload, destinationUri.toString());
+
+    // We now download manifest file on any network (similar to P/H). In the future, we may want to
+    // restrict the download only on WiFi, and need to introduce network policy. (However, some
+    // users are never on WiFi)
+    //
+    // Note: Right now, if the download of manifest config file is set to WiFi only but this
+    // populator is triggered in CELLULAR_CHARGING task, then the downloading will be blocked.
+    DownloadConstraints downloadConstraints = DownloadConstraints.NETWORK_CONNECTED;
+
+    return fileDownloader
+        .get()
+        .startDownloading(
+            DownloadRequest.newBuilder()
+                .setUrlToDownload(urlToDownload)
+                .setFileUri(destinationUri)
+                .setDownloadConstraints(downloadConstraints)
+                .build());
+  }
+
+  private ListenableFuture<ManifestConfig> parseManifestFile(Uri manifestFileUri) {
+    LogUtil.d("%s: Parse the manifest file at %s.", TAG, manifestFileUri);
+
+    ListenableFuture<ManifestConfig> parseFuture = manifestConfigParser.parse(manifestFileUri);
+    return DownloadException.wrapIfFailed(
+        parseFuture,
+        DownloadResultCode.MANIFEST_FILE_GROUP_POPULATOR_PARSE_MANIFEST_FILE_ERROR,
+        "Failed to parse the manifest file.");
+  }
+
+  private ListenableFuture<Void> checkForContentChangeBeforeDownload(
+      String urlToDownload,
+      Uri manifestFileUri,
+      AtomicReference<ManifestFileBookkeeping> bookkeepingRef) {
+    LogUtil.d("%s: Prepare for downloading manifest file.", TAG);
+
+    if (!dedupDownloadWithEtag) {
+      return immediateVoidFuture();
+    }
+
+    ManifestFileBookkeeping bookkeeping = bookkeepingRef.get();
+
+    ListenableFuture<CheckContentChangeResponse> isContentChangedFuture =
+        fileDownloader
+            .get()
+            .isContentChanged(
+                CheckContentChangeRequest.newBuilder()
+                    .setUrl(urlToDownload)
+                    .setCachedETagOptional(getCachedETag(bookkeeping))
+                    .build());
+
+    return PropagatedFutures.transformAsync(
+        isContentChangedFuture,
+        (final CheckContentChangeResponse response) -> {
+          Status currentStatus = bookkeepingRef.get().getStatus();
+
+          // If the manifest file on server side has been modified since last download, then the
+          // manifest file previously downloaded is now stale. We need to delete it and re-download
+          // the latest version.
+          //
+          // In case of url changes, we still want to send the network request to fetch the ETag.
+          boolean urlUpdated = !urlToDownload.equals(bookkeeping.getManifestFileUrl());
+          if (urlUpdated || response.contentChanged()) {
+            LogUtil.d(
+                "%s: Manifest file on server updated, will re-download; urlToDownload = %s;"
+                    + " manifestFileUri = %s",
+                TAG, urlToDownload, manifestFileUri);
+            currentStatus = Status.PENDING;
+            deleteManifestFileChecked(manifestFileUri);
+          }
+
+          bookkeepingRef.set(
+              createManifestFileBookkeeping(
+                  urlToDownload, currentStatus, response.freshETagOptional()));
+
+          return immediateVoidFuture();
+        },
+        backgroundExecutor);
+  }
+
+  private ListenableFuture<Void> checkForContentChangeAfterDownload(
+      String urlToDownload,
+      Uri manifestFileUri,
+      AtomicReference<ManifestFileBookkeeping> bookkeepingRef) {
+    LogUtil.d("%s: Finalize for downloading manifest file.", TAG);
+
+    if (!dedupDownloadWithEtag) {
+      return immediateVoidFuture();
+    }
+
+    ManifestFileBookkeeping bookkeeping = bookkeepingRef.get();
+
+    ListenableFuture<CheckContentChangeResponse> isContentChangedFuture =
+        fileDownloader
+            .get()
+            .isContentChanged(
+                CheckContentChangeRequest.newBuilder()
+                    .setUrl(urlToDownload)
+                    .setCachedETagOptional(getCachedETag(bookkeeping))
+                    .build());
+
+    return PropagatedFutures.transformAsync(
+        isContentChangedFuture,
+        (final CheckContentChangeResponse response) -> {
+          // If the manifest file on server has changed during download. The manifest file we just
+          // downloaded is stale during the download.
+          if (response.contentChanged()) {
+            LogUtil.e(
+                "%s: Manifest file on server changed during download, download failed;"
+                    + " urlToDownload = %s; manifestFileUri = %s",
+                TAG, urlToDownload, manifestFileUri);
+            return immediateFailedFuture(
+                DownloadException.builder()
+                    .setDownloadResultCode(
+                        DownloadResultCode
+                            .MANIFEST_FILE_GROUP_POPULATOR_CONTENT_CHANGED_DURING_DOWNLOAD_ERROR)
+                    .setMessage("Manifest file on server changed during download.")
+                    .build());
+          }
+
+          bookkeepingRef.set(
+              createManifestFileBookkeeping(
+                  urlToDownload, Status.DOWNLOADED, response.freshETagOptional()));
+
+          return immediateVoidFuture();
+        },
+        backgroundExecutor);
+  }
+
+  private ListenableFuture<Optional<ManifestFileBookkeeping>> readBookeeping(String manifestId) {
+    return DownloadException.wrapIfFailed(
+        manifestFileMetadataStore.read(manifestId),
+        DownloadResultCode.MANIFEST_FILE_GROUP_POPULATOR_METADATA_IO_ERROR,
+        "Failed to read bookkeeping.");
+  }
+
+  private ListenableFuture<Void> writeBookkeeping(
+      String manifestId, ManifestFileBookkeeping value) {
+    return DownloadException.wrapIfFailed(
+        manifestFileMetadataStore.upsert(manifestId, value),
+        DownloadResultCode.MANIFEST_FILE_GROUP_POPULATOR_METADATA_IO_ERROR,
+        "Failed to write bookkeeping.");
+  }
+
+  private void deleteManifestFileChecked(Uri manifestFileUri) throws DownloadException {
+    try {
+      deleteManifestFile(manifestFileUri);
+    } catch (IOException e) {
+      throw DownloadException.builder()
+          .setCause(e)
+          .setDownloadResultCode(
+              DownloadResultCode.MANIFEST_FILE_GROUP_POPULATOR_DELETE_MANIFEST_FILE_ERROR)
+          .setMessage("Failed to delete manifest file.")
+          .build();
+    }
+  }
+
+  private void deleteManifestFile(Uri manifestFileUri) throws IOException {
+    if (fileStorage.exists(manifestFileUri)) {
+      LogUtil.d("%s: Removing manifest file at: %s", TAG, manifestFileUri);
+      fileStorage.deleteFile(manifestFileUri);
+    } else {
+      LogUtil.d("%s: Manifest file doesn't exist: %s", TAG, manifestFileUri);
+    }
+  }
+
+  private void logRefreshResult(DownloadException e, ManifestFileFlag manifestFileFlag) {
+    eventLogger.logManifestFileGroupPopulatorRefreshResult(
+        0,
+        manifestFileFlag.getManifestId(),
+        context.getPackageName(),
+        manifestFileFlag.getManifestFileUrl());
+  }
+
+  private void logRefreshResult(int code, ManifestFileFlag manifestFileFlag) {
+    eventLogger.logManifestFileGroupPopulatorRefreshResult(
+        code,
+        manifestFileFlag.getManifestId(),
+        context.getPackageName(),
+        manifestFileFlag.getManifestFileUrl());
+  }
+
+  private void logAndThrowIfFailed(
+      ImmutableList<ListenableFuture<Void>> futures,
+      String message,
+      ManifestFileFlag manifestFileFlag)
+      throws AggregateException {
+    FutureCallback<Void> logRefreshResultCallback =
+        new FutureCallback<Void>() {
+          @Override
+          public void onSuccess(Void unused) {}
+
+          @Override
+          public void onFailure(Throwable t) {
+            if (t instanceof DownloadException) {
+              logRefreshResult((DownloadException) t, manifestFileFlag);
+            } else {
+              // Here, we encountered an error that is unchecked. If UNKNOWN_ERROR is observed, we
+              // will need to investigate the cause and have it checked.
+              logRefreshResult(
+                  DownloadException.builder()
+                      .setCause(t)
+                      .setDownloadResultCode(DownloadResultCode.UNKNOWN_ERROR)
+                      .setMessage("Refresh failed.")
+                      .build(),
+                  manifestFileFlag);
+            }
+          }
+        };
+    AggregateException.throwIfFailed(futures, Optional.of(logRefreshResultCallback), message);
+  }
+
+  private static ManifestFileBookkeeping createDefaultManifestFileBookkeeping(
+      String manifestFileUrl) {
+    return createManifestFileBookkeeping(
+        manifestFileUrl, Status.PENDING, /* eTagOptional = */ Optional.absent());
+  }
+
+  private static ManifestFileBookkeeping createManifestFileBookkeeping(
+      String manifestFileUrl, Status status, Optional<String> eTagOptional) {
+    ManifestFileBookkeeping.Builder bookkeeping =
+        ManifestFileBookkeeping.newBuilder().setManifestFileUrl(manifestFileUrl).setStatus(status);
+    if (eTagOptional.isPresent()) {
+      bookkeeping.setCachedEtag(eTagOptional.get());
+    }
+    return bookkeeping.build();
+  }
+
+  private static Optional<String> getCachedETag(ManifestFileBookkeeping bookkeeping) {
+    return bookkeeping.hasCachedEtag()
+        ? Optional.of(bookkeeping.getCachedEtag())
+        : Optional.absent();
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/populator/ManifestFileMetadataStore.java b/java/com/google/android/libraries/mobiledatadownload/populator/ManifestFileMetadataStore.java
new file mode 100644
index 0000000..4d80080
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/populator/ManifestFileMetadataStore.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.populator;
+
+import com.google.mobiledatadownload.populator.MetadataProto.ManifestFileBookkeeping;
+import com.google.common.base.Optional;
+import com.google.common.util.concurrent.ListenableFuture;
+
+/** Storage mechanism for ManifestFileBookkeeping. */
+interface ManifestFileMetadataStore {
+  /** Returns the metadata associated with {@code manifestId} if it exists. */
+  ListenableFuture<Optional<ManifestFileBookkeeping>> read(String manifestId);
+
+  /** Inserts or updates the metadata associated with {@code manifestId}. */
+  ListenableFuture<Void> upsert(String manifestId, ManifestFileBookkeeping value);
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/populator/MigrationProxyLocaleOverrider.java b/java/com/google/android/libraries/mobiledatadownload/populator/MigrationProxyLocaleOverrider.java
new file mode 100644
index 0000000..0019f55
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/populator/MigrationProxyLocaleOverrider.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.populator;
+
+import static com.google.common.util.concurrent.Futures.immediateFuture;
+
+import com.google.common.base.Supplier;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.mobiledatadownload.DownloadConfigProto.DataFileGroup;
+import com.google.mobiledatadownload.DownloadConfigProto.ManifestConfig;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Proxy overrider used in the migration of ManifestFileGroupPopulator.
+ *
+ * <p>{@code flagSupplier} is used to determine whether the ManifestFileGroupPopulator is enabled.
+ * It is called every time a client calls refreshFileGroups. When it returns true, it calls {@code
+ * localeOverrider.override()}; when it returns false, it returns an empty list which makes the
+ * ManifestFileGroupPopulator not add any data file group to MDD.
+ */
+public final class MigrationProxyLocaleOverrider implements ManifestConfigOverrider {
+  private static final String TAG = "MigrationProxyLocaleOverrider";
+
+  private final Supplier<Boolean> flagSupplier;
+  private final LocaleOverrider localeOverrider;
+
+  public MigrationProxyLocaleOverrider(
+      LocaleOverrider localeOverrider, Supplier<Boolean> flagSupplier) {
+    this.flagSupplier = flagSupplier;
+    this.localeOverrider = localeOverrider;
+  }
+
+  @Override
+  public ListenableFuture<List<DataFileGroup>> override(ManifestConfig manifestConfig) {
+    if (flagSupplier.get()) {
+      return localeOverrider.override(manifestConfig);
+    } else {
+      return immediateFuture(new ArrayList<>());
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/populator/MigrationProxyPopulator.java b/java/com/google/android/libraries/mobiledatadownload/populator/MigrationProxyPopulator.java
new file mode 100644
index 0000000..dc5f610
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/populator/MigrationProxyPopulator.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.populator;
+
+import com.google.android.libraries.mobiledatadownload.FileGroupPopulator;
+import com.google.android.libraries.mobiledatadownload.MobileDataDownload;
+import com.google.common.base.Supplier;
+import com.google.common.util.concurrent.ListenableFuture;
+
+/**
+ * A {@code MigrationProxyPopulator} can be used by a client to run migration experiments from one
+ * file group populator to another.
+ *
+ * <p>{@code flagSupplier} is used to determine whether the control or experiment populator is used.
+ * It is called every time a client calls refreshFileGroups. When it returns true, {@code
+ * experimentFileGroupPopulator} is delegated to refresh file group; when it returns false, {@code
+ * controlFileGroupPopulator} is delegated to refresh file group. This design enables the client to
+ * refresh on the correct populator based on most up-to-date flag value.
+ */
+public final class MigrationProxyPopulator implements FileGroupPopulator {
+  private final FileGroupPopulator controlFileGroupPopulator;
+  private final FileGroupPopulator experimentFileGroupPopulator;
+  private final Supplier<Boolean> flagSupplier;
+
+  public MigrationProxyPopulator(
+      FileGroupPopulator controlFileGroupPopulator,
+      FileGroupPopulator experimentFileGroupPopulator,
+      Supplier<Boolean> flagSupplier) {
+    this.controlFileGroupPopulator = controlFileGroupPopulator;
+    this.experimentFileGroupPopulator = experimentFileGroupPopulator;
+    this.flagSupplier = flagSupplier;
+  }
+
+  @Override
+  public ListenableFuture<Void> refreshFileGroups(MobileDataDownload mobileDataDownload) {
+    if (flagSupplier.get()) {
+      return experimentFileGroupPopulator.refreshFileGroups(mobileDataDownload);
+    } else {
+      return controlFileGroupPopulator.refreshFileGroups(mobileDataDownload);
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/populator/SharedPreferencesManifestFileMetadata.java b/java/com/google/android/libraries/mobiledatadownload/populator/SharedPreferencesManifestFileMetadata.java
new file mode 100644
index 0000000..8656e91
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/populator/SharedPreferencesManifestFileMetadata.java
@@ -0,0 +1,94 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.populator;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import com.google.mobiledatadownload.populator.MetadataProto.ManifestFileBookkeeping;
+import com.google.android.libraries.mobiledatadownload.internal.util.SharedPreferencesUtil;
+import com.google.android.libraries.mobiledatadownload.tracing.PropagatedFutures;
+import com.google.common.base.Optional;
+import com.google.common.base.Supplier;
+import com.google.common.base.Suppliers;
+import com.google.common.util.concurrent.ListenableFuture;
+import java.io.IOException;
+import java.util.concurrent.Executor;
+
+/** ManifestFileMetadataStore based on SharedPreferences. */
+public final class SharedPreferencesManifestFileMetadata implements ManifestFileMetadataStore {
+
+  private static final String SHARED_PREFS_NAME = "ManifestFileMetadata";
+
+  private final Object lock = new Object();
+
+  private final Supplier<SharedPreferences> sharedPrefs;
+  private final Executor backgroundExecutor;
+
+  /**
+   * @param sharedPrefs may be called multiple times, so memoization is recommended
+   */
+  public static SharedPreferencesManifestFileMetadata create(
+      Supplier<SharedPreferences> sharedPrefs, Executor backgroundExecutor) {
+    return new SharedPreferencesManifestFileMetadata(sharedPrefs, backgroundExecutor);
+  }
+
+  public static SharedPreferencesManifestFileMetadata createFromContext(
+      Context context, Optional<String> instanceIdOptional, Executor backgroundExecutor) {
+    // Avoid calling getSharedPreferences on the main thread.
+    Supplier<SharedPreferences> sharedPrefs =
+        Suppliers.memoize(
+            () ->
+                SharedPreferencesUtil.getSharedPreferences(
+                    context, SHARED_PREFS_NAME, instanceIdOptional));
+    return new SharedPreferencesManifestFileMetadata(sharedPrefs, backgroundExecutor);
+  }
+
+  private SharedPreferencesManifestFileMetadata(
+      Supplier<SharedPreferences> sharedPrefs, Executor backgroundExecutor) {
+    this.sharedPrefs = sharedPrefs;
+    this.backgroundExecutor = backgroundExecutor;
+  }
+
+  @Override
+  public ListenableFuture<Optional<ManifestFileBookkeeping>> read(String manifestId) {
+    return PropagatedFutures.submit(
+        () -> {
+          synchronized (lock) {
+            ManifestFileBookkeeping proto =
+                SharedPreferencesUtil.readProto(
+                    sharedPrefs.get(), manifestId, ManifestFileBookkeeping.parser());
+            return Optional.fromNullable(proto);
+          }
+        },
+        backgroundExecutor);
+  }
+
+  @Override
+  public ListenableFuture<Void> upsert(String manifestId, ManifestFileBookkeeping value) {
+    return PropagatedFutures.submit(
+        () -> {
+          synchronized (lock) {
+            SharedPreferences.Editor editor = sharedPrefs.get().edit();
+            SharedPreferencesUtil.writeProto(editor, manifestId, value);
+            if (!editor.commit()) {
+              throw new IOException("Failed to commit");
+            }
+            return null; // for Callable
+          }
+        },
+        backgroundExecutor);
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/populator/SingleDataFileGroupPopulator.java b/java/com/google/android/libraries/mobiledatadownload/populator/SingleDataFileGroupPopulator.java
new file mode 100644
index 0000000..10bd8c8
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/populator/SingleDataFileGroupPopulator.java
@@ -0,0 +1,137 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.populator;
+
+import android.util.Log;
+import com.google.android.libraries.mobiledatadownload.AddFileGroupRequest;
+import com.google.android.libraries.mobiledatadownload.FileGroupPopulator;
+import com.google.android.libraries.mobiledatadownload.MobileDataDownload;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil;
+import com.google.common.base.Optional;
+import com.google.common.base.Supplier;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.mobiledatadownload.DownloadConfigProto.DataFileGroup;
+
+/**
+ * FileGroupPopulator that gets a single file group from a supplier and populates it.
+ *
+ * <p>Client can set an optional file group overrider to override fields in the {@link
+ * DataFileGroup} if needed.
+ *
+ * <p>The overrider could also be used for on device targeting and filtering in the case that the
+ * file group is provided from a server.
+ */
+public final class SingleDataFileGroupPopulator implements FileGroupPopulator {
+
+  private static final String TAG = "SingleDataFileGroupPop";
+
+  /** Builder for {@link SingleDataFileGroupPopulator}. */
+  public static final class Builder {
+    private Supplier<DataFileGroup> dataFileGroupSupplier;
+    private Optional<DataFileGroupOverrider> overriderOptional = Optional.absent();
+
+    public Builder setDataFileGroupSupplier(Supplier<DataFileGroup> dataFileGroupSupplier) {
+      this.dataFileGroupSupplier = dataFileGroupSupplier;
+      return this;
+    }
+
+    /**
+     * Sets the optional file group overrider that takes the {@link DataFileGroup} and returns a
+     * {@link DataFileGroup} after being overridden. If the overrider returns a null data file
+     * group, nothing will be populated.
+     */
+    public Builder setOverriderOptional(Optional<DataFileGroupOverrider> overriderOptional) {
+      this.overriderOptional = overriderOptional;
+      return this;
+    }
+
+    public SingleDataFileGroupPopulator build() {
+      return new SingleDataFileGroupPopulator(this);
+    }
+  }
+
+  private final Supplier<DataFileGroup> dataFileGroupSupplier;
+  private final Optional<DataFileGroupOverrider> overriderOptional;
+
+  /** Returns a Builder for SingleDataFileGroupPopulator. */
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  private SingleDataFileGroupPopulator(Builder builder) {
+    this.dataFileGroupSupplier = builder.dataFileGroupSupplier;
+    this.overriderOptional = builder.overriderOptional;
+  }
+
+  @Override
+  public ListenableFuture<Void> refreshFileGroups(MobileDataDownload mobileDataDownload) {
+    LogUtil.d("%s: Add file group to Mdd.", TAG);
+
+    // Override data file group if the overrider is present. If the overrider returns an absent
+    // data file group, nothing will be populated.
+    ListenableFuture<Optional<DataFileGroup>> dataFileGroupOptionalFuture =
+        Futures.immediateFuture(Optional.absent());
+    if (dataFileGroupSupplier.get() != null
+        && !dataFileGroupSupplier.get().getGroupName().isEmpty()) {
+      dataFileGroupOptionalFuture =
+          overriderOptional.isPresent()
+              ? overriderOptional.get().override(dataFileGroupSupplier.get())
+              : Futures.immediateFuture(Optional.of(dataFileGroupSupplier.get()));
+    }
+
+    ListenableFuture<Boolean> addFileGroupFuture =
+        Futures.transformAsync(
+            dataFileGroupOptionalFuture,
+            dataFileGroupOptional -> {
+              if (dataFileGroupOptional.isPresent()
+                  && !dataFileGroupOptional.get().getGroupName().isEmpty()) {
+                return mobileDataDownload.addFileGroup(
+                    AddFileGroupRequest.newBuilder()
+                        .setDataFileGroup(dataFileGroupOptional.get())
+                        .build());
+              }
+              LogUtil.d("%s: Not adding file group because of overrider.", TAG);
+              return Futures.immediateFuture(false);
+            },
+            MoreExecutors.directExecutor());
+
+    Futures.addCallback(
+        addFileGroupFuture,
+        new FutureCallback<Boolean>() {
+          @Override
+          public void onSuccess(Boolean result) {
+            String groupName = dataFileGroupSupplier.get().getGroupName();
+            if (result.booleanValue()) {
+              Log.d(TAG, "Added file group " + groupName);
+            } else {
+              Log.e(TAG, "Failed to add file group " + groupName);
+            }
+          }
+
+          @Override
+          public void onFailure(Throwable t) {
+            Log.e(TAG, "Failed to add file group", t);
+          }
+        },
+        MoreExecutors.directExecutor());
+
+    return Futures.whenAllComplete(addFileGroupFuture)
+        .call(() -> null, MoreExecutors.directExecutor());
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/populator/proto/Android.bp b/java/com/google/android/libraries/mobiledatadownload/populator/proto/Android.bp
new file mode 100644
index 0000000..342a337
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/populator/proto/Android.bp
@@ -0,0 +1,39 @@
+
+//
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+java_library {
+    name: "mobile-data-download-populator-java-proto-lite",
+    proto: {
+        type: "lite",
+        include_dirs: ["external/protobuf/src"],
+        canonical_path_from_root: false,
+        //local_include_dirs: ["proto/*"],
+    },
+    static_libs: [
+        "libprotobuf-java-lite",
+    ],
+    srcs: [
+        "**/*.proto",
+        ":libprotobuf-internal-protos"],
+    sdk_version: "current",
+    min_sdk_version: "30",
+    jarjar_rules: "jarjar-rules.txt",
+    apex_available: [
+        "//apex_available:platform",
+        "com.android.adservices",
+    ],
+}
\ No newline at end of file
diff --git a/java/com/google/android/libraries/mobiledatadownload/populator/proto/BUILD b/java/com/google/android/libraries/mobiledatadownload/populator/proto/BUILD
new file mode 100644
index 0000000..91be276
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/populator/proto/BUILD
@@ -0,0 +1,37 @@
+# Copyright 2022 Google LLC
+#
+# 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(
+    default_visibility = [
+        "//:__subpackages__",
+    ],
+    licenses = ["notice"],
+)
+
+proto_library(
+    name = "metadata_proto",
+    srcs = ["metadata.proto"],
+    cc_api_version = 2,
+    deps = [],
+    alwayslink = 1,
+)
+
+java_proto_library(
+    name = "metadata_java_proto",
+    deps = [":metadata_proto"],
+)
+
+java_lite_proto_library(
+    name = "metadata_java_proto_lite",
+    deps = [":metadata_proto"],
+)
diff --git a/java/com/google/android/libraries/mobiledatadownload/populator/proto/jarjar-rules.txt b/java/com/google/android/libraries/mobiledatadownload/populator/proto/jarjar-rules.txt
new file mode 100644
index 0000000..035f3f8
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/populator/proto/jarjar-rules.txt
@@ -0,0 +1,3 @@
+
+# Use our statically linked protobuf library
+# rule com.google.protobuf.** com.android.adservices.protobuf.@1
diff --git a/java/com/google/android/libraries/mobiledatadownload/populator/proto/metadata.proto b/java/com/google/android/libraries/mobiledatadownload/populator/proto/metadata.proto
new file mode 100644
index 0000000..8dd2177
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/populator/proto/metadata.proto
@@ -0,0 +1,46 @@
+// Copyright 2022 Google LLC
+//
+// 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.
+syntax = "proto2";
+
+package mdi.download.populator;
+
+option java_package = "com.google.mobiledatadownload.populator";
+option java_outer_classname = "MetadataProto";
+
+// Bookkeeps the metadata of manifest file.
+// Next tag: 4
+message ManifestFileBookkeeping {
+  // The url where the manifest file is served.
+  optional string manifest_file_url = 3;
+
+  // The cached ETag that is fetched from the target url and stored on device.
+  // This is used for content change detection.
+  optional string cached_etag = 1;
+
+  // The status of downloading the manifest file.
+  enum Status {
+    INVALID = 0;
+
+    // The file is absent or the download has started.
+    PENDING = 1;
+
+    // The download has completed but the file has not been parsed.
+    DOWNLOADED = 2;
+
+    // The file was parsed and file groups were added to MDD.
+    COMMITTED = 3;
+  }
+
+  optional Status status = 2;
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/tracing/BUILD b/java/com/google/android/libraries/mobiledatadownload/tracing/BUILD
new file mode 100644
index 0000000..18ae88e
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/tracing/BUILD
@@ -0,0 +1,43 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_library")
+
+package(
+    default_visibility = ["//:__subpackages__"],
+    licenses = ["notice"],
+)
+
+android_library(
+    name = "tracing",
+    srcs = [
+        "TracePropagation.java",
+    ],
+    deps = [
+        "@com_google_errorprone_error_prone_annotations",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "concurrent",
+    srcs = [
+        "PropagatedFluentFuture.java",
+        "PropagatedFluentFutures.java",
+        "PropagatedFutures.java",
+    ],
+    deps = [
+        "@com_google_guava_guava",
+        "@org_checkerframework_qual",
+    ],
+)
diff --git a/java/com/google/android/libraries/mobiledatadownload/tracing/PropagatedFluentFuture.java b/java/com/google/android/libraries/mobiledatadownload/tracing/PropagatedFluentFuture.java
new file mode 100644
index 0000000..6df6e49
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/tracing/PropagatedFluentFuture.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.tracing;
+
+import com.google.common.base.Function;
+import com.google.common.util.concurrent.AsyncFunction;
+import com.google.common.util.concurrent.ForwardingListenableFuture.SimpleForwardingListenableFuture;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/**
+ * Similar to {@link com.google.common.util.concurrent.FluentFuture}, but with trace propagation.
+ * Note that the {@link ListenableFuture#addListener(Runnable, Executor)} method <b>does not</b>
+ * propagate traces.
+ */
+public final class PropagatedFluentFuture<V extends @Nullable Object>
+    extends SimpleForwardingListenableFuture<V> {
+
+  private PropagatedFluentFuture(ListenableFuture<V> delegate) {
+    super(delegate);
+  }
+
+  /** See {@link com.google.common.util.concurrent.FluentFuture#from(ListenableFuture)}. */
+  public static <V extends @Nullable Object> PropagatedFluentFuture<V> from(
+      ListenableFuture<V> future) {
+    return future instanceof PropagatedFluentFuture
+        ? (PropagatedFluentFuture<V>) future
+        : new PropagatedFluentFuture<>(future);
+  }
+
+  /**
+   * See {@link com.google.common.util.concurrent.FluentFuture#catching(Class, Function, Executor)}.
+   */
+  public final <X extends Throwable> PropagatedFluentFuture<V> catching(
+      Class<X> exceptionType, Function<? super X, ? extends V> fallback, Executor executor) {
+    return new PropagatedFluentFuture<>(
+        PropagatedFutures.catching(delegate(), exceptionType, fallback, executor));
+  }
+
+  /**
+   * See {@link com.google.common.util.concurrent.FluentFuture#catchingAsync(Class, AsyncFunction,
+   * Executor)}.
+   */
+  public final <X extends Throwable> PropagatedFluentFuture<V> catchingAsync(
+      Class<X> exceptionType, AsyncFunction<? super X, ? extends V> fallback, Executor executor) {
+    return new PropagatedFluentFuture<>(
+        PropagatedFutures.catchingAsync(delegate(), exceptionType, fallback, executor));
+  }
+
+  /**
+   * See {@link com.google.common.util.concurrent.FluentFuture#withTimeout(long, TimeUnit,
+   * ScheduledExecutorService)}.
+   */
+  public final PropagatedFluentFuture<V> withTimeout(
+      long timeout, TimeUnit unit, ScheduledExecutorService scheduledExecutor) {
+    return new PropagatedFluentFuture<>(
+        Futures.withTimeout(delegate(), timeout, unit, scheduledExecutor));
+  }
+
+  /**
+   * See {@link com.google.common.util.concurrent.FluentFuture#transformAsync(AsyncFunction,
+   * Executor)}.
+   */
+  public final <T extends @Nullable Object> PropagatedFluentFuture<T> transformAsync(
+      AsyncFunction<? super V, T> function, Executor executor) {
+    return new PropagatedFluentFuture<>(
+        PropagatedFutures.transformAsync(delegate(), function, executor));
+  }
+
+  /** See {@link com.google.common.util.concurrent.FluentFuture#transform(Function, Executor)}. */
+  public final <T extends @Nullable Object> PropagatedFluentFuture<T> transform(
+      Function<? super V, T> function, Executor executor) {
+    return new PropagatedFluentFuture<>(
+        PropagatedFutures.transform(delegate(), function, executor));
+  }
+
+  /**
+   * See {@link com.google.common.util.concurrent.FluentFuture#addCallback(FutureCallback,
+   * Executor)}.
+   */
+  public final void addCallback(FutureCallback<? super V> callback, Executor executor) {
+    PropagatedFutures.addCallback(delegate(), callback, executor);
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/tracing/PropagatedFluentFutures.java b/java/com/google/android/libraries/mobiledatadownload/tracing/PropagatedFluentFutures.java
new file mode 100644
index 0000000..a94edb2
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/tracing/PropagatedFluentFutures.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.tracing;
+
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import java.util.List;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/**
+ * Wrapper around {@link Futures} and {@link PropagatedFutures} that returns {@link
+ * PropagatedFluentFuture}.
+ */
+public final class PropagatedFluentFutures {
+
+  private PropagatedFluentFutures() {}
+
+  /**
+   * @see Futures#allAsList(ListenableFuture[])
+   */
+  public static <T extends @Nullable Object> PropagatedFluentFuture<List<T>> allAsList(
+      ListenableFuture<? extends T>... futures) {
+    return PropagatedFluentFuture.from(Futures.allAsList(futures));
+  }
+
+  /** See {@link Futures#successfulAsList(ListenableFuture[])}. */
+  public static <T extends @Nullable Object>
+      PropagatedFluentFuture<List<@Nullable T>> successfulAsList(
+          ListenableFuture<? extends T>... futures) {
+    return PropagatedFluentFuture.from(Futures.successfulAsList(futures));
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/tracing/PropagatedFutures.java b/java/com/google/android/libraries/mobiledatadownload/tracing/PropagatedFutures.java
new file mode 100644
index 0000000..286dd20
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/tracing/PropagatedFutures.java
@@ -0,0 +1,134 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.tracing;
+
+import com.google.common.base.Function;
+import com.google.common.util.concurrent.AsyncCallable;
+import com.google.common.util.concurrent.AsyncFunction;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.Futures.FutureCombiner;
+import com.google.common.util.concurrent.ListenableFuture;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/** Wrapper around {@link Futures}. */
+public final class PropagatedFutures {
+
+  private PropagatedFutures() {}
+
+  public static <I extends @Nullable Object, O extends @Nullable Object>
+      ListenableFuture<O> transformAsync(
+          ListenableFuture<I> input,
+          AsyncFunction<? super I, ? extends O> function,
+          Executor executor) {
+    return Futures.transformAsync(input, function, executor);
+  }
+
+  public static <I extends @Nullable Object, O extends @Nullable Object>
+      ListenableFuture<O> transform(
+          ListenableFuture<I> input, Function<? super I, ? extends O> function, Executor executor) {
+    return Futures.transform(input, function, executor);
+  }
+
+  public static <V extends @Nullable Object> void addCallback(
+      ListenableFuture<V> future, FutureCallback<? super V> callback, Executor executor) {
+    Futures.addCallback(future, callback, executor);
+  }
+
+  public static <V extends @Nullable Object, X extends Throwable> ListenableFuture<V> catching(
+      ListenableFuture<? extends V> input,
+      Class<X> exceptionType,
+      Function<? super X, ? extends V> fallback,
+      Executor executor) {
+    return Futures.catching(input, exceptionType, fallback, executor);
+  }
+
+  public static <V extends @Nullable Object, X extends Throwable> ListenableFuture<V> catchingAsync(
+      ListenableFuture<? extends V> input,
+      Class<X> exceptionType,
+      AsyncFunction<? super X, ? extends V> fallback,
+      Executor executor) {
+    return Futures.catchingAsync(input, exceptionType, fallback, executor);
+  }
+
+  public static <V extends @Nullable Object> ListenableFuture<V> submit(
+      Callable<V> callable, Executor executor) {
+    return Futures.submit(callable, executor);
+  }
+
+  public static ListenableFuture<@Nullable Void> submit(Runnable runnable, Executor executor) {
+    return Futures.submit(runnable, executor);
+  }
+
+  public static <V extends @Nullable Object> ListenableFuture<V> submitAsync(
+      AsyncCallable<V> callable, Executor executor) {
+    return Futures.submitAsync(callable, executor);
+  }
+
+  public static <V extends @Nullable Object> ListenableFuture<V> scheduleAsync(
+      AsyncCallable<V> callable, long delay, TimeUnit timeUnit, ScheduledExecutorService executor) {
+    return Futures.scheduleAsync(callable, delay, timeUnit, executor);
+  }
+
+  @SafeVarargs
+  public static <V extends @Nullable Object> PropagatedFutureCombiner<V> whenAllComplete(
+      ListenableFuture<? extends V>... futures) {
+    return new PropagatedFutureCombiner<>(Futures.whenAllComplete(futures));
+  }
+
+  public static <V extends @Nullable Object> PropagatedFutureCombiner<V> whenAllComplete(
+      Iterable<? extends ListenableFuture<? extends V>> futures) {
+    return new PropagatedFutureCombiner<>(Futures.whenAllComplete(futures));
+  }
+
+  @SafeVarargs
+  public static <V extends @Nullable Object> PropagatedFutureCombiner<V> whenAllSucceed(
+      ListenableFuture<? extends V>... futures) {
+    return new PropagatedFutureCombiner<>(Futures.whenAllSucceed(futures));
+  }
+
+  public static <V extends @Nullable Object> PropagatedFutureCombiner<V> whenAllSucceed(
+      Iterable<? extends ListenableFuture<? extends V>> futures) {
+    return new PropagatedFutureCombiner<>(Futures.whenAllSucceed(futures));
+  }
+
+  /** Wrapper around {@link FutureCombiner}. */
+  public static final class PropagatedFutureCombiner<V extends @Nullable Object> {
+    private final FutureCombiner<V> futureCombiner;
+
+    private PropagatedFutureCombiner(FutureCombiner<V> futureCombiner) {
+      this.futureCombiner = futureCombiner;
+    }
+
+    public <C extends @Nullable Object> ListenableFuture<C> callAsync(
+        AsyncCallable<C> combiner, Executor executor) {
+      return futureCombiner.callAsync(combiner, executor);
+    }
+
+    public <C extends @Nullable Object> ListenableFuture<C> call(
+        Callable<C> combiner, Executor executor) {
+      return futureCombiner.call(combiner, executor);
+    }
+
+    public ListenableFuture<?> run(final Runnable combiner, Executor executor) {
+      return futureCombiner.run(combiner, executor);
+    }
+  }
+}
diff --git a/java/com/google/android/libraries/mobiledatadownload/tracing/TracePropagation.java b/java/com/google/android/libraries/mobiledatadownload/tracing/TracePropagation.java
new file mode 100644
index 0000000..7c1ee13
--- /dev/null
+++ b/java/com/google/android/libraries/mobiledatadownload/tracing/TracePropagation.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.tracing;
+
+import com.google.common.base.Function;
+import com.google.common.util.concurrent.AsyncCallable;
+import com.google.common.util.concurrent.AsyncFunction;
+import com.google.common.util.concurrent.ClosingFuture.ClosingFunction;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.errorprone.annotations.CheckReturnValue;
+import java.util.concurrent.Callable;
+
+/**
+ * Contains decorators for propagating the current trace into a callback. When the wrapped callback
+ * runs, it will run under the same trace the wrapper was created in.
+ */
+@CheckReturnValue
+public final class TracePropagation {
+
+  @CheckReturnValue
+  public static <V> AsyncCallable<V> propagateAsyncCallable(final AsyncCallable<V> asyncCallable) {
+    return asyncCallable;
+  }
+
+  @CheckReturnValue
+  public static <I, O> AsyncFunction<I, O> propagateAsyncFunction(
+      final AsyncFunction<I, O> function) {
+    return function;
+  }
+
+  @CheckReturnValue
+  public static <V> Callable<V> propagateCallable(final Callable<V> callable) {
+    return callable;
+  }
+
+  @CheckReturnValue
+  public static <I, O> Function<I, O> propagateFunction(final Function<I, O> function) {
+    return function;
+  }
+
+  @CheckReturnValue
+  public static <T> FutureCallback<T> propagateFutureCallback(final FutureCallback<T> callback) {
+    return callback;
+  }
+
+  public static <I, O> ClosingFunction<I, O> propagateClosingFunction(
+      final ClosingFunction<I, O> closingFunction) {
+    return closingFunction;
+  }
+
+  private TracePropagation() {}
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/AggregateExceptionTest.java b/javatests/com/google/android/libraries/mobiledatadownload/AggregateExceptionTest.java
new file mode 100644
index 0000000..4ecec0f
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/AggregateExceptionTest.java
@@ -0,0 +1,179 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.util.concurrent.Futures.immediateCancelledFuture;
+import static com.google.common.util.concurrent.Futures.immediateFailedFuture;
+import static com.google.common.util.concurrent.Futures.immediateVoidFuture;
+import static org.junit.Assert.assertThrows;
+
+import com.google.android.libraries.mobiledatadownload.DownloadException.DownloadResultCode;
+import com.google.common.base.Optional;
+import com.google.common.collect.ImmutableList;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.ListenableFuture;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+/** Tests for {@link AggregateException}. */
+@RunWith(RobolectricTestRunner.class)
+public class AggregateExceptionTest {
+  @Test
+  public void unwrapException_recursivelyUnwrapExecutionException() {
+    DownloadException downloadException =
+        DownloadException.builder().setDownloadResultCode(DownloadResultCode.UNKNOWN_ERROR).build();
+    ExecutionException executionException1 = new ExecutionException(downloadException);
+    ExecutionException executionException2 = new ExecutionException(executionException1);
+    ExecutionException executionException3 = new ExecutionException(executionException2);
+
+    Throwable throwable = AggregateException.unwrapException(executionException3);
+    assertThat(throwable).isEqualTo(downloadException);
+    assertThat(throwable).hasMessageThat().contains("UNKNOWN_ERROR");
+    assertThat(throwable).hasCauseThat().isNull();
+  }
+
+  @Test
+  public void unwrapException_keepOtherExceptions() {
+    NullPointerException nullPointerException = new NullPointerException("NPE");
+    DownloadException downloadException =
+        DownloadException.builder()
+            .setDownloadResultCode(DownloadResultCode.UNKNOWN_ERROR)
+            .setCause(nullPointerException)
+            .build();
+
+    Throwable throwable = AggregateException.unwrapException(downloadException);
+    assertThat(throwable).isEqualTo(downloadException);
+    assertThat(throwable).hasMessageThat().contains("UNKNOWN_ERROR");
+    assertThat(throwable).hasCauseThat().isEqualTo(nullPointerException);
+  }
+
+  @Test
+  public void throwableToString_maxCauseDepthCutoff() {
+    Throwable throwable =
+        new IOException(
+            "One",
+            new IOException(
+                "Two",
+                new IOException(
+                    "Three",
+                    new IOException("Four", new IOException("Five", new IOException("Six"))))));
+    assertThat(AggregateException.throwableToString(throwable))
+        .isEqualTo(
+            "java.io.IOException: One"
+                + "\nCaused by: java.io.IOException: Two"
+                + "\nCaused by: java.io.IOException: Three"
+                + "\nCaused by: java.io.IOException: Four"
+                + "\nCaused by: java.io.IOException: Five"
+                + "\n(...)");
+  }
+
+  @Test
+  public void throwIfFailed_multipleExceptions() {
+    List<ListenableFuture<Void>> futures = new ArrayList<>();
+
+    // First 10 futures failed.
+    for (int i = 0; i < 10; i++) {
+      futures.add(
+          immediateFailedFuture(
+              DownloadException.builder()
+                  .setDownloadResultCode(DownloadResultCode.UNKNOWN_ERROR)
+                  .setMessage(String.format("ERROR_#%d", i + 1))
+                  .build()));
+    }
+    // Next 10 futures are canceled.
+    for (int i = 0; i < 10; i++) {
+      futures.add(immediateCancelledFuture());
+    }
+    // The remaining 10 futures succeeded.
+    for (int i = 0; i < 10; i++) {
+      futures.add(immediateVoidFuture());
+    }
+
+    AggregateException exception =
+        assertThrows(
+            AggregateException.class, () -> AggregateException.throwIfFailed(futures, "Failed"));
+    ImmutableList<Throwable> failures = exception.getFailures();
+    assertThat(failures).hasSize(20);
+
+    for (int i = 0; i < 10; i++) {
+      assertThat(failures.get(i)).isNotNull();
+      assertThat(failures.get(i)).isInstanceOf(DownloadException.class);
+      assertThat(failures.get(i)).hasMessageThat().contains("ERROR");
+    }
+    for (int i = 10; i < 20; i++) {
+      assertThat(failures.get(i)).isNotNull();
+      assertThat(failures.get(i)).isInstanceOf(CancellationException.class);
+    }
+  }
+
+  @Test
+  public void throwIfFailed_withCallback_invokesCallback() throws Exception {
+    List<ListenableFuture<Void>> futures = new ArrayList<>();
+
+    // First 10 futures failed.
+    for (int i = 0; i < 10; i++) {
+      futures.add(
+          immediateFailedFuture(
+              DownloadException.builder()
+                  .setDownloadResultCode(DownloadResultCode.UNKNOWN_ERROR)
+                  .setMessage(String.format("ERROR_#%d", i + 1))
+                  .build()));
+    }
+    // Next 10 futures are canceled.
+    for (int i = 0; i < 10; i++) {
+      futures.add(immediateCancelledFuture());
+    }
+    // The remaining 10 futures succeeded.
+    for (int i = 0; i < 10; i++) {
+      futures.add(immediateVoidFuture());
+    }
+
+    AtomicInteger successCount = new AtomicInteger(0);
+    AtomicInteger failureCount = new AtomicInteger(0);
+    AggregateException exception =
+        assertThrows(
+            AggregateException.class,
+            () ->
+                AggregateException.throwIfFailed(
+                    futures,
+                    Optional.of(
+                        new FutureCallback<Void>() {
+                          @Override
+                          public void onSuccess(Void unused) {
+                            successCount.getAndIncrement();
+                          }
+
+                          @Override
+                          public void onFailure(Throwable t) {
+                            failureCount.getAndIncrement();
+                          }
+                        }),
+                    "Failed"));
+
+    // Make sure that onSuccess is called 10 times, and onFailure is called 20 times.
+    assertThat(exception.getFailures()).hasSize(20);
+    assertThat(successCount.get()).isEqualTo(10);
+    assertThat(failureCount.get()).isEqualTo(20);
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/AndroidManifest.xml b/javatests/com/google/android/libraries/mobiledatadownload/AndroidManifest.xml
new file mode 100644
index 0000000..945f71c
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/AndroidManifest.xml
@@ -0,0 +1,34 @@
+<!--
+/*
+ * Copyright 2022 Google LLC
+ *
+ * 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.
+ */
+-->
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+  package="com.google.android.libraries.mobiledatadownload" >
+
+  <uses-sdk android:minSdkVersion="16" />
+  <uses-permission android:name="android.permission.INTERNET"/>
+  <uses-permission
+    android:name="com.google.android.gms.permission.BIND_NETWORK_TASK_SERVICE"/>
+  <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
+
+  <application android:name="android.support.multidex.MultiDexApplication">
+  </application>
+
+  <instrumentation
+    android:name="com.google.android.apps.common.testing.testrunner.Google3InstrumentationTestRunner"
+    android:targetPackage="com.google.android.libraries.mobiledatadownload" />
+</manifest>
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/BUILD b/javatests/com/google/android/libraries/mobiledatadownload/BUILD
new file mode 100644
index 0000000..c80ff58
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/BUILD
@@ -0,0 +1,379 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("//tools/build_defs/testing:bzl_library.bzl", "bzl_library")
+load("//javatests/com/google/android/libraries/mobiledatadownload:test_defs.bzl", "mdd_android_test", "mdd_local_test")
+load("@build_bazel_rules_android//android:rules.bzl", "android_local_test")
+
+package(
+    default_visibility = ["//:__subpackages__"],
+    licenses = ["notice"],
+)
+
+mdd_local_test(
+    name = "MobileDataDownloadTest",
+    srcs = ["MobileDataDownloadTest.java"],
+    test_class = "com.google.android.libraries.mobiledatadownload.MobileDataDownloadTest",
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload",
+        "//java/com/google/android/libraries/mobiledatadownload:DownloadException",
+        "//java/com/google/android/libraries/mobiledatadownload:DownloadListener",
+        "//java/com/google/android/libraries/mobiledatadownload:FileSource",
+        "//java/com/google/android/libraries/mobiledatadownload:Flags",
+        "//java/com/google/android/libraries/mobiledatadownload:UsageEvent",
+        "//java/com/google/android/libraries/mobiledatadownload/account:AccountUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:android",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:stream",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:MobileDataDownloadManager",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:EventLogger",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:ProtoConversionUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/lite",
+        "//java/com/google/android/libraries/mobiledatadownload/monitor:DownloadProgressMonitor",
+        "//proto:client_config_java_proto_lite",
+        "//proto:download_config_java_proto_lite",
+        "@androidx_test",
+        "@com_google_guava_guava",
+        "@com_google_protobuf//:any_proto",
+        "@com_google_protobuf//:protobuf_lite",
+        "@com_google_protobuf//:wrappers_proto",
+        "@mockito",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "AggregateExceptionTest",
+    srcs = ["AggregateExceptionTest.java"],
+    manifest_values = {
+        "minSdkVersion": "16",
+        "targetSdkVersion": "27",
+    },
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload:AggregateException",
+        "//java/com/google/android/libraries/mobiledatadownload:DownloadException",
+        "@com_google_guava_guava",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "DownloadExceptionTest",
+    srcs = ["DownloadExceptionTest.java"],
+    manifest_values = {
+        "minSdkVersion": "16",
+        "targetSdkVersion": "27",
+    },
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload:DownloadException",
+        "@com_google_guava_guava",
+        "@truth",
+    ],
+)
+
+mdd_android_test(
+    name = "MobileDataDownloadIntegrationTest",
+    size = "large",
+    srcs = [
+        "MobileDataDownloadIntegrationTest.java",
+        "TestFileGroupPopulator.java",
+        "TwoStepPopulator.java",
+        "ZipFolderFileGroupPopulator.java",
+    ],
+    data = [
+        "//javatests/com/google/android/libraries/mobiledatadownload/testdata:integration_test_data_files",
+    ],
+    manifest = "//javatests/com/google/android/libraries/mobiledatadownload/testing:AndroidManifest.xml",
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload",
+        "//java/com/google/android/libraries/mobiledatadownload:AggregateException",
+        "//java/com/google/android/libraries/mobiledatadownload:DownloadException",
+        "//java/com/google/android/libraries/mobiledatadownload:Flags",
+        "//java/com/google/android/libraries/mobiledatadownload:Logger",
+        "//java/com/google/android/libraries/mobiledatadownload:MobileDataDownloadBuilder",
+        "//java/com/google/android/libraries/mobiledatadownload/account:AccountUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/downloader:FileDownloader",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:android",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:android_adapter",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:string",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LogUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:DirectoryUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/monitor:NetworkUsageMonitor",
+        "//java/com/google/android/libraries/mobiledatadownload/tracing",
+        "//javatests/com/google/android/libraries/mobiledatadownload/testing:BlockingFileDownloader",
+        "//javatests/com/google/android/libraries/mobiledatadownload/testing:FakeTimeSource",
+        "//javatests/com/google/android/libraries/mobiledatadownload/testing:TestFileDownloader",
+        "//javatests/com/google/android/libraries/mobiledatadownload/testing:TestFlags",
+        "//proto:client_config_java_proto_lite",
+        "//proto:download_config_java_proto_lite",
+        "//proto:transform_java_proto_lite",
+        "@android_sdk_linux",
+        "@androidx_core_core",
+        "@androidx_test",
+        "@com_google_guava_guava",
+        "@cronet-api",
+        "@junit",
+        "@mockito",
+        "@truth",
+    ],
+)
+
+mdd_android_test(
+    name = "DownloadFileGroupIntegrationTest",
+    size = "large",
+    srcs = [
+        "DownloadFileGroupIntegrationTest.java",
+        "TestFileGroupPopulator.java",
+    ],
+    manifest = "AndroidManifest.xml",
+    tags = ["requires-net:external"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload",
+        "//java/com/google/android/libraries/mobiledatadownload:AggregateException",
+        "//java/com/google/android/libraries/mobiledatadownload:DownloadException",
+        "//java/com/google/android/libraries/mobiledatadownload:DownloadListener",
+        "//java/com/google/android/libraries/mobiledatadownload:Flags",
+        "//java/com/google/android/libraries/mobiledatadownload:MobileDataDownloadBuilder",
+        "//java/com/google/android/libraries/mobiledatadownload/downloader:FileDownloader",
+        "//java/com/google/android/libraries/mobiledatadownload/downloader/offroad/dagger/downloader2:base",
+        "//java/com/google/android/libraries/mobiledatadownload/downloader/offroad/dagger/downloader2:base_deps",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:android",
+        "//java/com/google/android/libraries/mobiledatadownload/file/integration/downloader:downloader2_sp",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:compress",
+        "//java/com/google/android/libraries/mobiledatadownload/monitor:DownloadProgressMonitor",
+        "//java/com/google/android/libraries/mobiledatadownload/monitor:NetworkUsageMonitor",
+        "//javatests/com/google/android/libraries/mobiledatadownload/testing:BlockingFileDownloader",
+        "//javatests/com/google/android/libraries/mobiledatadownload/testing:TestFlags",
+        "//proto:client_config_java_proto_lite",
+        "//proto:download_config_java_proto_lite",
+        "@android_sdk_linux",
+        "@androidx_test",
+        "@com_google_guava_guava",
+        "@cronet-api",
+        "@junit",
+        "@mockito",
+        "@truth",
+    ],
+)
+
+mdd_android_test(
+    name = "DownloadFileGroupAndroidSharingIntegrationTest",
+    size = "large",
+    timeout = "long",
+    srcs = [
+        "DownloadFileGroupAndroidSharingIntegrationTest.java",
+        "TestFileGroupPopulator.java",
+    ],
+    data = [
+        "//javatests/com/google/android/libraries/mobiledatadownload/testdata:integration_test_data_files",
+    ],
+    manifest = "//javatests/com/google/android/libraries/mobiledatadownload/testing:AndroidManifest.xml",
+    target_devices = ["//tools/android/emulated_devices/generic_phone:google_30_x86"],  # Blob Sharing available in R+
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload",
+        "//java/com/google/android/libraries/mobiledatadownload:DownloadListener",
+        "//java/com/google/android/libraries/mobiledatadownload:Flags",
+        "//java/com/google/android/libraries/mobiledatadownload:Logger",
+        "//java/com/google/android/libraries/mobiledatadownload:MobileDataDownloadBuilder",
+        "//java/com/google/android/libraries/mobiledatadownload/downloader:FileDownloader",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:android",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:blob_uri",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:blobstore_backend",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:compress",
+        "//java/com/google/android/libraries/mobiledatadownload/monitor:DownloadProgressMonitor",
+        "//java/com/google/android/libraries/mobiledatadownload/monitor:NetworkUsageMonitor",
+        "//javatests/com/google/android/libraries/mobiledatadownload/testing:TestFileDownloader",
+        "//javatests/com/google/android/libraries/mobiledatadownload/testing:TestFlags",
+        "//proto:client_config_java_proto_lite",
+        "//proto:download_config_java_proto_lite",
+        "@android_sdk_linux",
+        "@androidx_test",
+        "@com_google_guava_guava",
+        "@junit",
+        "@mockito",
+        "@truth",
+    ],
+)
+
+mdd_local_test(
+    name = "DownloadFileTest",
+    srcs = ["DownloadFileTest.java"],
+    test_class = "com.google.android.libraries.mobiledatadownload.DownloadFileTest",
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload",
+        "//java/com/google/android/libraries/mobiledatadownload:DownloadException",
+        "//java/com/google/android/libraries/mobiledatadownload:DownloadListener",
+        "//java/com/google/android/libraries/mobiledatadownload:MobileDataDownloadBuilder",
+        "//java/com/google/android/libraries/mobiledatadownload/downloader:FileDownloader",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:android",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LogUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/lite:DownloadListener",
+        "//java/com/google/android/libraries/mobiledatadownload/monitor:DownloadProgressMonitor",
+        "//java/com/google/android/libraries/mobiledatadownload/monitor:NetworkUsageMonitor",
+        "//javatests/com/google/android/libraries/mobiledatadownload/testing:BlockingFileDownloader",
+        "//javatests/com/google/android/libraries/mobiledatadownload/testing:FakeTimeSource",
+        "//javatests/com/google/android/libraries/mobiledatadownload/testing:TestFlags",
+        "@androidx_test",
+        "@com_google_guava_guava",
+        "@cronet-api",
+        "@mockito",
+        "@truth",
+    ],
+)
+
+mdd_android_test(
+    name = "DownloadFileIntegrationTest",
+    size = "large",
+    srcs = [
+        "DownloadFileIntegrationTest.java",
+    ],
+    manifest = "AndroidManifest.xml",
+    tags = ["requires-net:external"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload",
+        "//java/com/google/android/libraries/mobiledatadownload:AggregateException",
+        "//java/com/google/android/libraries/mobiledatadownload:DownloadException",
+        "//java/com/google/android/libraries/mobiledatadownload:DownloadListener",
+        "//java/com/google/android/libraries/mobiledatadownload:Flags",
+        "//java/com/google/android/libraries/mobiledatadownload:MobileDataDownloadBuilder",
+        "//java/com/google/android/libraries/mobiledatadownload/downloader:FileDownloader",
+        "//java/com/google/android/libraries/mobiledatadownload/downloader/offroad/dagger/downloader2:base",
+        "//java/com/google/android/libraries/mobiledatadownload/downloader/offroad/dagger/downloader2:base_deps",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:android",
+        "//java/com/google/android/libraries/mobiledatadownload/file/integration/downloader:downloader2_sp",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:compress",
+        "//java/com/google/android/libraries/mobiledatadownload/monitor:DownloadProgressMonitor",
+        "//java/com/google/android/libraries/mobiledatadownload/monitor:NetworkUsageMonitor",
+        "//javatests/com/google/android/libraries/mobiledatadownload/testing:BlockingFileDownloader",
+        "//javatests/com/google/android/libraries/mobiledatadownload/testing:FakeTimeSource",
+        "//javatests/com/google/android/libraries/mobiledatadownload/testing:TestFlags",
+        "//proto:client_config_java_proto_lite",
+        "//proto:download_config_java_proto_lite",
+        "@android_sdk_linux",
+        "@androidx_test",
+        "@com_google_guava_guava",
+        "@cronet-api",
+        "@junit",
+        "@mockito",
+        "@truth",
+    ],
+)
+
+mdd_android_test(
+    name = "ImportFilesIntegrationTest",
+    size = "large",
+    srcs = [
+        "ImportFilesIntegrationTest.java",
+        "TestFileGroupPopulator.java",
+    ],
+    data = [
+        "//javatests/com/google/android/libraries/mobiledatadownload/testdata:integration_test_data_files",
+    ],
+    manifest = "//javatests/com/google/android/libraries/mobiledatadownload/testing:AndroidManifest.xml",
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload",
+        "//java/com/google/android/libraries/mobiledatadownload:AggregateException",
+        "//java/com/google/android/libraries/mobiledatadownload:DownloadException",
+        "//java/com/google/android/libraries/mobiledatadownload:FileSource",
+        "//java/com/google/android/libraries/mobiledatadownload:Flags",
+        "//java/com/google/android/libraries/mobiledatadownload:MobileDataDownloadBuilder",
+        "//java/com/google/android/libraries/mobiledatadownload/downloader:FileDownloader",
+        "//java/com/google/android/libraries/mobiledatadownload/downloader/inline:InlineFileDownloader",
+        "//java/com/google/android/libraries/mobiledatadownload/downloader/offroad/dagger/downloader2:base",
+        "//java/com/google/android/libraries/mobiledatadownload/downloader/offroad/dagger/downloader2:base_deps",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:android",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing:fake_file_backend",
+        "//java/com/google/android/libraries/mobiledatadownload/file/integration/downloader:downloader2_sp",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:stream",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:compress",
+        "//java/com/google/android/libraries/mobiledatadownload/monitor:DownloadProgressMonitor",
+        "//java/com/google/android/libraries/mobiledatadownload/monitor:NetworkUsageMonitor",
+        "//javatests/com/google/android/libraries/mobiledatadownload/testing:BlockingFileDownloader",
+        "//javatests/com/google/android/libraries/mobiledatadownload/testing:TestFlags",
+        "//proto:client_config_java_proto_lite",
+        "//proto:download_config_java_proto_lite",
+        "@android_sdk_linux",
+        "@androidx_test",
+        "@com_google_guava_guava",
+        "@com_google_protobuf//:protobuf_lite",
+        "@cronet-api",
+        "@junit",
+        "@mockito",
+        "@truth",
+    ],
+)
+
+mdd_android_test(
+    name = "MddGarbageCollectionWithAndroidSharingIntegrationTest",
+    size = "large",
+    timeout = "long",
+    srcs = [
+        "MddGarbageCollectionWithAndroidSharingIntegrationTest.java",
+        "TestFileGroupPopulator.java",
+    ],
+    data = [
+        "//javatests/com/google/android/libraries/mobiledatadownload/testdata:integration_test_data_files",
+    ],
+    manifest = "//javatests/com/google/android/libraries/mobiledatadownload/testing:AndroidManifest.xml",
+    target_devices = ["//tools/android/emulated_devices/generic_phone:google_30_x86"],  # Blob Sharing available in R+
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload",
+        "//java/com/google/android/libraries/mobiledatadownload:DownloadListener",
+        "//java/com/google/android/libraries/mobiledatadownload:Flags",
+        "//java/com/google/android/libraries/mobiledatadownload:Logger",
+        "//java/com/google/android/libraries/mobiledatadownload:MobileDataDownloadBuilder",
+        "//java/com/google/android/libraries/mobiledatadownload/downloader:FileDownloader",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:android",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:blob_uri",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:blobstore_backend",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:compress",
+        "//java/com/google/android/libraries/mobiledatadownload/monitor:DownloadProgressMonitor",
+        "//java/com/google/android/libraries/mobiledatadownload/monitor:NetworkUsageMonitor",
+        "//javatests/com/google/android/libraries/mobiledatadownload/internal:MddTestUtil",
+        "//javatests/com/google/android/libraries/mobiledatadownload/testing:TestFileDownloader",
+        "//javatests/com/google/android/libraries/mobiledatadownload/testing:TestFlags",
+        "//proto:client_config_java_proto_lite",
+        "//proto:download_config_java_proto_lite",
+        "@android_sdk_linux",
+        "@androidx_test",
+        "@com_google_guava_guava",
+        "@junit",
+        "@mockito",
+        "@truth",
+    ],
+)
+
+bzl_library(
+    name = "test_defs_bzl",
+    srcs = ["test_defs.bzl"],
+    parse_tests = False,
+    deps = [
+        "//devtools/build_cleaner/skylark:build_defs_lib",
+        "//devtools/deps/check:deps_check",
+        "//tools/build_defs/android:rules_bzl",
+    ],
+)
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/DownloadExceptionTest.java b/javatests/com/google/android/libraries/mobiledatadownload/DownloadExceptionTest.java
new file mode 100644
index 0000000..2ed1c6e
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/DownloadExceptionTest.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+import static com.google.common.labs.truth.FutureSubject.assertThat;
+import static com.google.common.util.concurrent.Futures.immediateFailedFuture;
+import static com.google.common.util.concurrent.Futures.immediateFuture;
+
+import com.google.android.libraries.mobiledatadownload.DownloadException.DownloadResultCode;
+import com.google.common.util.concurrent.ListenableFuture;
+import java.io.IOException;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+/** Tests for {@link DownloadException}. */
+@RunWith(RobolectricTestRunner.class)
+public final class DownloadExceptionTest {
+
+  @Test
+  public void wrapIfFailed_futureFails_wrapsFailedFutureWithDownloadException() throws Exception {
+    ListenableFuture<Void> future = immediateFailedFuture(new IOException("cause"));
+    ListenableFuture<Void> wrappedFuture =
+        DownloadException.wrapIfFailed(
+            future, DownloadResultCode.ANDROID_DOWNLOADER_HTTP_ERROR, "failed");
+    assertThat(wrappedFuture).whenDone().isFailedWith(DownloadException.class);
+    assertThat(wrappedFuture).whenDone().hasFailureThat().hasMessageThat().isEqualTo("failed");
+    assertThat(wrappedFuture)
+        .whenDone()
+        .hasFailureThat()
+        .hasCauseThat()
+        .isInstanceOf(IOException.class);
+    assertThat(wrappedFuture)
+        .whenDone()
+        .hasFailureThat()
+        .hasCauseThat()
+        .hasMessageThat()
+        .isEqualTo("cause");
+  }
+
+  @Test
+  public void wrapIfFailed_futureSucceeds_noop() throws Exception {
+    ListenableFuture<Integer> future = immediateFuture(7);
+    ListenableFuture<Integer> wrappedFuture =
+        DownloadException.wrapIfFailed(
+            future, DownloadResultCode.ANDROID_DOWNLOADER_HTTP_ERROR, "failed");
+    assertThat(wrappedFuture).whenDone().hasValue(7);
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/DownloadFileGroupAndroidSharingIntegrationTest.java b/javatests/com/google/android/libraries/mobiledatadownload/DownloadFileGroupAndroidSharingIntegrationTest.java
new file mode 100644
index 0000000..503d573
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/DownloadFileGroupAndroidSharingIntegrationTest.java
@@ -0,0 +1,622 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+import static com.google.android.libraries.mobiledatadownload.TestFileGroupPopulator.FILE_CHECKSUM;
+import static com.google.android.libraries.mobiledatadownload.TestFileGroupPopulator.FILE_ID;
+import static com.google.android.libraries.mobiledatadownload.TestFileGroupPopulator.FILE_SIZE;
+import static com.google.android.libraries.mobiledatadownload.TestFileGroupPopulator.FILE_URL;
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import android.app.blob.BlobStoreManager;
+import android.content.Context;
+import android.net.Uri;
+import android.util.Log;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.android.libraries.mobiledatadownload.downloader.FileDownloader;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.AndroidFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.backends.BlobStoreBackend;
+import com.google.android.libraries.mobiledatadownload.file.backends.BlobUri;
+import com.google.android.libraries.mobiledatadownload.file.backends.JavaFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.transforms.CompressTransform;
+import com.google.android.libraries.mobiledatadownload.monitor.DownloadProgressMonitor;
+import com.google.android.libraries.mobiledatadownload.monitor.NetworkUsageMonitor;
+import com.google.android.libraries.mobiledatadownload.testing.TestFileDownloader;
+import com.google.android.libraries.mobiledatadownload.testing.TestFlags;
+import com.google.common.base.Optional;
+import com.google.common.base.Supplier;
+import com.google.common.collect.ImmutableList;
+import com.google.common.io.BaseEncoding;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.mobiledatadownload.ClientConfigProto.ClientFile;
+import com.google.mobiledatadownload.ClientConfigProto.ClientFileGroup;
+import com.google.mobiledatadownload.DownloadConfigProto.DataFileGroup;
+import com.google.mobiledatadownload.DownloadConfigProto.DownloadConditions.DeviceNetworkPolicy;
+import java.io.OutputStream;
+import java.security.MessageDigest;
+import java.util.Calendar;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+@RunWith(AndroidJUnit4.class)
+public class DownloadFileGroupAndroidSharingIntegrationTest {
+
+  private static final String TAG = "DownloadFileGroupIntegrationTest";
+  private static final int MAX_DOWNLOAD_FILE_GROUP_WAIT_TIME_SECS = 300;
+
+  private static final String TEST_DATA_RELATIVE_PATH =
+      "third_party/java_src/android_libs/mobiledatadownload/javatests/com/google/android/libraries/mobiledatadownload/testdata/";
+
+  // Note: Control Executor must not be a single thread executor.
+  private static final ListeningExecutorService CONTROL_EXECUTOR =
+      MoreExecutors.listeningDecorator(Executors.newCachedThreadPool());
+  private static final ScheduledExecutorService DOWNLOAD_EXECUTOR =
+      Executors.newScheduledThreadPool(2);
+
+  private static final String FILE_GROUP_TO_SHARE_1 = "test-group-1";
+  private static final String FILE_GROUP_TO_SHARE_2 = "test-group-2";
+
+  private static final String FILE_ID_1 = "test-file-to-share-1";
+  private static final String FILE_CHECKSUM_1 = "fcc96b272633cdf6c4bbd2d77512cca51bfb1dbd"; // SHA_1
+  static final String FILE_ANDROID_SHARING_CHECKSUM_1 =
+      "225017b5d5ec35732940af813b1ab7be5191e4c52659953e75a1a36a1398c48d"; // SHA_256
+  static final int FILE_SIZE_1 = 57;
+  static final String FILE_URL_1 = "https://www.gstatic.com/icing/idd/sample_group/step1.txt";
+
+  private static final String FILE_ID_2 = "test-file-to-share-2";
+  private static final String FILE_CHECKSUM_2 = "22d565c9511c5752baab8a3bbf7b955bd2ca66fd"; // SHA_1
+  static final String FILE_ANDROID_SHARING_CHECKSUM_2 =
+      "98863d56d683f6f1fdf17b38873a481f47a3216e05314750f9b384220af418ab"; // SHA_256
+  static final int FILE_SIZE_2 = 13;
+  static final String FILE_URL_2 = "https://www.gstatic.com/icing/idd/sample_group/step2.txt";
+
+  private static final Context context = ApplicationProvider.getApplicationContext();
+
+  @Mock private TaskScheduler mockTaskScheduler;
+  @Mock private NetworkUsageMonitor mockNetworkUsageMonitor;
+  @Mock private DownloadProgressMonitor mockDownloadProgressMonitor;
+  @Mock private Logger mockLogger;
+
+  private SynchronousFileStorage fileStorage;
+  private BlobStoreBackend blobStoreBackend;
+  private BlobStoreManager blobStoreManager;
+  private MobileDataDownload mobileDataDownload;
+
+  private final TestFlags flags = new TestFlags();
+
+  @Rule public final MockitoRule mocks = MockitoJUnit.rule();
+
+  @Before
+  public void setUp() throws Exception {
+    flags.mddAndroidSharingSampleInterval = Optional.of(1);
+    flags.mddDefaultSampleInterval = Optional.of(1);
+    blobStoreBackend = new BlobStoreBackend(context);
+    blobStoreManager = (BlobStoreManager) context.getSystemService(Context.BLOB_STORE_SERVICE);
+
+    fileStorage =
+        new SynchronousFileStorage(
+            /* backends= */ ImmutableList.of(
+                AndroidFileBackend.builder(context).build(),
+                blobStoreBackend,
+                new JavaFileBackend()),
+            /* transforms= */ ImmutableList.of(new CompressTransform()),
+            /* monitors= */ ImmutableList.of(mockNetworkUsageMonitor, mockDownloadProgressMonitor));
+
+    Supplier<FileDownloader> fileDownloaderSupplier =
+        () ->
+            new TestFileDownloader(
+                TEST_DATA_RELATIVE_PATH,
+                fileStorage,
+                MoreExecutors.listeningDecorator(DOWNLOAD_EXECUTOR));
+
+    mobileDataDownload =
+        MobileDataDownloadBuilder.newBuilder()
+            .setContext(context)
+            .setControlExecutor(CONTROL_EXECUTOR)
+            .setFileDownloaderSupplier(fileDownloaderSupplier)
+            .setTaskScheduler(Optional.of(mockTaskScheduler))
+            .setDeltaDecoderOptional(Optional.absent())
+            .setFileStorage(fileStorage)
+            .setNetworkUsageMonitor(mockNetworkUsageMonitor)
+            .setDownloadMonitorOptional(Optional.of(mockDownloadProgressMonitor))
+            .setLoggerOptional(Optional.of(mockLogger))
+            .setFlagsOptional(Optional.of(flags))
+            .build();
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    mobileDataDownload.clear().get();
+  }
+
+  private static String computeDigest(byte[] byteContent, String algorithm) throws Exception {
+    MessageDigest messageDigest = MessageDigest.getInstance(algorithm);
+    if (messageDigest == null) {
+      return "";
+    }
+    return BaseEncoding.base16().lowerCase().encode(messageDigest.digest(byteContent));
+  }
+
+  @Test
+  public void oneAndroidSharedFile_blobStoreBackendNotRegistered_fileDownloadedAndStoredLocally()
+      throws Exception {
+    fileStorage =
+        new SynchronousFileStorage(
+            /* backends= */ ImmutableList.of(
+                AndroidFileBackend.builder(context).build(), new JavaFileBackend()),
+            /* transforms= */ ImmutableList.of(new CompressTransform()),
+            /* monitors= */ ImmutableList.of(mockNetworkUsageMonitor, mockDownloadProgressMonitor));
+
+    Supplier<FileDownloader> fileDownloaderSupplier =
+        () ->
+            new TestFileDownloader(
+                TEST_DATA_RELATIVE_PATH,
+                fileStorage,
+                MoreExecutors.listeningDecorator(DOWNLOAD_EXECUTOR));
+
+    mobileDataDownload =
+        MobileDataDownloadBuilder.newBuilder()
+            .setContext(context)
+            .setControlExecutor(CONTROL_EXECUTOR)
+            .setFileDownloaderSupplier(fileDownloaderSupplier)
+            .setTaskScheduler(Optional.of(mockTaskScheduler))
+            .setDeltaDecoderOptional(Optional.absent())
+            .setFileStorage(fileStorage)
+            .setNetworkUsageMonitor(mockNetworkUsageMonitor)
+            .setDownloadMonitorOptional(Optional.of(mockDownloadProgressMonitor))
+            .setLoggerOptional(Optional.of(mockLogger))
+            .setFlagsOptional(Optional.of(flags))
+            .build();
+
+    Uri androidUri =
+        BlobUri.builder(context).setBlobParameters(FILE_ANDROID_SHARING_CHECKSUM_1).build();
+
+    // A file group with one android-shared file and one non-androidShared file
+    DataFileGroup groupWithFileToShare =
+        TestFileGroupPopulator.createDataFileGroup(
+            FILE_GROUP_TO_SHARE_1,
+            context.getPackageName(),
+            new String[] {FILE_ID_1, FILE_ID},
+            new int[] {FILE_SIZE_1, FILE_SIZE},
+            new String[] {FILE_CHECKSUM_1, FILE_CHECKSUM},
+            new String[] {FILE_ANDROID_SHARING_CHECKSUM_1, ""},
+            new String[] {FILE_URL_1, FILE_URL},
+            DeviceNetworkPolicy.DOWNLOAD_ON_ANY_NETWORK);
+
+    assertThat(
+            mobileDataDownload
+                .addFileGroup(
+                    AddFileGroupRequest.newBuilder().setDataFileGroup(groupWithFileToShare).build())
+                .get())
+        .isTrue();
+
+    mobileDataDownload
+        .downloadFileGroup(
+            DownloadFileGroupRequest.newBuilder()
+                .setGroupName(FILE_GROUP_TO_SHARE_1)
+                .setListenerOptional(
+                    Optional.of(
+                        new DownloadListener() {
+                          @Override
+                          public void onProgress(long currentSize) {
+                            Log.i(TAG, "onProgress " + currentSize);
+                          }
+
+                          @Override
+                          public void onComplete(ClientFileGroup clientFileGroup) {
+                            Log.i(TAG, "onComplete " + clientFileGroup.getGroupName());
+                          }
+                        }))
+                .build())
+        .get(MAX_DOWNLOAD_FILE_GROUP_WAIT_TIME_SECS, SECONDS);
+
+    ClientFileGroup clientFileGroup =
+        mobileDataDownload
+            .getFileGroup(
+                GetFileGroupRequest.newBuilder().setGroupName(FILE_GROUP_TO_SHARE_1).build())
+            .get();
+
+    assertThat(clientFileGroup).isNotNull();
+    assertThat(clientFileGroup.getGroupName()).isEqualTo(FILE_GROUP_TO_SHARE_1);
+    assertThat(clientFileGroup.getFileCount()).isEqualTo(2);
+
+    ClientFile clientFile = clientFileGroup.getFileList().get(0);
+    assertThat(clientFile.getFileId()).isEqualTo(FILE_ID_1);
+    Uri uri = Uri.parse(clientFile.getFileUri());
+    assertThat(uri).isNotEqualTo(androidUri);
+    assertThat(fileStorage.fileSize(uri)).isEqualTo(FILE_SIZE_1);
+
+    clientFile = clientFileGroup.getFileList().get(1);
+    assertThat(clientFile.getFileId()).isEqualTo(FILE_ID);
+    uri = Uri.parse(clientFile.getFileUri());
+    assertThat(fileStorage.fileSize(uri)).isEqualTo(FILE_SIZE);
+  }
+
+  @Test
+  public void oneAndroidSharedFile_twoFileGroups_downloadedOnlyOnce() throws Exception {
+    Uri androidUri =
+        BlobUri.builder(context).setBlobParameters(FILE_ANDROID_SHARING_CHECKSUM_1).build();
+    assertThat(fileStorage.exists(androidUri)).isFalse();
+
+    // groupWithFileToShare1 and groupWithFileToShare2 contain the same file configured to be
+    // shared.
+    DataFileGroup groupWithFileToShare1 =
+        TestFileGroupPopulator.createDataFileGroup(
+            FILE_GROUP_TO_SHARE_1,
+            context.getPackageName(),
+            new String[] {FILE_ID_1},
+            new int[] {FILE_SIZE_1},
+            new String[] {FILE_CHECKSUM_1},
+            new String[] {FILE_ANDROID_SHARING_CHECKSUM_1},
+            new String[] {FILE_URL_1},
+            DeviceNetworkPolicy.DOWNLOAD_ON_ANY_NETWORK);
+
+    long oneDayLaterInSeconds =
+        Calendar.getInstance().getTimeInMillis() / 1000 + 86400; // in one day
+    groupWithFileToShare1 =
+        groupWithFileToShare1.toBuilder().setExpirationDate(oneDayLaterInSeconds).build();
+
+    DataFileGroup groupWithFileToShare2 =
+        TestFileGroupPopulator.createDataFileGroup(
+            FILE_GROUP_TO_SHARE_2,
+            context.getPackageName(),
+            new String[] {FILE_ID_1},
+            new int[] {FILE_SIZE_1},
+            new String[] {FILE_CHECKSUM_1},
+            new String[] {FILE_ANDROID_SHARING_CHECKSUM_1},
+            new String[] {FILE_URL_1},
+            DeviceNetworkPolicy.DOWNLOAD_ON_ANY_NETWORK);
+
+    long twoDaysLaterInSeconds =
+        Calendar.getInstance().getTimeInMillis() / 1000 + 172800; // in two days
+    groupWithFileToShare2 =
+        groupWithFileToShare2.toBuilder().setExpirationDate(twoDaysLaterInSeconds).build();
+
+    assertThat(
+            mobileDataDownload
+                .addFileGroup(
+                    AddFileGroupRequest.newBuilder()
+                        .setDataFileGroup(groupWithFileToShare1)
+                        .build())
+                .get())
+        .isTrue();
+
+    assertThat(
+            mobileDataDownload
+                .addFileGroup(
+                    AddFileGroupRequest.newBuilder()
+                        .setDataFileGroup(groupWithFileToShare2)
+                        .build())
+                .get())
+        .isTrue();
+
+    mobileDataDownload
+        .downloadFileGroup(
+            DownloadFileGroupRequest.newBuilder()
+                .setGroupName(FILE_GROUP_TO_SHARE_1)
+                .setListenerOptional(
+                    Optional.of(
+                        new DownloadListener() {
+                          @Override
+                          public void onProgress(long currentSize) {
+                            Log.i(TAG, "onProgress " + currentSize);
+                          }
+
+                          @Override
+                          public void onComplete(ClientFileGroup clientFileGroup) {
+                            Log.i(TAG, "onComplete " + clientFileGroup.getGroupName());
+                          }
+                        }))
+                .build())
+        .get(MAX_DOWNLOAD_FILE_GROUP_WAIT_TIME_SECS, SECONDS);
+
+    ClientFileGroup clientFileGroup =
+        mobileDataDownload
+            .getFileGroup(
+                GetFileGroupRequest.newBuilder().setGroupName(FILE_GROUP_TO_SHARE_1).build())
+            .get();
+
+    assertThat(clientFileGroup).isNotNull();
+    assertThat(clientFileGroup.getGroupName()).isEqualTo(FILE_GROUP_TO_SHARE_1);
+    assertThat(clientFileGroup.getFileCount()).isEqualTo(1);
+
+    ClientFile clientFile = clientFileGroup.getFileList().get(0);
+    assertThat(clientFile.getFileId()).isEqualTo(FILE_ID_1);
+    Uri uri = Uri.parse(clientFile.getFileUri());
+
+    // The file is now available in the android shared storage.
+    assertThat(uri).isEqualTo(androidUri);
+    assertThat(fileStorage.exists(uri)).isTrue();
+    assertThat(blobStoreManager.getLeasedBlobs()).hasSize(1);
+
+    mobileDataDownload
+        .downloadFileGroup(
+            DownloadFileGroupRequest.newBuilder()
+                .setGroupName(FILE_GROUP_TO_SHARE_2)
+                .setListenerOptional(
+                    Optional.of(
+                        new DownloadListener() {
+                          @Override
+                          public void onProgress(long currentSize) {
+                            Log.i(TAG, "onProgress " + currentSize);
+                          }
+
+                          @Override
+                          public void onComplete(ClientFileGroup clientFileGroup) {
+                            Log.i(TAG, "onComplete " + clientFileGroup.getGroupName());
+                          }
+                        }))
+                .build())
+        .get(MAX_DOWNLOAD_FILE_GROUP_WAIT_TIME_SECS, SECONDS);
+
+    String debugString = mobileDataDownload.getDebugInfoAsString();
+    Log.i(TAG, "MDD Lib dump:");
+    for (String line : debugString.split("\n", -1)) {
+      Log.i(TAG, line);
+    }
+
+    clientFileGroup =
+        mobileDataDownload
+            .getFileGroup(
+                GetFileGroupRequest.newBuilder().setGroupName(FILE_GROUP_TO_SHARE_2).build())
+            .get();
+
+    assertThat(clientFileGroup).isNotNull();
+    assertThat(clientFileGroup.getGroupName()).isEqualTo(FILE_GROUP_TO_SHARE_2);
+    assertThat(clientFileGroup.getFileCount()).isEqualTo(1);
+
+    clientFile = clientFileGroup.getFileList().get(0);
+    assertThat(clientFile.getFileId()).isEqualTo(FILE_ID_1);
+    uri = Uri.parse(clientFile.getFileUri());
+
+    // The file is still available in the android shared storage.
+    assertThat(uri).isEqualTo(androidUri);
+    assertThat(fileStorage.exists(uri)).isTrue();
+    assertThat(blobStoreManager.getLeasedBlobs()).hasSize(1);
+  }
+
+  @Test
+  public void fileAvailableInSharedStorage_neverDownloaded() throws Exception {
+    byte[] content = "fileAvailableInSharedStorage_neverDownloaded".getBytes();
+    String androidChecksum = computeDigest(content, "SHA-256");
+    String checksum = computeDigest(content, "SHA-1");
+    Uri androidUri = BlobUri.builder(context).setBlobParameters(androidChecksum).build();
+
+    assertThat(blobStoreBackend.exists(androidUri)).isFalse();
+
+    // Write file in the shared storage
+    try (OutputStream out = blobStoreBackend.openForWrite(androidUri)) {
+      assertThat(out).isNotNull();
+      out.write(content);
+    }
+    assertThat(blobStoreBackend.exists(androidUri)).isTrue();
+    assertThat(blobStoreManager.getLeasedBlobs()).isEmpty();
+
+    // A file group with one android-shared file and one non-androidShared file
+    DataFileGroup groupWithFileToShare1 =
+        TestFileGroupPopulator.createDataFileGroup(
+            FILE_GROUP_TO_SHARE_1,
+            context.getPackageName(),
+            new String[] {FILE_ID_1, FILE_ID},
+            new int[] {content.length, FILE_SIZE},
+            new String[] {checksum, FILE_CHECKSUM},
+            new String[] {androidChecksum, ""},
+            new String[] {"https://random-url", FILE_URL},
+            DeviceNetworkPolicy.DOWNLOAD_ON_ANY_NETWORK);
+
+    assertThat(
+            mobileDataDownload
+                .addFileGroup(
+                    AddFileGroupRequest.newBuilder()
+                        .setDataFileGroup(groupWithFileToShare1)
+                        .build())
+                .get())
+        .isTrue();
+
+    mobileDataDownload
+        .downloadFileGroup(
+            DownloadFileGroupRequest.newBuilder()
+                .setGroupName(FILE_GROUP_TO_SHARE_1)
+                .setListenerOptional(
+                    Optional.of(
+                        new DownloadListener() {
+                          @Override
+                          public void onProgress(long currentSize) {
+                            Log.i(TAG, "onProgress " + currentSize);
+                          }
+
+                          @Override
+                          public void onComplete(ClientFileGroup clientFileGroup) {
+                            Log.i(TAG, "onComplete " + clientFileGroup.getGroupName());
+                          }
+                        }))
+                .build())
+        .get(MAX_DOWNLOAD_FILE_GROUP_WAIT_TIME_SECS, SECONDS);
+
+    ClientFileGroup clientFileGroup =
+        mobileDataDownload
+            .getFileGroup(
+                GetFileGroupRequest.newBuilder().setGroupName(FILE_GROUP_TO_SHARE_1).build())
+            .get();
+
+    assertThat(clientFileGroup).isNotNull();
+    assertThat(clientFileGroup.getGroupName()).isEqualTo(FILE_GROUP_TO_SHARE_1);
+    assertThat(clientFileGroup.getFileCount()).isEqualTo(2);
+
+    ClientFile clientFile = clientFileGroup.getFileList().get(0);
+    assertThat(clientFile.getFileId()).isEqualTo(FILE_ID_1);
+    Uri uri = Uri.parse(clientFile.getFileUri());
+
+    // The file is available in the android shared storage.
+    assertThat(uri).isEqualTo(androidUri);
+    assertThat(fileStorage.exists(androidUri)).isTrue();
+    assertThat(blobStoreManager.getLeasedBlobs()).hasSize(1);
+
+    clientFile = clientFileGroup.getFileList().get(1);
+    assertThat(clientFile.getFileId()).isEqualTo(FILE_ID);
+    uri = Uri.parse(clientFile.getFileUri());
+    assertThat(fileStorage.fileSize(uri)).isEqualTo(FILE_SIZE);
+  }
+
+  @Test
+  public void fileDownloadedForFirstFileGroup_thenSharedForSecondFileGroup() throws Exception {
+    Uri androidUri =
+        BlobUri.builder(context).setBlobParameters(FILE_ANDROID_SHARING_CHECKSUM_2).build();
+    assertThat(blobStoreBackend.exists(androidUri)).isFalse();
+
+    // Create non-android-shared file group.
+    DataFileGroup groupWithFileToShare1 =
+        TestFileGroupPopulator.createDataFileGroup(
+            FILE_GROUP_TO_SHARE_1,
+            context.getPackageName(),
+            new String[] {FILE_ID_2},
+            new int[] {FILE_SIZE_2},
+            new String[] {FILE_CHECKSUM_2},
+            new String[] {FILE_URL_2},
+            DeviceNetworkPolicy.DOWNLOAD_ON_ANY_NETWORK);
+
+    long laterTimeSecs = Calendar.getInstance().getTimeInMillis() / 1000 + 86400; // in one day
+    groupWithFileToShare1 =
+        groupWithFileToShare1.toBuilder().setExpirationDate(laterTimeSecs).build();
+
+    assertThat(
+            mobileDataDownload
+                .addFileGroup(
+                    AddFileGroupRequest.newBuilder()
+                        .setDataFileGroup(groupWithFileToShare1)
+                        .build())
+                .get())
+        .isTrue();
+
+    // groupWithFileToShare2 has the same file as the previous file group but it has been configured
+    // to be share.
+    DataFileGroup groupWithFileToShare2 =
+        TestFileGroupPopulator.createDataFileGroup(
+            FILE_GROUP_TO_SHARE_2,
+            context.getPackageName(),
+            new String[] {FILE_ID_2},
+            new int[] {FILE_SIZE_2},
+            new String[] {FILE_CHECKSUM_2},
+            new String[] {FILE_ANDROID_SHARING_CHECKSUM_2},
+            new String[] {FILE_URL_2},
+            DeviceNetworkPolicy.DOWNLOAD_ON_ANY_NETWORK);
+
+    assertThat(
+            mobileDataDownload
+                .addFileGroup(
+                    AddFileGroupRequest.newBuilder()
+                        .setDataFileGroup(groupWithFileToShare2)
+                        .build())
+                .get())
+        .isTrue();
+
+    mobileDataDownload
+        .downloadFileGroup(
+            DownloadFileGroupRequest.newBuilder()
+                .setGroupName(FILE_GROUP_TO_SHARE_1)
+                .setListenerOptional(
+                    Optional.of(
+                        new DownloadListener() {
+                          @Override
+                          public void onProgress(long currentSize) {
+                            Log.i(TAG, "onProgress " + currentSize);
+                          }
+
+                          @Override
+                          public void onComplete(ClientFileGroup clientFileGroup) {
+                            Log.i(TAG, "onComplete " + clientFileGroup.getGroupName());
+                          }
+                        }))
+                .build())
+        .get(MAX_DOWNLOAD_FILE_GROUP_WAIT_TIME_SECS, SECONDS);
+
+    ClientFileGroup clientFileGroup =
+        mobileDataDownload
+            .getFileGroup(
+                GetFileGroupRequest.newBuilder().setGroupName(FILE_GROUP_TO_SHARE_1).build())
+            .get();
+
+    assertThat(clientFileGroup).isNotNull();
+    assertThat(clientFileGroup.getGroupName()).isEqualTo(FILE_GROUP_TO_SHARE_1);
+    assertThat(clientFileGroup.getFileCount()).isEqualTo(1);
+
+    ClientFile clientFile = clientFileGroup.getFileList().get(0);
+    assertThat(clientFile.getFileId()).isEqualTo(FILE_ID_2);
+
+    // File stored locally
+    Uri localUri = Uri.parse(clientFile.getFileUri());
+    assertThat(localUri).isNotEqualTo(androidUri);
+    assertThat(blobStoreManager.getLeasedBlobs()).isEmpty();
+
+    mobileDataDownload
+        .downloadFileGroup(
+            DownloadFileGroupRequest.newBuilder()
+                .setGroupName(FILE_GROUP_TO_SHARE_2)
+                .setListenerOptional(
+                    Optional.of(
+                        new DownloadListener() {
+                          @Override
+                          public void onProgress(long currentSize) {
+                            Log.i(TAG, "onProgress " + currentSize);
+                          }
+
+                          @Override
+                          public void onComplete(ClientFileGroup clientFileGroup) {
+                            Log.i(TAG, "onComplete " + clientFileGroup.getGroupName());
+                          }
+                        }))
+                .build())
+        .get(MAX_DOWNLOAD_FILE_GROUP_WAIT_TIME_SECS, SECONDS);
+
+    String debugString = mobileDataDownload.getDebugInfoAsString();
+    Log.i(TAG, "MDD Lib dump:");
+    for (String line : debugString.split("\n", -1)) {
+      Log.i(TAG, line);
+    }
+
+    clientFileGroup =
+        mobileDataDownload
+            .getFileGroup(
+                GetFileGroupRequest.newBuilder().setGroupName(FILE_GROUP_TO_SHARE_2).build())
+            .get();
+
+    assertThat(clientFileGroup).isNotNull();
+    assertThat(clientFileGroup.getGroupName()).isEqualTo(FILE_GROUP_TO_SHARE_2);
+    assertThat(clientFileGroup.getFileCount()).isEqualTo(1);
+
+    clientFile = clientFileGroup.getFileList().get(0);
+    assertThat(clientFile.getFileId()).isEqualTo(FILE_ID_2);
+    Uri uri = Uri.parse(clientFile.getFileUri());
+
+    // The file is now available in the shared storage.
+    assertThat(uri).isNotEqualTo(localUri);
+    assertThat(uri).isEqualTo(androidUri);
+    assertThat(fileStorage.exists(uri)).isTrue();
+    assertThat(blobStoreManager.getLeasedBlobs()).hasSize(1);
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/DownloadFileGroupIntegrationTest.java b/javatests/com/google/android/libraries/mobiledatadownload/DownloadFileGroupIntegrationTest.java
new file mode 100644
index 0000000..c5b3239
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/DownloadFileGroupIntegrationTest.java
@@ -0,0 +1,411 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+import static com.google.android.libraries.mobiledatadownload.TestFileGroupPopulator.FILE_CHECKSUM;
+import static com.google.android.libraries.mobiledatadownload.TestFileGroupPopulator.FILE_GROUP_NAME;
+import static com.google.android.libraries.mobiledatadownload.TestFileGroupPopulator.FILE_ID;
+import static com.google.android.libraries.mobiledatadownload.TestFileGroupPopulator.FILE_SIZE;
+import static com.google.android.libraries.mobiledatadownload.TestFileGroupPopulator.FILE_URL;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.fail;
+
+import android.content.Context;
+import android.net.Uri;
+import android.util.Log;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest;
+import com.google.android.libraries.mobiledatadownload.downloader.FileDownloader;
+import com.google.android.libraries.mobiledatadownload.downloader.offroad.dagger.downloader2.BaseFileDownloaderModule;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.AndroidFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.integration.downloader.SharedPreferencesDownloadMetadata;
+import com.google.android.libraries.mobiledatadownload.file.transforms.CompressTransform;
+import com.google.android.libraries.mobiledatadownload.monitor.DownloadProgressMonitor;
+import com.google.android.libraries.mobiledatadownload.monitor.NetworkUsageMonitor;
+import com.google.android.libraries.mobiledatadownload.testing.BlockingFileDownloader;
+import com.google.android.libraries.mobiledatadownload.testing.TestFlags;
+import com.google.common.base.Optional;
+import com.google.common.base.Supplier;
+import com.google.common.collect.ImmutableList;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.mobiledatadownload.ClientConfigProto.ClientFile;
+import com.google.mobiledatadownload.ClientConfigProto.ClientFileGroup;
+import com.google.mobiledatadownload.DownloadConfigProto.DataFileGroup;
+import com.google.mobiledatadownload.DownloadConfigProto.DownloadConditions.DeviceNetworkPolicy;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+@RunWith(AndroidJUnit4.class)
+public class DownloadFileGroupIntegrationTest {
+
+  private static final String TAG = "DownloadFileGroupIntegrationTest";
+  private static final int MAX_DOWNLOAD_FILE_GROUP_WAIT_TIME_SECS = 300;
+
+  // Note: Control Executor must not be a single thread executor.
+  private static final ListeningExecutorService CONTROL_EXECUTOR =
+      MoreExecutors.listeningDecorator(Executors.newCachedThreadPool());
+  private static final ScheduledExecutorService DOWNLOAD_EXECUTOR =
+      Executors.newScheduledThreadPool(2);
+  private static final ListeningExecutorService listeningExecutorService =
+      MoreExecutors.listeningDecorator(DOWNLOAD_EXECUTOR);
+
+  private static final String FILE_GROUP_NAME_INSECURE_URL = "test-group-insecure-url";
+  private static final String FILE_GROUP_NAME_MULTIPLE_FILES = "test-group-multiple-files";
+
+  private static final String FILE_ID_1 = "test-file-1";
+  private static final String FILE_ID_2 = "test-file-2";
+  private static final String FILE_CHECKSUM_1 = "a1cba9d87b1440f41ce9e7da38c43e1f6bd7d5df";
+  private static final String FILE_CHECKSUM_2 = "cb2459d9f1b508993aba36a5ffd942a7e0d49ed6";
+  private static final String FILE_NOT_EXIST_URL =
+      "https://www.gstatic.com/icing/idd/notexist/file.txt";
+
+  private static final Context context = ApplicationProvider.getApplicationContext();
+
+  @Mock private TaskScheduler mockTaskScheduler;
+  @Mock private NetworkUsageMonitor mockNetworkUsageMonitor;
+  @Mock private DownloadProgressMonitor mockDownloadProgressMonitor;
+
+  private SynchronousFileStorage fileStorage;
+
+  private final TestFlags flags = new TestFlags();
+
+  @Rule public final MockitoRule mocks = MockitoJUnit.rule();
+
+  /* Differentiates between Downloader libraries for shared test method assertions. */
+  private enum DownloaderVersion {
+    V2
+  }
+
+  @Before
+  public void setUp() throws Exception {
+
+    fileStorage =
+        new SynchronousFileStorage(
+            /* backends= */ ImmutableList.of(AndroidFileBackend.builder(context).build()),
+            /* transforms= */ ImmutableList.of(new CompressTransform()),
+            /* monitors= */ ImmutableList.of(mockNetworkUsageMonitor, mockDownloadProgressMonitor));
+  }
+
+  @Test
+  public void downloadAndRead_downloader2() throws Exception {
+    Supplier<FileDownloader> fileDownloaderSupplier =
+        () ->
+            BaseFileDownloaderModule.createOffroad2FileDownloader(
+                context,
+                DOWNLOAD_EXECUTOR,
+                CONTROL_EXECUTOR,
+                fileStorage,
+                new SharedPreferencesDownloadMetadata(
+                    context.getSharedPreferences("downloadmetadata", 0), listeningExecutorService),
+                Optional.of(mockDownloadProgressMonitor),
+                /* urlEngineOptional= */ Optional.absent(),
+                /* exceptionHandlerOptional= */ Optional.absent(),
+                /* authTokenProviderOptional= */ Optional.absent(),
+                /* trafficTag= */ Optional.absent(),
+                flags);
+
+    testDownloadAndRead(fileDownloaderSupplier, DownloaderVersion.V2);
+  }
+
+  @Test
+  public void downloadFailed_downloader2() throws Exception {
+    Supplier<FileDownloader> fileDownloaderSupplier =
+        () ->
+            BaseFileDownloaderModule.createOffroad2FileDownloader(
+                context,
+                DOWNLOAD_EXECUTOR,
+                CONTROL_EXECUTOR,
+                fileStorage,
+                new SharedPreferencesDownloadMetadata(
+                    context.getSharedPreferences("downloadmetadata", 0), listeningExecutorService),
+                Optional.of(mockDownloadProgressMonitor),
+                /* urlEngineOptional= */ Optional.absent(),
+                /* exceptionHandlerOptional= */ Optional.absent(),
+                /* authTokenProviderOptional= */ Optional.absent(),
+                /* trafficTag= */ Optional.absent(),
+                flags);
+
+    testDownloadFailed(fileDownloaderSupplier, DownloaderVersion.V2);
+  }
+
+  private void testDownloadFailed(
+      Supplier<FileDownloader> fileDownloaderSupplier, DownloaderVersion version) throws Exception {
+    MobileDataDownload mobileDataDownload =
+        MobileDataDownloadBuilder.newBuilder()
+            .setContext(context)
+            .setControlExecutor(CONTROL_EXECUTOR)
+            .setFileDownloaderSupplier(fileDownloaderSupplier)
+            .setTaskScheduler(Optional.of(mockTaskScheduler))
+            .setDeltaDecoderOptional(Optional.absent())
+            .setFileStorage(fileStorage)
+            .setNetworkUsageMonitor(mockNetworkUsageMonitor)
+            .setDownloadMonitorOptional(Optional.of(mockDownloadProgressMonitor))
+            .setFlagsOptional(Optional.of(flags))
+            .build();
+
+    // The data file group has a file with insecure url.
+    DataFileGroup groupWithInsecureUrl =
+        TestFileGroupPopulator.createDataFileGroup(
+            FILE_GROUP_NAME_INSECURE_URL,
+            context.getPackageName(),
+            new String[] {FILE_ID},
+            new int[] {FILE_SIZE},
+            new String[] {FILE_CHECKSUM},
+            // Make the url insecure. This would lead to download failure.
+            new String[] {FILE_URL.replace("https", "http")},
+            DeviceNetworkPolicy.DOWNLOAD_ON_ANY_NETWORK);
+
+    // The data file group has a file with non-existent url, and a file with insecure url.
+    DataFileGroup groupWithMultipleFiles =
+        TestFileGroupPopulator.createDataFileGroup(
+            FILE_GROUP_NAME_MULTIPLE_FILES,
+            context.getPackageName(),
+            new String[] {FILE_ID_1, FILE_ID_2},
+            new int[] {FILE_SIZE, FILE_SIZE},
+            new String[] {FILE_CHECKSUM_1, FILE_CHECKSUM_2},
+            // The first file url doesn't exist and the second file url is insecure.
+            new String[] {FILE_NOT_EXIST_URL, FILE_URL.replace("https", "http")},
+            DeviceNetworkPolicy.DOWNLOAD_ON_ANY_NETWORK);
+
+    assertThat(
+            mobileDataDownload
+                .addFileGroup(
+                    AddFileGroupRequest.newBuilder().setDataFileGroup(groupWithInsecureUrl).build())
+                .get())
+        .isTrue();
+
+    assertThat(
+            mobileDataDownload
+                .addFileGroup(
+                    AddFileGroupRequest.newBuilder()
+                        .setDataFileGroup(groupWithMultipleFiles)
+                        .build())
+                .get())
+        .isTrue();
+
+    ExecutionException exception =
+        assertThrows(
+            ExecutionException.class,
+            () ->
+                mobileDataDownload
+                    .downloadFileGroup(
+                        DownloadFileGroupRequest.newBuilder()
+                            .setGroupName(FILE_GROUP_NAME_INSECURE_URL)
+                            .build())
+                    .get(MAX_DOWNLOAD_FILE_GROUP_WAIT_TIME_SECS, TimeUnit.SECONDS));
+    assertThat(exception).hasCauseThat().isInstanceOf(AggregateException.class);
+    AggregateException cause = (AggregateException) exception.getCause();
+    assertThat(cause).isNotNull();
+    ImmutableList<Throwable> failures = cause.getFailures();
+    assertThat(failures).hasSize(1);
+    assertThat(failures.get(0)).isInstanceOf(DownloadException.class);
+    assertThat(failures.get(0)).hasMessageThat().contains("INSECURE_URL_ERROR");
+
+    ExecutionException exception2 =
+        assertThrows(
+            ExecutionException.class,
+            () ->
+                mobileDataDownload
+                    .downloadFileGroup(
+                        DownloadFileGroupRequest.newBuilder()
+                            .setGroupName(FILE_GROUP_NAME_MULTIPLE_FILES)
+                            .build())
+                    .get(MAX_DOWNLOAD_FILE_GROUP_WAIT_TIME_SECS, TimeUnit.SECONDS));
+    assertThat(exception2).hasCauseThat().isInstanceOf(AggregateException.class);
+    AggregateException cause2 = (AggregateException) exception2.getCause();
+    assertThat(cause2).isNotNull();
+    ImmutableList<Throwable> failures2 = cause2.getFailures();
+    assertThat(failures2).hasSize(2);
+    assertThat(failures2.get(0)).isInstanceOf(DownloadException.class);
+    switch (version) {
+      case V2:
+        assertThat(failures2.get(0))
+            .hasCauseThat()
+            .hasMessageThat()
+            .containsMatch("httpStatusCode=404");
+        break;
+    }
+    assertThat(failures2.get(1)).isInstanceOf(DownloadException.class);
+    assertThat(failures2.get(1)).hasMessageThat().contains("INSECURE_URL_ERROR");
+
+    switch (version) {
+      case V2:
+        // No-op
+    }
+  }
+
+  private void testDownloadAndRead(
+      Supplier<FileDownloader> fileDownloaderSupplier, DownloaderVersion version) throws Exception {
+    TestFileGroupPopulator testFileGroupPopulator = new TestFileGroupPopulator(context);
+    MobileDataDownload mobileDataDownload =
+        MobileDataDownloadBuilder.newBuilder()
+            .setContext(context)
+            .setControlExecutor(CONTROL_EXECUTOR)
+            .setFileDownloaderSupplier(fileDownloaderSupplier)
+            .addFileGroupPopulator(testFileGroupPopulator)
+            .setTaskScheduler(Optional.of(mockTaskScheduler))
+            .setDeltaDecoderOptional(Optional.absent())
+            .setFileStorage(fileStorage)
+            .setNetworkUsageMonitor(mockNetworkUsageMonitor)
+            .setDownloadMonitorOptional(Optional.of(mockDownloadProgressMonitor))
+            .setFlagsOptional(Optional.of(flags))
+            .build();
+
+    testFileGroupPopulator.refreshFileGroups(mobileDataDownload).get();
+    mobileDataDownload
+        .downloadFileGroup(
+            DownloadFileGroupRequest.newBuilder()
+                .setGroupName(FILE_GROUP_NAME)
+                .setListenerOptional(
+                    Optional.of(
+                        new DownloadListener() {
+                          @Override
+                          public void onProgress(long currentSize) {
+                            Log.i(TAG, "onProgress " + currentSize);
+                          }
+
+                          @Override
+                          public void onComplete(ClientFileGroup clientFileGroup) {
+                            Log.i(TAG, "onComplete " + clientFileGroup.getGroupName());
+                          }
+                        }))
+                .build())
+        .get(MAX_DOWNLOAD_FILE_GROUP_WAIT_TIME_SECS, TimeUnit.SECONDS);
+
+    String debugString = mobileDataDownload.getDebugInfoAsString();
+    Log.i(TAG, "MDD Lib dump:");
+    for (String line : debugString.split("\n", -1)) {
+      Log.i(TAG, line);
+    }
+
+    ClientFileGroup clientFileGroup =
+        mobileDataDownload
+            .getFileGroup(GetFileGroupRequest.newBuilder().setGroupName(FILE_GROUP_NAME).build())
+            .get();
+
+    assertThat(clientFileGroup).isNotNull();
+    assertThat(clientFileGroup.getGroupName()).isEqualTo(FILE_GROUP_NAME);
+    assertThat(clientFileGroup.getFileCount()).isEqualTo(1);
+
+    ClientFile clientFile = clientFileGroup.getFileList().get(0);
+    assertThat(clientFile.getFileId()).isEqualTo(FILE_ID);
+    Uri androidUri = Uri.parse(clientFile.getFileUri());
+    assertThat(fileStorage.fileSize(androidUri)).isEqualTo(FILE_SIZE);
+
+    mobileDataDownload.clear().get();
+
+    switch (version) {
+      case V2:
+        // No-op
+    }
+  }
+
+  @Test
+  public void cancelDownload() throws Exception {
+    // In this test we will start a download and make sure that calling cancel on the returned
+    // future will cancel the download.
+    // We create a BlockingFileDownloader that allows the download to be blocked indefinitely.
+    // We also provide a delegate FileDownloader that attaches a FutureCallback to the internal
+    // download future and fail if the future is not cancelled.
+    BlockingFileDownloader blockingFileDownloader =
+        new BlockingFileDownloader(
+            listeningExecutorService,
+            new FileDownloader() {
+              @Override
+              public ListenableFuture<Void> startDownloading(DownloadRequest downloadRequest) {
+                ListenableFuture<Void> downloadTaskFuture = Futures.immediateVoidFuture();
+                Futures.addCallback(
+                    downloadTaskFuture,
+                    new FutureCallback<Void>() {
+                      @Override
+                      public void onSuccess(Void result) {
+                        // Should not get here since we will cancel the future.
+                        fail();
+                      }
+
+                      @Override
+                      public void onFailure(Throwable t) {
+                        assertThat(downloadTaskFuture.isCancelled()).isTrue();
+
+                        Log.i(TAG, "downloadTask is cancelled!");
+                      }
+                    },
+                    listeningExecutorService);
+                return downloadTaskFuture;
+              }
+            });
+    Supplier<FileDownloader> neverFinishDownloader = () -> blockingFileDownloader;
+
+    // Use never finish downloader to test whether the cancellation on the downloadFuture would
+    // cancel all the parent futures.
+    TestFileGroupPopulator testFileGroupPopulator = new TestFileGroupPopulator(context);
+    MobileDataDownload mobileDataDownload =
+        MobileDataDownloadBuilder.newBuilder()
+            .setContext(context)
+            .setControlExecutor(CONTROL_EXECUTOR)
+            .setFileDownloaderSupplier(neverFinishDownloader)
+            .addFileGroupPopulator(testFileGroupPopulator)
+            .setTaskScheduler(Optional.of(mockTaskScheduler))
+            .setDeltaDecoderOptional(Optional.absent())
+            .setFileStorage(fileStorage)
+            .setNetworkUsageMonitor(mockNetworkUsageMonitor)
+            .setDownloadMonitorOptional(Optional.of(mockDownloadProgressMonitor))
+            .setFlagsOptional(Optional.of(flags))
+            .build();
+
+    testFileGroupPopulator.refreshFileGroups(mobileDataDownload).get();
+
+    // Now start to download the file group.
+    ListenableFuture<ClientFileGroup> downloadFileGroupFuture =
+        mobileDataDownload.downloadFileGroup(
+            DownloadFileGroupRequest.newBuilder().setGroupName(FILE_GROUP_NAME).build());
+
+    // Note: we could have a race condition here between when we call the
+    // downloadFileGroupFuture.cancel and when the FileDownloader.startDownloading is executed.
+    // The following call will ensure that we will only call cancel on the downloadFileGroupFuture
+    // when the actual download has happened (the downloadTaskFuture).
+    // This will block until the downloadTaskFuture starts.
+    blockingFileDownloader.waitForDownloadStarted();
+
+    // Cancel the downloadFileGroupFuture, it should cascade cancellation to downloadTaskFuture.
+    downloadFileGroupFuture.cancel(true /*may interrupt*/);
+
+    // Allow the download to continue and trigger our delegate FileDownloader. If the future isn't
+    // cancelled, the onSuccess callback should fail the test.
+    blockingFileDownloader.finishDownloading();
+    blockingFileDownloader.waitForDownloadCompleted();
+
+    assertThat(downloadFileGroupFuture.isCancelled()).isTrue();
+
+    mobileDataDownload.clear().get();
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/DownloadFileIntegrationTest.java b/javatests/com/google/android/libraries/mobiledatadownload/DownloadFileIntegrationTest.java
new file mode 100644
index 0000000..e1b37a0
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/DownloadFileIntegrationTest.java
@@ -0,0 +1,220 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+import static com.google.android.libraries.mobiledatadownload.DownloadException.DownloadResultCode.ANDROID_DOWNLOADER_HTTP_ERROR;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.verify;
+
+import android.content.Context;
+import android.net.Uri;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.android.libraries.mobiledatadownload.downloader.DownloadConstraints;
+import com.google.android.libraries.mobiledatadownload.downloader.FileDownloader;
+import com.google.android.libraries.mobiledatadownload.downloader.offroad.dagger.downloader2.BaseFileDownloaderModule;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.AndroidFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.backends.AndroidUri;
+import com.google.android.libraries.mobiledatadownload.file.integration.downloader.SharedPreferencesDownloadMetadata;
+import com.google.android.libraries.mobiledatadownload.monitor.DownloadProgressMonitor;
+import com.google.android.libraries.mobiledatadownload.monitor.NetworkUsageMonitor;
+import com.google.android.libraries.mobiledatadownload.testing.BlockingFileDownloader;
+import com.google.android.libraries.mobiledatadownload.testing.FakeTimeSource;
+import com.google.android.libraries.mobiledatadownload.testing.TestFlags;
+import com.google.common.base.Optional;
+import com.google.common.base.Supplier;
+import com.google.common.collect.ImmutableList;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+@RunWith(AndroidJUnit4.class)
+public class DownloadFileIntegrationTest {
+
+  @Rule public final MockitoRule mocks = MockitoJUnit.rule();
+
+  private static final String TAG = "DownloadFileIntegrationTest";
+
+  private static final int FILE_SIZE = 554;
+  private static final String FILE_URL = "https://www.gstatic.com/suggest-dev/odws1_empty.jar";
+  private static final String DOES_NOT_EXIST_FILE_URL =
+      "https://www.gstatic.com/non-existing/suggest-dev/not-exist.txt";
+
+  // Note: Control Executor must not be a single thread executor.
+  private static final ListeningExecutorService CONTROL_EXECUTOR =
+      MoreExecutors.listeningDecorator(Executors.newCachedThreadPool());
+  private static final ScheduledExecutorService DOWNLOAD_EXECUTOR =
+      Executors.newScheduledThreadPool(2);
+
+  private static final Context context = ApplicationProvider.getApplicationContext();
+
+  private MobileDataDownload mobileDataDownload;
+
+  private final Uri destinationFileUri =
+      AndroidUri.builder(context).setModule("mdd").setRelativePath("file_1").build();
+
+  private DownloadProgressMonitor downloadProgressMonitor;
+  private SynchronousFileStorage fileStorage;
+  private Supplier<FileDownloader> fileDownloaderSupplier;
+  private final FakeTimeSource clock = new FakeTimeSource();
+
+  @Mock private SingleFileDownloadListener mockDownloadListener;
+  @Mock private NetworkUsageMonitor mockNetworkUsageMonitor;
+
+  private final TestFlags flags = new TestFlags();
+
+  @Before
+  public void setUp() throws Exception {
+    downloadProgressMonitor = new DownloadProgressMonitor(clock, CONTROL_EXECUTOR);
+
+    fileStorage =
+        new SynchronousFileStorage(
+            /* backends= */ ImmutableList.of(AndroidFileBackend.builder(context).build()),
+            /* transforms= */ ImmutableList.of(),
+            /* monitors= */ ImmutableList.of(downloadProgressMonitor));
+
+    fileDownloaderSupplier =
+        () ->
+            BaseFileDownloaderModule.createOffroad2FileDownloader(
+                context,
+                DOWNLOAD_EXECUTOR,
+                CONTROL_EXECUTOR,
+                fileStorage,
+                new SharedPreferencesDownloadMetadata(
+                    context.getSharedPreferences("downloadmetadata", 0), CONTROL_EXECUTOR),
+                Optional.of(downloadProgressMonitor),
+                /* urlEngineOptional= */ Optional.absent(),
+                /* exceptionHandlerOptional= */ Optional.absent(),
+                /* authTokenProviderOptional= */ Optional.absent(),
+                /* trafficTag= */ Optional.absent(),
+                flags);
+  }
+
+  @Test
+  public void downloadFile_success() throws Exception {
+    assertThat(fileStorage.exists(destinationFileUri)).isFalse();
+
+    mobileDataDownload = getMobileDataDownload(fileDownloaderSupplier);
+
+    SingleFileDownloadRequest downloadRequest =
+        SingleFileDownloadRequest.newBuilder()
+            .setDestinationFileUri(destinationFileUri)
+            .setUrlToDownload(FILE_URL)
+            .setDownloadConstraints(DownloadConstraints.NETWORK_CONNECTED)
+            .setListenerOptional(Optional.of(mockDownloadListener))
+            .build();
+
+    ListenableFuture<Void> downloadFuture = mobileDataDownload.downloadFile(downloadRequest);
+    downloadFuture.get();
+
+    // Verify the file is downloaded.
+    assertThat(fileStorage.exists(destinationFileUri)).isTrue();
+    assertThat(fileStorage.fileSize(destinationFileUri)).isEqualTo(FILE_SIZE);
+    fileStorage.deleteFile(destinationFileUri);
+
+    // Verify the downloadListener is called.
+    // Sleep for 1 sec to wait for the Future's callback to finish.
+    Thread.sleep(/*millis=*/ 1000);
+    verify(mockDownloadListener).onComplete();
+  }
+
+  @Test
+  public void downloadFile_failure() throws Exception {
+    assertThat(fileStorage.exists(destinationFileUri)).isFalse();
+
+    mobileDataDownload = getMobileDataDownload(fileDownloaderSupplier);
+
+    // Trying to download doesn't exist URL.
+    SingleFileDownloadRequest downloadRequest =
+        SingleFileDownloadRequest.newBuilder()
+            .setDestinationFileUri(destinationFileUri)
+            .setUrlToDownload(DOES_NOT_EXIST_FILE_URL)
+            .setDownloadConstraints(DownloadConstraints.NETWORK_CONNECTED)
+            .setListenerOptional(Optional.of(mockDownloadListener))
+            .build();
+
+    ListenableFuture<Void> downloadFuture = mobileDataDownload.downloadFile(downloadRequest);
+    ExecutionException ex = assertThrows(ExecutionException.class, downloadFuture::get);
+    assertThat(ex).hasCauseThat().isInstanceOf(DownloadException.class);
+    assertThat(((DownloadException) ex.getCause()).getDownloadResultCode())
+        .isEqualTo(ANDROID_DOWNLOADER_HTTP_ERROR);
+
+    // Verify the file is downloaded.
+    assertThat(fileStorage.exists(destinationFileUri)).isFalse();
+
+    // Verify the downloadListener is called.
+    // Sleep for 1 sec to wait for the Future's callback to finish.
+    Thread.sleep(/*millis=*/ 1000);
+    verify(mockDownloadListener).onFailure(any(DownloadException.class));
+  }
+
+  @Test
+  public void downloadFile_cancel() throws Exception {
+    // Reinitialize downloader with a BlockingFileDownloader to ensure download remains in progress
+    // until it is cancelled.
+    BlockingFileDownloader blockingFileDownloader = new BlockingFileDownloader(CONTROL_EXECUTOR);
+    mobileDataDownload = getMobileDataDownload(() -> blockingFileDownloader);
+
+    SingleFileDownloadRequest downloadRequest =
+        SingleFileDownloadRequest.newBuilder()
+            .setDestinationFileUri(destinationFileUri)
+            .setUrlToDownload(FILE_URL)
+            .setDownloadConstraints(DownloadConstraints.NETWORK_CONNECTED)
+            .build();
+
+    ListenableFuture<Void> downloadFuture = mobileDataDownload.downloadFile(downloadRequest);
+
+    // Note: we could have a race condition here between when the FileDownloader.startDownloading()
+    // is called and when we cancel our download with Future.cancel(). To prevent this, we first
+    // wait until we have started downloading to ensure that it is in progress before we cancel.
+    blockingFileDownloader.waitForDownloadStarted();
+
+    // Cancel the download
+    downloadFuture.cancel(/* mayInterruptIfRunning= */ true);
+
+    assertThrows(CancellationException.class, downloadFuture::get);
+
+    // Cleanup
+    blockingFileDownloader.resetState();
+  }
+
+  private MobileDataDownload getMobileDataDownload(
+      Supplier<FileDownloader> fileDownloaderSupplier) {
+    return MobileDataDownloadBuilder.newBuilder()
+        .setContext(context)
+        .setControlExecutor(CONTROL_EXECUTOR)
+        .setFileDownloaderSupplier(fileDownloaderSupplier)
+        .setFileStorage(fileStorage)
+        .setDownloadMonitorOptional(Optional.of(downloadProgressMonitor))
+        .setNetworkUsageMonitor(mockNetworkUsageMonitor)
+        .setFlagsOptional(Optional.of(flags))
+        .build();
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/DownloadFileTest.java b/javatests/com/google/android/libraries/mobiledatadownload/DownloadFileTest.java
new file mode 100644
index 0000000..2a8ed40
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/DownloadFileTest.java
@@ -0,0 +1,644 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+import static com.google.android.libraries.mobiledatadownload.DownloadException.DownloadResultCode.ANDROID_DOWNLOADER_UNKNOWN;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.content.Context;
+import android.net.Uri;
+import android.util.Log;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.android.libraries.mobiledatadownload.downloader.DownloadConstraints;
+import com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest;
+import com.google.android.libraries.mobiledatadownload.downloader.FileDownloader;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.AndroidFileBackend;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil;
+import com.google.android.libraries.mobiledatadownload.monitor.DownloadProgressMonitor;
+import com.google.android.libraries.mobiledatadownload.monitor.NetworkUsageMonitor;
+import com.google.android.libraries.mobiledatadownload.testing.BlockingFileDownloader;
+import com.google.android.libraries.mobiledatadownload.testing.FakeTimeSource;
+import com.google.android.libraries.mobiledatadownload.testing.TestFlags;
+import com.google.common.base.Optional;
+import com.google.common.base.Supplier;
+import com.google.common.base.Suppliers;
+import com.google.common.collect.ImmutableList;
+import com.google.common.labs.concurrent.LabsFutures;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executors;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.shadows.ShadowLog;
+
+@RunWith(RobolectricTestRunner.class)
+public final class DownloadFileTest {
+  @Rule public final MockitoRule mocks = MockitoJUnit.rule();
+
+  // 1MB file.
+  private static final String FILE_URL =
+      "https://www.gstatic.com/icing/idd/sample_group/sample_file_3_1519240701";
+  private static final Uri DESTINATION_FILE_URI =
+      Uri.parse(
+          "android://com.google.android.libraries.mobiledatadownload/files/datadownload/shared/public/file_1");
+
+  private static final Context context = ApplicationProvider.getApplicationContext();
+
+  private final TestFlags flags = new TestFlags();
+  private final FakeTimeSource clock = new FakeTimeSource();
+
+  private DownloadProgressMonitor downloadProgressMonitor;
+  private SynchronousFileStorage fileStorage;
+
+  @Mock private SingleFileDownloadListener mockDownloadListener;
+  @Mock private NetworkUsageMonitor mockNetworkUsageMonitor;
+  @Mock private FileDownloader mockFileDownloader;
+  @Mock private DownloadProgressMonitor mockDownloadMonitor;
+
+  private BlockingFileDownloader blockingFileDownloader;
+  private SingleFileDownloadRequest singleFileDownloadRequest;
+  private MobileDataDownload mobileDataDownload;
+
+  @Captor ArgumentCaptor<DownloadListener> downloadListenerCaptor;
+
+  @Captor
+  ArgumentCaptor<com.google.android.libraries.mobiledatadownload.lite.DownloadListener>
+      liteDownloadListenerCaptor;
+
+  @Captor ArgumentCaptor<DownloadRequest> singleFileDownloadRequestCaptor;
+
+  ListeningExecutorService controlExecutor =
+      MoreExecutors.listeningDecorator(Executors.newCachedThreadPool());
+
+  @Before
+  public void setUp() {
+    ShadowLog.setLoggable(LogUtil.TAG, Log.DEBUG);
+
+    downloadProgressMonitor = new DownloadProgressMonitor(clock, controlExecutor);
+
+    fileStorage =
+        new SynchronousFileStorage(
+            /* backends= */ ImmutableList.of(AndroidFileBackend.builder(context).build()),
+            /* transforms= */ ImmutableList.of(),
+            /* monitors= */ ImmutableList.of(downloadProgressMonitor));
+
+    singleFileDownloadRequest =
+        SingleFileDownloadRequest.newBuilder()
+            .setDestinationFileUri(DESTINATION_FILE_URI)
+            .setUrlToDownload(FILE_URL)
+            .setDownloadConstraints(DownloadConstraints.NETWORK_CONNECTED)
+            .setNotificationContentTitle("File url: " + FILE_URL)
+            .build();
+
+    blockingFileDownloader = new BlockingFileDownloader(controlExecutor);
+
+    when(mockDownloadListener.onComplete()).thenReturn(Futures.immediateVoidFuture());
+    when(mockFileDownloader.startDownloading(singleFileDownloadRequestCaptor.capture()))
+        .thenReturn(Futures.immediateVoidFuture());
+  }
+
+  @After
+  public void tearDown() {
+    // Reset state of blockingFileDownloader to prevent deadlocks
+    blockingFileDownloader.resetState();
+  }
+
+  @Test
+  public void downloadFile_whenRequestAlreadyMade_dedups() throws Exception {
+    // Use BlockingFileDownloader to ensure first download is in progress.
+    mobileDataDownload = getMobileDataDownload(() -> blockingFileDownloader);
+
+    ListenableFuture<Void> downloadFuture1 =
+        mobileDataDownload.downloadFile(singleFileDownloadRequest);
+    ListenableFuture<Void> downloadFuture2 =
+        mobileDataDownload.downloadFile(singleFileDownloadRequest);
+
+    // Allow blocking download to finish
+    blockingFileDownloader.finishDownloading();
+
+    // Finish future 2 and assert that future 1 has completed as well
+    downloadFuture2.get();
+
+    awaitAllExecutorsIdle();
+
+    assertThat(downloadFuture1.isDone()).isTrue();
+  }
+
+  @Test
+  public void downloadFile_whenRequestAlreadyMadeUsingForegroundService_dedups() throws Exception {
+    // Use BlockingFileDownloader to ensure first download is in progress.
+    mobileDataDownload = getMobileDataDownload(() -> blockingFileDownloader);
+
+    ListenableFuture<Void> downloadFuture1 =
+        mobileDataDownload.downloadFileWithForegroundService(singleFileDownloadRequest);
+    ListenableFuture<Void> downloadFuture2 =
+        mobileDataDownload.downloadFile(singleFileDownloadRequest);
+
+    // Allow blocking download to finish
+    blockingFileDownloader.finishDownloading();
+
+    // Finish future 2 and assert that future 1 has completed as well
+    downloadFuture2.get();
+
+    awaitAllExecutorsIdle();
+    assertThat(downloadFuture1.isDone()).isTrue();
+  }
+
+  @Test
+  public void downloadFile_beginsDownload() throws Exception {
+    mobileDataDownload =
+        getMobileDataDownload(
+            () -> mockFileDownloader,
+            /* foregroundDownloadServiceClassOptional = */ Optional.absent(),
+            Optional.of(mockDownloadMonitor));
+
+    singleFileDownloadRequest =
+        SingleFileDownloadRequest.newBuilder()
+            .setDestinationFileUri(DESTINATION_FILE_URI)
+            .setUrlToDownload(FILE_URL)
+            .setDownloadConstraints(DownloadConstraints.NETWORK_CONNECTED)
+            .setListenerOptional(Optional.of(mockDownloadListener))
+            .build();
+
+    ListenableFuture<Void> downloadFuture =
+        mobileDataDownload.downloadFile(singleFileDownloadRequest);
+    downloadFuture.get();
+
+    awaitAllExecutorsIdle();
+
+    // Verify that correct DownloadRequest is sent to underlying FileDownloader
+    DownloadRequest actualDownloadRequest = singleFileDownloadRequestCaptor.getValue();
+    assertThat(actualDownloadRequest.fileUri()).isEqualTo(DESTINATION_FILE_URI);
+    assertThat(actualDownloadRequest.urlToDownload()).isEqualTo(FILE_URL);
+    assertThat(actualDownloadRequest.downloadConstraints())
+        .isEqualTo(DownloadConstraints.NETWORK_CONNECTED);
+
+    // Verify that downloadMonitor adds the listener
+    verify(mockDownloadMonitor).addDownloadListener(any(), liteDownloadListenerCaptor.capture());
+    verify(mockFileDownloader).startDownloading(any());
+
+    verify(mockDownloadMonitor).removeDownloadListener(DESTINATION_FILE_URI);
+    verify(mockDownloadListener).onComplete();
+
+    // Ensure that given download listener is the same one passed to download monitor
+    com.google.android.libraries.mobiledatadownload.lite.DownloadListener capturedDownloadListener =
+        liteDownloadListenerCaptor.getValue();
+    DownloadException testException =
+        DownloadException.builder().setDownloadResultCode(ANDROID_DOWNLOADER_UNKNOWN).build();
+
+    capturedDownloadListener.onProgress(10);
+    capturedDownloadListener.onFailure(testException);
+    capturedDownloadListener.onPausedForConnectivity();
+
+    verify(mockDownloadListener).onProgress(10);
+    verify(mockDownloadListener).onFailure(testException);
+    verify(mockDownloadListener).onPausedForConnectivity();
+  }
+
+  @Test
+  public void download_whenListenerProvided_handlesOnCompleteFailed() throws Exception {
+    Exception failureException = new Exception("test failure");
+    when(mockDownloadListener.onComplete())
+        .thenReturn(Futures.immediateFailedFuture(failureException));
+    mobileDataDownload =
+        getMobileDataDownload(
+            createSuccessfulFileDownloaderSupplier(),
+            /* foregroundDownloadServiceClassOptional = */ Optional.absent(),
+            Optional.of(downloadProgressMonitor));
+
+    singleFileDownloadRequest =
+        SingleFileDownloadRequest.newBuilder()
+            .setDestinationFileUri(DESTINATION_FILE_URI)
+            .setUrlToDownload(FILE_URL)
+            .setDownloadConstraints(DownloadConstraints.NETWORK_CONNECTED)
+            .setListenerOptional(Optional.of(mockDownloadListener))
+            .build();
+
+    mobileDataDownload.downloadFile(singleFileDownloadRequest).get();
+
+    awaitAllExecutorsIdle();
+
+    // Verify the DownloadListeners onComplete was invoked
+    verify(mockDownloadListener).onComplete();
+  }
+
+  @Test
+  public void downloadFile_whenDownloadFails_reportsFailure() throws Exception {
+    DownloadException downloadException =
+        DownloadException.builder().setDownloadResultCode(ANDROID_DOWNLOADER_UNKNOWN).build();
+
+    mobileDataDownload =
+        getMobileDataDownload(
+            createFailingFileDownloaderSupplier(downloadException),
+            /* foregroundDownloadServiceClassOptional = */ Optional.absent(),
+            Optional.of(downloadProgressMonitor));
+
+    singleFileDownloadRequest =
+        SingleFileDownloadRequest.newBuilder()
+            .setDestinationFileUri(DESTINATION_FILE_URI)
+            .setUrlToDownload(FILE_URL)
+            .setDownloadConstraints(DownloadConstraints.NETWORK_CONNECTED)
+            .setListenerOptional(Optional.of(mockDownloadListener))
+            .build();
+
+    ListenableFuture<Void> downloadFuture =
+        mobileDataDownload.downloadFile(singleFileDownloadRequest);
+    assertThrows(ExecutionException.class, downloadFuture::get);
+    DownloadException e = LabsFutures.getFailureCauseAs(downloadFuture, DownloadException.class);
+    assertThat(e.getDownloadResultCode()).isEqualTo(ANDROID_DOWNLOADER_UNKNOWN);
+
+    // Verify that DownloadListener.onFailure was invoked with failure
+    verify(mockDownloadListener).onFailure(downloadException);
+    verify(mockDownloadListener, times(0)).onComplete();
+  }
+
+  @Test
+  public void downloadFile_whenReturnedFutureIsCanceled_cancelsDownload() throws Exception {
+    // Wrap mock around BlockingFileDownloader to simulate long download
+    mobileDataDownload = getMobileDataDownload(() -> blockingFileDownloader);
+
+    ListenableFuture<Void> downloadFuture =
+        mobileDataDownload.downloadFile(singleFileDownloadRequest);
+
+    // Wait for download to start and confirm download future is still running.
+    blockingFileDownloader.waitForDownloadStarted();
+    assertThat(downloadFuture.isDone()).isFalse();
+
+    // Cancel download future.
+    downloadFuture.cancel(true);
+
+    // Check that future is now cancelled.
+    assertThat(downloadFuture.isCancelled()).isTrue();
+  }
+
+  @Test
+  public void downloadFile_whenMonitorNotProvided_whenDownloadFails_reportsFailure()
+      throws Exception {
+    DownloadException downloadException =
+        DownloadException.builder().setDownloadResultCode(ANDROID_DOWNLOADER_UNKNOWN).build();
+
+    mobileDataDownload =
+        getMobileDataDownload(createFailingFileDownloaderSupplier(downloadException));
+
+    singleFileDownloadRequest =
+        SingleFileDownloadRequest.newBuilder()
+            .setDestinationFileUri(DESTINATION_FILE_URI)
+            .setUrlToDownload(FILE_URL)
+            .setDownloadConstraints(DownloadConstraints.NETWORK_CONNECTED)
+            .setListenerOptional(Optional.of(mockDownloadListener))
+            .build();
+
+    ListenableFuture<Void> downloadFuture =
+        mobileDataDownload.downloadFile(singleFileDownloadRequest);
+    assertThrows(ExecutionException.class, downloadFuture::get);
+    DownloadException e = LabsFutures.getFailureCauseAs(downloadFuture, DownloadException.class);
+    assertThat(e.getDownloadResultCode()).isEqualTo(ANDROID_DOWNLOADER_UNKNOWN);
+
+    // Verify that DownloadListener.onFailure was invoked with failure
+    verify(mockDownloadListener).onFailure(downloadException);
+    verify(mockDownloadListener, times(0)).onComplete();
+  }
+
+  @Test
+  public void downloadFileWithWithForegroundService_requiresForegroundDownloadService()
+      throws Exception {
+    // Create downloader without providing foreground service
+    mobileDataDownload =
+        getMobileDataDownload(
+            () -> mockFileDownloader,
+            /* foregroundDownloadServiceClassOptional = */ Optional.absent(),
+            Optional.of(downloadProgressMonitor));
+
+    // Without foreground service, download call should fail with IllegalStateException
+    ListenableFuture<Void> downloadFuture =
+        mobileDataDownload.downloadFileWithForegroundService(singleFileDownloadRequest);
+    ExecutionException e = assertThrows(ExecutionException.class, downloadFuture::get);
+    assertThat(e).hasCauseThat().isInstanceOf(IllegalStateException.class);
+
+    // Verify that underlying download is not started
+    verify(mockFileDownloader, times(0)).startDownloading(any());
+  }
+
+  @Test
+  public void downloadFileWithForegroundService_requiresDownloadMonitor() throws Exception {
+    // Create downloader without providing DownloadMonitor
+    mobileDataDownload =
+        getMobileDataDownload(
+            () -> mockFileDownloader,
+            Optional.of(this.getClass()),
+            /* downloadProgressMonitorOptional = */ Optional.absent());
+
+    // Without monitor, download call should fail with IllegalStateException
+    ListenableFuture<Void> downloadFuture =
+        mobileDataDownload.downloadFileWithForegroundService(singleFileDownloadRequest);
+    ExecutionException e = assertThrows(ExecutionException.class, downloadFuture::get);
+    assertThat(e).hasCauseThat().isInstanceOf(IllegalStateException.class);
+
+    // Verify that underlying download is not started
+    verify(mockFileDownloader, times(0)).startDownloading(any());
+  }
+
+  @Test
+  public void downloadFileWithForegroundService_whenRequestAlreadyMade_dedups() throws Exception {
+    // Use BlockingFileDownloader to control when the download will finish.
+    mobileDataDownload = getMobileDataDownload(() -> blockingFileDownloader);
+
+    ListenableFuture<Void> downloadFuture1 =
+        mobileDataDownload.downloadFileWithForegroundService(singleFileDownloadRequest);
+    ListenableFuture<Void> downloadFuture2 =
+        mobileDataDownload.downloadFileWithForegroundService(singleFileDownloadRequest);
+
+    // Now we let the 2 futures downloadFuture1 downloadFuture2 to run by opening the latch.
+    blockingFileDownloader.finishDownloading();
+
+    // Now finish future 2, future 1 should finish too and the cache clears the future.
+    downloadFuture2.get();
+
+    awaitAllExecutorsIdle();
+
+    assertThat(downloadFuture1.isDone()).isTrue();
+  }
+
+  @Test
+  public void
+      downloadFileWithForegroundService_whenRequestAlreadyMadeWithoutForegroundService_dedups()
+          throws Exception {
+    // Use BlockingFileDownloader to control when the download will finish.
+    mobileDataDownload =
+        getMobileDataDownload(
+            () -> blockingFileDownloader,
+            Optional.of(this.getClass()),
+            Optional.of(downloadProgressMonitor));
+
+    ListenableFuture<Void> downloadFuture1 =
+        mobileDataDownload.downloadFile(singleFileDownloadRequest);
+    ListenableFuture<Void> downloadFuture2 =
+        mobileDataDownload.downloadFileWithForegroundService(singleFileDownloadRequest);
+
+    // Now we let the 2 futures downloadFuture1 downloadFuture2 to run by opening the latch.
+    blockingFileDownloader.finishDownloading();
+
+    // Now finish future 2, future 1 should finish too and the cache clears the future.
+    downloadFuture2.get();
+
+    awaitAllExecutorsIdle();
+
+    assertThat(downloadFuture1.isDone()).isTrue();
+  }
+
+  @Test
+  public void downloadFileWithForegroundService() throws Exception {
+    when(mockFileDownloader.startDownloading(singleFileDownloadRequestCaptor.capture()))
+        .thenReturn(Futures.immediateVoidFuture());
+    mobileDataDownload =
+        getMobileDataDownload(
+            () -> mockFileDownloader,
+            Optional.of(this.getClass()),
+            Optional.of(mockDownloadMonitor));
+
+    singleFileDownloadRequest =
+        SingleFileDownloadRequest.newBuilder()
+            .setDestinationFileUri(DESTINATION_FILE_URI)
+            .setUrlToDownload(FILE_URL)
+            .setDownloadConstraints(DownloadConstraints.NETWORK_CONNECTED)
+            .setNotificationContentTitle("File url: " + FILE_URL)
+            .setListenerOptional(Optional.of(mockDownloadListener))
+            .build();
+
+    ListenableFuture<Void> downloadFuture =
+        mobileDataDownload.downloadFileWithForegroundService(singleFileDownloadRequest);
+    downloadFuture.get();
+
+    awaitAllExecutorsIdle();
+
+    // Verify that the correct DownloadRequest is sent to underderlying FileDownloader.
+    DownloadRequest actualDownloadRequest = singleFileDownloadRequestCaptor.getValue();
+    assertThat(actualDownloadRequest.fileUri()).isEqualTo(DESTINATION_FILE_URI);
+    assertThat(actualDownloadRequest.urlToDownload()).isEqualTo(FILE_URL);
+    assertThat(actualDownloadRequest.downloadConstraints())
+        .isEqualTo(DownloadConstraints.NETWORK_CONNECTED);
+
+    // Verify that downloadMonitor will add a DownloadListener.
+    verify(mockDownloadMonitor).addDownloadListener(any(), liteDownloadListenerCaptor.capture());
+    verify(mockFileDownloader).startDownloading(any());
+
+    verify(mockDownloadMonitor).removeDownloadListener(DESTINATION_FILE_URI);
+
+    verify(mockDownloadListener).onComplete();
+
+    com.google.android.libraries.mobiledatadownload.lite.DownloadListener capturedListener =
+        liteDownloadListenerCaptor.getValue();
+
+    // Now simulate other DownloadListener's callbacks:
+    capturedListener.onProgress(10);
+    capturedListener.onPausedForConnectivity();
+    DownloadException downloadException =
+        DownloadException.builder().setDownloadResultCode(ANDROID_DOWNLOADER_UNKNOWN).build();
+    capturedListener.onFailure(downloadException);
+
+    awaitAllExecutorsIdle();
+
+    verify(mockDownloadListener).onProgress(10);
+    verify(mockDownloadListener).onPausedForConnectivity();
+    verify(mockDownloadListener).onFailure(downloadException);
+  }
+
+  @Test
+  public void downloadFileWithForegroundService_clientOnCompleteFailed() throws Exception {
+    Exception failureException = new Exception("test failure");
+
+    when(mockFileDownloader.startDownloading(singleFileDownloadRequestCaptor.capture()))
+        .thenReturn(Futures.immediateVoidFuture());
+    mobileDataDownload =
+        getMobileDataDownload(
+            () -> mockFileDownloader,
+            Optional.of(this.getClass()),
+            Optional.of(downloadProgressMonitor));
+
+    // Client's provided DownloadListener.onComplete failed.
+    when(mockDownloadListener.onComplete())
+        .thenReturn(Futures.immediateFailedFuture(failureException));
+
+    singleFileDownloadRequest =
+        SingleFileDownloadRequest.newBuilder()
+            .setDestinationFileUri(DESTINATION_FILE_URI)
+            .setUrlToDownload(FILE_URL)
+            .setDownloadConstraints(DownloadConstraints.NETWORK_CONNECTED)
+            .setNotificationContentTitle("File url: " + FILE_URL)
+            .setListenerOptional(Optional.of(mockDownloadListener))
+            .build();
+
+    ListenableFuture<Void> downloadFuture =
+        mobileDataDownload.downloadFileWithForegroundService(singleFileDownloadRequest);
+    downloadFuture.get();
+
+    awaitAllExecutorsIdle();
+
+    // Verify that the correct DownloadRequest is sent to underderlying FileDownloader.
+    DownloadRequest actualDownloadRequest = singleFileDownloadRequestCaptor.getValue();
+    assertThat(actualDownloadRequest.fileUri()).isEqualTo(DESTINATION_FILE_URI);
+    assertThat(actualDownloadRequest.urlToDownload()).isEqualTo(FILE_URL);
+    assertThat(actualDownloadRequest.downloadConstraints())
+        .isEqualTo(DownloadConstraints.NETWORK_CONNECTED);
+
+    verify(mockFileDownloader).startDownloading(any());
+    verify(mockDownloadListener).onComplete();
+  }
+
+  @Test
+  public void downloadFileWithForegroundService_failure() throws Exception {
+    DownloadException downloadException =
+        DownloadException.builder().setDownloadResultCode(ANDROID_DOWNLOADER_UNKNOWN).build();
+
+    when(mockFileDownloader.startDownloading(singleFileDownloadRequestCaptor.capture()))
+        .thenReturn(Futures.immediateFailedFuture(downloadException));
+
+    mobileDataDownload =
+        getMobileDataDownload(
+            () -> mockFileDownloader,
+            Optional.of(this.getClass()),
+            Optional.of(downloadProgressMonitor));
+
+    singleFileDownloadRequest =
+        SingleFileDownloadRequest.newBuilder()
+            .setDestinationFileUri(DESTINATION_FILE_URI)
+            .setUrlToDownload(FILE_URL)
+            .setDownloadConstraints(DownloadConstraints.NETWORK_CONNECTED)
+            .setNotificationContentTitle("File url: " + FILE_URL)
+            .setListenerOptional(Optional.of(mockDownloadListener))
+            .build();
+
+    ListenableFuture<Void> downloadFuture =
+        mobileDataDownload.downloadFileWithForegroundService(singleFileDownloadRequest);
+    assertThrows(ExecutionException.class, downloadFuture::get);
+    DownloadException e = LabsFutures.getFailureCauseAs(downloadFuture, DownloadException.class);
+    assertThat(e.getDownloadResultCode()).isEqualTo(ANDROID_DOWNLOADER_UNKNOWN);
+
+    awaitAllExecutorsIdle();
+
+    // Verify that the correct DownloadRequest is sent to underderlying FileDownloader.
+    DownloadRequest actualDownloadRequest = singleFileDownloadRequestCaptor.getValue();
+    assertThat(actualDownloadRequest.fileUri()).isEqualTo(DESTINATION_FILE_URI);
+    assertThat(actualDownloadRequest.urlToDownload()).isEqualTo(FILE_URL);
+    assertThat(actualDownloadRequest.downloadConstraints())
+        .isEqualTo(DownloadConstraints.NETWORK_CONNECTED);
+
+    // Since the download failed, onComplete will not be called but onFailure.
+    verify(mockDownloadListener, times(0)).onComplete();
+    verify(mockDownloadListener).onFailure(downloadException);
+  }
+
+  @Test
+  public void cancelDownloadFileWithForegroundService() throws Exception {
+    // Use BlockingFileDownloader to control when the download will finish.
+    mobileDataDownload = getMobileDataDownload(() -> blockingFileDownloader);
+
+    ListenableFuture<Void> downloadFuture =
+        mobileDataDownload.downloadFileWithForegroundService(singleFileDownloadRequest);
+
+    mobileDataDownload.cancelForegroundDownload(DESTINATION_FILE_URI.toString());
+
+    awaitAllExecutorsIdle();
+
+    assertTrue(downloadFuture.isCancelled());
+  }
+
+  @Test
+  public void cancelListenableFuture() throws Exception {
+    // Use BlockingFileDownloader to control when the download will finish.
+    mobileDataDownload = getMobileDataDownload(() -> blockingFileDownloader);
+
+    ListenableFuture<Void> downloadFuture =
+        mobileDataDownload.downloadFileWithForegroundService(singleFileDownloadRequest);
+
+    // Wait for download to start and confirm download future is still running.
+    blockingFileDownloader.waitForDownloadStarted();
+    assertThat(downloadFuture.isDone()).isFalse();
+
+    // Cancel download future.
+    downloadFuture.cancel(true);
+
+    // Check that future is now cancelled.
+    assertThat(downloadFuture.isCancelled()).isTrue();
+  }
+
+  private Supplier<FileDownloader> createFailingFileDownloaderSupplier(Throwable throwable) {
+    return Suppliers.ofInstance(
+        new FileDownloader() {
+          @Override
+          public ListenableFuture<Void> startDownloading(DownloadRequest request) {
+            return Futures.immediateFailedFuture(throwable);
+          }
+        });
+  }
+
+  private Supplier<FileDownloader> createSuccessfulFileDownloaderSupplier() {
+    return Suppliers.ofInstance(
+        new FileDownloader() {
+          @Override
+          public ListenableFuture<Void> startDownloading(DownloadRequest request) {
+            return Futures.immediateVoidFuture();
+          }
+        });
+  }
+
+  private MobileDataDownload getMobileDataDownload(
+      Supplier<FileDownloader> fileDownloaderSupplier) {
+    return getMobileDataDownload(
+        fileDownloaderSupplier, Optional.of(this.getClass()), Optional.of(downloadProgressMonitor));
+  }
+
+  private MobileDataDownload getMobileDataDownload(
+      Supplier<FileDownloader> fileDownloaderSupplier,
+      Optional<Class<?>> foregroundDownloadServiceClassOptional,
+      Optional<DownloadProgressMonitor> downloadProgressMonitorOptional) {
+    return MobileDataDownloadBuilder.newBuilder()
+        .setContext(context)
+        .setControlExecutor(controlExecutor)
+        .setFileDownloaderSupplier(fileDownloaderSupplier)
+        .setFileStorage(fileStorage)
+        .setDownloadMonitorOptional(downloadProgressMonitorOptional)
+        .setNetworkUsageMonitor(mockNetworkUsageMonitor)
+        .setForegroundDownloadServiceOptional(foregroundDownloadServiceClassOptional)
+        .setFlagsOptional(Optional.of(flags))
+        .build();
+  }
+
+  /** Waits long enough for async operations to finish running. */
+  // TODO(b/217551873): investigate ways to make this more robust
+  private static void awaitAllExecutorsIdle() throws Exception {
+    Thread.sleep(/* millis= */ 100);
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/ImportFilesIntegrationTest.java b/javatests/com/google/android/libraries/mobiledatadownload/ImportFilesIntegrationTest.java
new file mode 100644
index 0000000..dc5f1ea
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/ImportFilesIntegrationTest.java
@@ -0,0 +1,845 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+import static com.google.android.libraries.mobiledatadownload.TestFileGroupPopulator.FILE_CHECKSUM;
+import static com.google.android.libraries.mobiledatadownload.TestFileGroupPopulator.FILE_GROUP_NAME;
+import static com.google.android.libraries.mobiledatadownload.TestFileGroupPopulator.FILE_ID;
+import static com.google.android.libraries.mobiledatadownload.TestFileGroupPopulator.FILE_SIZE;
+import static com.google.android.libraries.mobiledatadownload.TestFileGroupPopulator.FILE_URL;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.fail;
+
+import android.content.Context;
+import android.net.Uri;
+import android.os.Environment;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.android.libraries.mobiledatadownload.DownloadException.DownloadResultCode;
+import com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest;
+import com.google.android.libraries.mobiledatadownload.downloader.FileDownloader;
+import com.google.android.libraries.mobiledatadownload.downloader.MultiSchemeFileDownloader;
+import com.google.android.libraries.mobiledatadownload.downloader.inline.InlineFileDownloader;
+import com.google.android.libraries.mobiledatadownload.downloader.offroad.dagger.downloader2.BaseFileDownloaderModule;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.AndroidFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.backends.FileUri;
+import com.google.android.libraries.mobiledatadownload.file.backends.JavaFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.FakeFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.FakeFileBackend.OperationType;
+import com.google.android.libraries.mobiledatadownload.file.integration.downloader.SharedPreferencesDownloadMetadata;
+import com.google.android.libraries.mobiledatadownload.file.openers.ReadStreamOpener;
+import com.google.android.libraries.mobiledatadownload.file.transforms.CompressTransform;
+import com.google.android.libraries.mobiledatadownload.monitor.DownloadProgressMonitor;
+import com.google.android.libraries.mobiledatadownload.monitor.NetworkUsageMonitor;
+import com.google.android.libraries.mobiledatadownload.testing.BlockingFileDownloader;
+import com.google.android.libraries.mobiledatadownload.testing.TestFlags;
+import com.google.common.base.Optional;
+import com.google.common.base.Supplier;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.truth.Correspondence;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.mobiledatadownload.ClientConfigProto.ClientFile;
+import com.google.mobiledatadownload.ClientConfigProto.ClientFileGroup;
+import com.google.mobiledatadownload.ClientConfigProto.ClientFileGroup.Status;
+import com.google.mobiledatadownload.DownloadConfigProto.DataFile;
+import com.google.mobiledatadownload.DownloadConfigProto.DataFileGroup;
+import com.google.protobuf.ByteString;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+@RunWith(AndroidJUnit4.class)
+public final class ImportFilesIntegrationTest {
+
+  @Rule public final MockitoRule mocks = MockitoJUnit.rule();
+
+  private static final String TAG = "ImportFilesIntegrationTest";
+
+  private static final String TEST_DATA_ABSOLUTE_PATH =
+      Environment.getExternalStorageDirectory()
+          + "/googletest/test_runfiles/google3/third_party/java_src/android_libs/mobiledatadownload/javatests/com/google/android/libraries/mobiledatadownload/testdata/";
+
+  // Note: Control Executor must not be a single thread executor.
+  private static final ListeningExecutorService CONTROL_EXECUTOR =
+      MoreExecutors.listeningDecorator(Executors.newCachedThreadPool());
+  private static final ScheduledExecutorService DOWNLOAD_EXECUTOR =
+      Executors.newScheduledThreadPool(2);
+
+  private static final String FILE_ID_1 = "test-file-1";
+  private static final Uri FILE_URI_1 =
+      Uri.parse(
+          FileUri.builder().setPath(TEST_DATA_ABSOLUTE_PATH + "odws1_empty").build().toString());
+  private static final String FILE_CHECKSUM_1 = "a1cba9d87b1440f41ce9e7da38c43e1f6bd7d5df";
+  private static final String FILE_URL_1 = "inlinefile:sha1:" + FILE_CHECKSUM_1;
+  private static final int FILE_SIZE_1 = 554;
+  private static final DataFile INLINE_DATA_FILE_1 =
+      DataFile.newBuilder()
+          .setFileId(FILE_ID_1)
+          .setByteSize(FILE_SIZE_1)
+          .setUrlToDownload(FILE_URL_1)
+          .setChecksum(FILE_CHECKSUM_1)
+          .build();
+
+  private static final String FILE_ID_2 = "test-file-2";
+  private static final Uri FILE_URI_2 =
+      Uri.parse(
+          FileUri.builder()
+              .setPath(TEST_DATA_ABSOLUTE_PATH + "zip_test_folder.zip")
+              .build()
+              .toString());
+  private static final String FILE_CHECKSUM_2 = "7024b6bcddf2b2897656e9353f7fc715df5ea986";
+  private static final String FILE_URL_2 = "inlinefile:sha2:" + FILE_CHECKSUM_2;
+  private static final int FILE_SIZE_2 = 373;
+  private static final DataFile INLINE_DATA_FILE_2 =
+      DataFile.newBuilder()
+          .setFileId(FILE_ID_2)
+          .setByteSize(FILE_SIZE_2)
+          .setUrlToDownload(FILE_URL_2)
+          .setChecksum(FILE_CHECKSUM_2)
+          .build();
+
+  private static final Context context = ApplicationProvider.getApplicationContext();
+
+  @Mock private TaskScheduler mockTaskScheduler;
+  @Mock private NetworkUsageMonitor mockNetworkUsageMonitor;
+  @Mock private DownloadProgressMonitor mockDownloadProgressMonitor;
+
+  private FakeFileBackend fakeFileBackend;
+  private SynchronousFileStorage fileStorage;
+  private Supplier<FileDownloader> multiSchemeFileDownloaderSupplier;
+  private MobileDataDownload mobileDataDownload;
+
+  private FileSource inlineFileSource1;
+  private FileSource inlineFileSource2;
+
+  private final TestFlags flags = new TestFlags();
+
+  @Before
+  public void setUp() throws Exception {
+
+    fakeFileBackend = new FakeFileBackend(AndroidFileBackend.builder(context).build());
+    fileStorage =
+        new SynchronousFileStorage(
+            /* backends= */ ImmutableList.of(fakeFileBackend, new JavaFileBackend()),
+            /* transforms= */ ImmutableList.of(new CompressTransform()),
+            /* monitors= */ ImmutableList.of(mockNetworkUsageMonitor, mockDownloadProgressMonitor));
+
+    Supplier<FileDownloader> fileDownloaderSupplier =
+        () ->
+            BaseFileDownloaderModule.createOffroad2FileDownloader(
+                context,
+                DOWNLOAD_EXECUTOR,
+                CONTROL_EXECUTOR,
+                fileStorage,
+                new SharedPreferencesDownloadMetadata(
+                    context.getSharedPreferences("downloadmetadata", 0), CONTROL_EXECUTOR),
+                Optional.of(mockDownloadProgressMonitor),
+                /* urlEngineOptional= */ Optional.absent(),
+                /* exceptionHandlerOptional= */ Optional.absent(),
+                /* authTokenProviderOptional= */ Optional.absent(),
+                /* trafficTag= */ Optional.absent(),
+                flags);
+
+    Supplier<FileDownloader> inlineFileDownloaderSupplier =
+        () -> new InlineFileDownloader(fileStorage, DOWNLOAD_EXECUTOR);
+    multiSchemeFileDownloaderSupplier =
+        () ->
+            MultiSchemeFileDownloader.builder()
+                .addScheme("https", fileDownloaderSupplier.get())
+                .addScheme("inlinefile", inlineFileDownloaderSupplier.get())
+                .build();
+
+    // Set up inline file sources
+    try (InputStream fileStream1 = fileStorage.open(FILE_URI_1, ReadStreamOpener.create());
+        InputStream fileStream2 = fileStorage.open(FILE_URI_2, ReadStreamOpener.create())) {
+      inlineFileSource1 = FileSource.ofByteString(ByteString.readFrom(fileStream1));
+      inlineFileSource2 = FileSource.ofByteString(ByteString.readFrom(fileStream2));
+    }
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    // Clear file group to ensure there is not cross-test pollination
+    mobileDataDownload.clear().get();
+    // Reset fake file backend
+    fakeFileBackend.clearFailure(OperationType.ALL);
+  }
+
+  @Test
+  public void importFiles_performsImport() throws Exception {
+    createMobileDataDownload(multiSchemeFileDownloaderSupplier);
+
+    DataFileGroup fileGroupWithInlineFile =
+        DataFileGroup.newBuilder()
+            .setGroupName(FILE_GROUP_NAME)
+            .addFile(INLINE_DATA_FILE_1)
+            .build();
+
+    // Ensure that we add the file group successfully
+    assertThat(
+            mobileDataDownload
+                .addFileGroup(
+                    AddFileGroupRequest.newBuilder()
+                        .setDataFileGroup(fileGroupWithInlineFile)
+                        .build())
+                .get())
+        .isTrue();
+
+    // Perform the import
+    mobileDataDownload
+        .importFiles(
+            ImportFilesRequest.newBuilder()
+                .setGroupName(FILE_GROUP_NAME)
+                .setBuildId(fileGroupWithInlineFile.getBuildId())
+                .setVariantId(fileGroupWithInlineFile.getVariantId())
+                .setInlineFileMap(ImmutableMap.of(FILE_ID_1, inlineFileSource1))
+                .build())
+        .get();
+
+    // Assert that the resulting group is downloaded and contains a reference to on device file
+    ClientFileGroup importResult =
+        mobileDataDownload
+            .getFileGroup(GetFileGroupRequest.newBuilder().setGroupName(FILE_GROUP_NAME).build())
+            .get();
+
+    assertThat(importResult).isNotNull();
+    assertThat(importResult.getGroupName()).isEqualTo(FILE_GROUP_NAME);
+    assertThat(importResult.getFileCount()).isEqualTo(1);
+    assertThat(importResult.getStatus()).isEqualTo(Status.DOWNLOADED);
+    assertThat(importResult.getFile(0).getFileUri()).isNotEmpty();
+
+    assertThat(fileStorage.exists(Uri.parse(importResult.getFile(0).getFileUri()))).isTrue();
+  }
+
+  @Test
+  public void importFiles_whenImportingMultipleFiles_performsImport() throws Exception {
+    createMobileDataDownload(multiSchemeFileDownloaderSupplier);
+
+    DataFileGroup fileGroupWithInlineFile =
+        DataFileGroup.newBuilder()
+            .setGroupName(FILE_GROUP_NAME)
+            .addFile(INLINE_DATA_FILE_1)
+            .addFile(INLINE_DATA_FILE_2)
+            .build();
+
+    // Ensure that we add the file group successfully
+    assertThat(
+            mobileDataDownload
+                .addFileGroup(
+                    AddFileGroupRequest.newBuilder()
+                        .setDataFileGroup(fileGroupWithInlineFile)
+                        .build())
+                .get())
+        .isTrue();
+
+    // Perform the import
+    mobileDataDownload
+        .importFiles(
+            ImportFilesRequest.newBuilder()
+                .setGroupName(FILE_GROUP_NAME)
+                .setBuildId(fileGroupWithInlineFile.getBuildId())
+                .setVariantId(fileGroupWithInlineFile.getVariantId())
+                .setInlineFileMap(
+                    ImmutableMap.of(FILE_ID_1, inlineFileSource1, FILE_ID_2, inlineFileSource2))
+                .build())
+        .get();
+
+    // Assert that the resulting group is downloaded and contains a reference to on device file
+    ClientFileGroup importResult =
+        mobileDataDownload
+            .getFileGroup(GetFileGroupRequest.newBuilder().setGroupName(FILE_GROUP_NAME).build())
+            .get();
+
+    assertThat(importResult).isNotNull();
+    assertThat(importResult.getGroupName()).isEqualTo(FILE_GROUP_NAME);
+    assertThat(importResult.getFileCount()).isEqualTo(2);
+    assertThat(importResult.getStatus()).isEqualTo(Status.DOWNLOADED);
+    assertThat(importResult.getFile(0).getFileUri()).isNotEmpty();
+    assertThat(importResult.getFile(1).getFileUri()).isNotEmpty();
+
+    assertThat(fileStorage.exists(Uri.parse(importResult.getFile(0).getFileUri()))).isTrue();
+    assertThat(fileStorage.exists(Uri.parse(importResult.getFile(1).getFileUri()))).isTrue();
+  }
+
+  @Test
+  public void importFiles_supportsMultipleCallsConcurrently() throws Exception {
+    // Use BlockingFileDownloader to ensure both imports start around the same time.
+    AtomicInteger fileDownloaderInvocationCount = new AtomicInteger(0);
+    BlockingFileDownloader blockingFileDownloader =
+        new BlockingFileDownloader(
+            MoreExecutors.listeningDecorator(DOWNLOAD_EXECUTOR),
+            new FileDownloader() {
+              @Override
+              public ListenableFuture<Void> startDownloading(DownloadRequest request) {
+                fileDownloaderInvocationCount.addAndGet(1);
+                return multiSchemeFileDownloaderSupplier.get().startDownloading(request);
+              }
+            });
+    createMobileDataDownload(() -> blockingFileDownloader);
+
+    DataFileGroup fileGroup1WithInlineFile =
+        DataFileGroup.newBuilder()
+            .setGroupName(FILE_GROUP_NAME)
+            .addFile(INLINE_DATA_FILE_1)
+            .build();
+
+    DataFileGroup fileGroup2WithInlineFile =
+        DataFileGroup.newBuilder()
+            .setGroupName(FILE_GROUP_NAME + "2")
+            .addFile(INLINE_DATA_FILE_2)
+            .build();
+
+    // Ensure that we add the file groups successfully
+    assertThat(
+            mobileDataDownload
+                .addFileGroup(
+                    AddFileGroupRequest.newBuilder()
+                        .setDataFileGroup(fileGroup1WithInlineFile)
+                        .build())
+                .get())
+        .isTrue();
+    assertThat(
+            mobileDataDownload
+                .addFileGroup(
+                    AddFileGroupRequest.newBuilder()
+                        .setDataFileGroup(fileGroup2WithInlineFile)
+                        .build())
+                .get())
+        .isTrue();
+
+    // Perform the imports
+    ListenableFuture<Void> importFuture1 =
+        mobileDataDownload.importFiles(
+            ImportFilesRequest.newBuilder()
+                .setGroupName(FILE_GROUP_NAME)
+                .setBuildId(fileGroup1WithInlineFile.getBuildId())
+                .setVariantId(fileGroup1WithInlineFile.getVariantId())
+                .setInlineFileMap(ImmutableMap.of(FILE_ID_1, inlineFileSource1))
+                .build());
+
+    ListenableFuture<Void> importFuture2 =
+        mobileDataDownload.importFiles(
+            ImportFilesRequest.newBuilder()
+                .setGroupName(FILE_GROUP_NAME + "2")
+                .setBuildId(fileGroup2WithInlineFile.getBuildId())
+                .setVariantId(fileGroup2WithInlineFile.getVariantId())
+                .setInlineFileMap(ImmutableMap.of(FILE_ID_2, inlineFileSource2))
+                .build());
+
+    // blocking file downloader should be waiting on the imports, block the test to ensure both
+    // imports have started.
+    blockingFileDownloader.waitForDownloadStarted();
+
+    // unblock the imports so both happen concurrently.
+    blockingFileDownloader.finishDownloading();
+
+    // Wait for both futures to complete
+    Futures.whenAllSucceed(importFuture1, importFuture2).call(() -> null, CONTROL_EXECUTOR).get();
+
+    // Assert that the resulting group is downloaded and contains a reference to on device file
+    ClientFileGroup importResult1 =
+        mobileDataDownload
+            .getFileGroup(GetFileGroupRequest.newBuilder().setGroupName(FILE_GROUP_NAME).build())
+            .get();
+    ClientFileGroup importResult2 =
+        mobileDataDownload
+            .getFileGroup(
+                GetFileGroupRequest.newBuilder().setGroupName(FILE_GROUP_NAME + "2").build())
+            .get();
+
+    assertThat(importResult1).isNotNull();
+    assertThat(importResult1.getFileCount()).isEqualTo(1);
+    assertThat(importResult1.getStatus()).isEqualTo(Status.DOWNLOADED);
+    assertThat(importResult1.getFile(0).getFileUri()).isNotEmpty();
+
+    assertThat(importResult2).isNotNull();
+    assertThat(importResult2.getFileCount()).isEqualTo(1);
+    assertThat(importResult2.getStatus()).isEqualTo(Status.DOWNLOADED);
+    assertThat(importResult2.getFile(0).getFileUri()).isNotEmpty();
+
+    assertThat(fileStorage.exists(Uri.parse(importResult1.getFile(0).getFileUri()))).isTrue();
+    assertThat(fileStorage.exists(Uri.parse(importResult2.getFile(0).getFileUri()))).isTrue();
+
+    // assert that file downloader was called 2 times, 1 for each import.
+    assertThat(fileDownloaderInvocationCount.get()).isEqualTo(2);
+  }
+
+  @Test
+  public void importFiles_whenNewInlineFileSpecified_importsAndStoresFile() throws Exception {
+    createMobileDataDownload(multiSchemeFileDownloaderSupplier);
+
+    DataFileGroup fileGroupWithOneInlineFile =
+        DataFileGroup.newBuilder()
+            .setGroupName(FILE_GROUP_NAME)
+            .addFile(INLINE_DATA_FILE_1)
+            .build();
+    ImmutableList<DataFile> updatedDataFileList = ImmutableList.of(INLINE_DATA_FILE_2);
+
+    // Ensure that we add the file group successfully
+    assertThat(
+            mobileDataDownload
+                .addFileGroup(
+                    AddFileGroupRequest.newBuilder()
+                        .setDataFileGroup(fileGroupWithOneInlineFile)
+                        .build())
+                .get())
+        .isTrue();
+
+    // Perform the import
+    mobileDataDownload
+        .importFiles(
+            ImportFilesRequest.newBuilder()
+                .setGroupName(FILE_GROUP_NAME)
+                .setBuildId(fileGroupWithOneInlineFile.getBuildId())
+                .setVariantId(fileGroupWithOneInlineFile.getVariantId())
+                .setUpdatedDataFileList(updatedDataFileList)
+                .setInlineFileMap(
+                    ImmutableMap.of(FILE_ID_1, inlineFileSource1, FILE_ID_2, inlineFileSource2))
+                .build())
+        .get();
+
+    // Assert that the resulting group is downloaded and contains both files
+    ClientFileGroup importResult =
+        mobileDataDownload
+            .getFileGroup(GetFileGroupRequest.newBuilder().setGroupName(FILE_GROUP_NAME).build())
+            .get();
+
+    assertThat(importResult.getGroupName()).isEqualTo(FILE_GROUP_NAME);
+    assertThat(importResult.getFileCount()).isEqualTo(2);
+    assertThat(importResult.getStatus()).isEqualTo(Status.DOWNLOADED);
+    assertThat(importResult.getFileList())
+        .comparingElementsUsing(Correspondence.transforming(ClientFile::getFileId, "using file id"))
+        .containsExactly(FILE_ID_1, FILE_ID_2);
+    assertThat(importResult.getFile(0).getFileUri()).isNotEmpty();
+    assertThat(importResult.getFile(1).getFileUri()).isNotEmpty();
+  }
+
+  @Test
+  public void importFiles_whenNewInlineFileAddedToPendingGroup_importsAndStoresFile()
+      throws Exception {
+    createMobileDataDownload(multiSchemeFileDownloaderSupplier);
+
+    DataFileGroup fileGroupWithStandardFile =
+        DataFileGroup.newBuilder()
+            .setGroupName(FILE_GROUP_NAME)
+            .addFile(
+                DataFile.newBuilder()
+                    .setFileId(FILE_ID)
+                    .setUrlToDownload(FILE_URL)
+                    .setChecksum(FILE_CHECKSUM)
+                    .setByteSize(FILE_SIZE)
+                    .build())
+            .build();
+    ImmutableList<DataFile> updatedDataFileList = ImmutableList.of(INLINE_DATA_FILE_2);
+
+    // Ensure that we add the file group successfully
+    assertThat(
+            mobileDataDownload
+                .addFileGroup(
+                    AddFileGroupRequest.newBuilder()
+                        .setDataFileGroup(fileGroupWithStandardFile)
+                        .build())
+                .get())
+        .isTrue();
+
+    // Perform the import
+    mobileDataDownload
+        .importFiles(
+            ImportFilesRequest.newBuilder()
+                .setGroupName(FILE_GROUP_NAME)
+                .setBuildId(fileGroupWithStandardFile.getBuildId())
+                .setVariantId(fileGroupWithStandardFile.getVariantId())
+                .setUpdatedDataFileList(updatedDataFileList)
+                .setInlineFileMap(ImmutableMap.of(FILE_ID_2, inlineFileSource2))
+                .build())
+        .get();
+
+    // Assert that the file is pending (does not return from getFileGroup)
+    ClientFileGroup getFileGroupResult =
+        mobileDataDownload
+            .getFileGroup(GetFileGroupRequest.newBuilder().setGroupName(FILE_GROUP_NAME).build())
+            .get();
+    assertThat(getFileGroupResult).isNull();
+
+    // Use getFileGroupsByFilter to get the file group
+    ImmutableList<ClientFileGroup> allFileGroups =
+        mobileDataDownload
+            .getFileGroupsByFilter(
+                GetFileGroupsByFilterRequest.newBuilder()
+                    .setGroupNameOptional(Optional.of(FILE_GROUP_NAME))
+                    .build())
+            .get();
+
+    // GetFileGroupsByFilter returns both downloaded and pending, so find the pending group.
+    ClientFileGroup pendingInlineGroup = null;
+    for (ClientFileGroup group : allFileGroups) {
+      if (group.getStatus().equals(Status.PENDING)) {
+        pendingInlineGroup = group;
+        break;
+      }
+    }
+
+    // Assert that the resulting group is pending and but contains imported file
+    assertThat(pendingInlineGroup).isNotNull();
+    assertThat(pendingInlineGroup.getGroupName()).isEqualTo(FILE_GROUP_NAME);
+    assertThat(pendingInlineGroup.getFileCount()).isEqualTo(2);
+    assertThat(pendingInlineGroup.getStatus()).isEqualTo(Status.PENDING);
+    assertThat(pendingInlineGroup.getFileList())
+        .comparingElementsUsing(Correspondence.transforming(ClientFile::getFileId, "using file id"))
+        .containsExactly(FILE_ID, FILE_ID_2);
+  }
+
+  @Test
+  public void importFiles_toNonExistentDataFileGroup_fails() throws Exception {
+    createMobileDataDownload(multiSchemeFileDownloaderSupplier);
+
+    FileSource inlineFileSource = FileSource.ofByteString(ByteString.copyFromUtf8("TEST_CONTENT"));
+
+    // Perform the import
+    ExecutionException ex =
+        assertThrows(
+            ExecutionException.class,
+            () ->
+                mobileDataDownload
+                    .importFiles(
+                        ImportFilesRequest.newBuilder()
+                            .setGroupName(FILE_GROUP_NAME)
+                            .setBuildId(0)
+                            .setVariantId("")
+                            .setInlineFileMap(ImmutableMap.of(FILE_ID_1, inlineFileSource))
+                            .build())
+                    .get());
+    assertThat(ex).hasCauseThat().isInstanceOf(DownloadException.class);
+    DownloadException dex = (DownloadException) ex.getCause();
+    assertThat(dex.getDownloadResultCode()).isEqualTo(DownloadResultCode.GROUP_NOT_FOUND_ERROR);
+  }
+
+  @Test
+  public void importFiles_whenMismatchedVersion_failToImport() throws Exception {
+    createMobileDataDownload(multiSchemeFileDownloaderSupplier);
+
+    DataFileGroup fileGroupWithInlineFile =
+        DataFileGroup.newBuilder()
+            .setGroupName(FILE_GROUP_NAME)
+            .addFile(INLINE_DATA_FILE_1)
+            .build();
+
+    // Ensure that we add the file group successfully
+    assertThat(
+            mobileDataDownload
+                .addFileGroup(
+                    AddFileGroupRequest.newBuilder()
+                        .setDataFileGroup(fileGroupWithInlineFile)
+                        .build())
+                .get())
+        .isTrue();
+
+    // Perform the import
+    ExecutionException ex =
+        assertThrows(
+            ExecutionException.class,
+            () ->
+                mobileDataDownload
+                    .importFiles(
+                        ImportFilesRequest.newBuilder()
+                            .setGroupName(FILE_GROUP_NAME)
+                            .setBuildId(10)
+                            .setVariantId("")
+                            .setInlineFileMap(ImmutableMap.of(FILE_ID_1, inlineFileSource1))
+                            .build())
+                    .get());
+    assertThat(ex).hasCauseThat().isInstanceOf(DownloadException.class);
+    DownloadException dex = (DownloadException) ex.getCause();
+    assertThat(dex.getDownloadResultCode()).isEqualTo(DownloadResultCode.GROUP_NOT_FOUND_ERROR);
+  }
+
+  @Test
+  public void importFiles_whenImportFails_doesNotWriteUpdatedMetadata() throws Exception {
+    createMobileDataDownload(multiSchemeFileDownloaderSupplier);
+
+    // Create initial file group to import
+    DataFileGroup initialFileGroup =
+        DataFileGroup.newBuilder()
+            .setGroupName(FILE_GROUP_NAME)
+            .addFile(INLINE_DATA_FILE_1)
+            .build();
+
+    // Ensure that we add the file group successfully
+    assertThat(
+            mobileDataDownload
+                .addFileGroup(
+                    AddFileGroupRequest.newBuilder().setDataFileGroup(initialFileGroup).build())
+                .get())
+        .isTrue();
+
+    // Perform the initial import
+    mobileDataDownload
+        .importFiles(
+            ImportFilesRequest.newBuilder()
+                .setGroupName(FILE_GROUP_NAME)
+                .setBuildId(initialFileGroup.getBuildId())
+                .setVariantId(initialFileGroup.getVariantId())
+                .setInlineFileMap(ImmutableMap.of(FILE_ID_1, inlineFileSource1))
+                .build())
+        .get();
+
+    // Assert that the resulting group is downloaded and contains a reference to on device file
+    ClientFileGroup currentFileGroup =
+        mobileDataDownload
+            .getFileGroup(GetFileGroupRequest.newBuilder().setGroupName(FILE_GROUP_NAME).build())
+            .get();
+
+    assertThat(currentFileGroup).isNotNull();
+    assertThat(currentFileGroup.getGroupName()).isEqualTo(FILE_GROUP_NAME);
+    assertThat(currentFileGroup.getFileCount()).isEqualTo(1);
+    assertThat(currentFileGroup.getStatus()).isEqualTo(Status.DOWNLOADED);
+    assertThat(currentFileGroup.getFile(0).getFileUri()).isNotEmpty();
+
+    // Use fake file backend to invoke a failure when importing another file
+    fakeFileBackend.setFailure(OperationType.WRITE_STREAM, new IOException("test failure"));
+
+    // Assert that importFiles fails due to failure importing file
+    ExecutionException ex =
+        assertThrows(
+            ExecutionException.class,
+            () ->
+                mobileDataDownload
+                    .importFiles(
+                        ImportFilesRequest.newBuilder()
+                            .setGroupName(FILE_GROUP_NAME)
+                            .setBuildId(initialFileGroup.getBuildId())
+                            .setVariantId(initialFileGroup.getVariantId())
+                            .setUpdatedDataFileList(ImmutableList.of(INLINE_DATA_FILE_2))
+                            .setInlineFileMap(ImmutableMap.of(FILE_ID_2, inlineFileSource2))
+                            .build())
+                    .get());
+    assertThat(ex).hasCauseThat().isInstanceOf(AggregateException.class);
+    AggregateException aex = (AggregateException) ex.getCause();
+    assertThat(aex.getFailures()).hasSize(1);
+    assertThat(aex.getFailures().get(0)).isInstanceOf(DownloadException.class);
+    DownloadException dex = (DownloadException) aex.getFailures().get(0);
+    assertThat(dex.getDownloadResultCode()).isEqualTo(DownloadResultCode.INLINE_FILE_IO_ERROR);
+
+    // Get the file group again after the second import fails
+    currentFileGroup =
+        mobileDataDownload
+            .getFileGroup(GetFileGroupRequest.newBuilder().setGroupName(FILE_GROUP_NAME).build())
+            .get();
+
+    // Assert that file group remains unchanged (no metadata change)
+    assertThat(currentFileGroup).isNotNull();
+    assertThat(currentFileGroup.getGroupName()).isEqualTo(FILE_GROUP_NAME);
+    assertThat(currentFileGroup.getFileCount()).isEqualTo(1);
+    assertThat(currentFileGroup.getStatus()).isEqualTo(Status.DOWNLOADED);
+    assertThat(currentFileGroup.getFile(0).getFileUri()).isNotEmpty();
+  }
+
+  @Test
+  public void importFiles_supportsDedup() throws Exception {
+    // Use BlockingFileDownloader to block the import of a file indefinitely. This is used to ensure
+    // a file import is in-progress before starting another import
+    AtomicInteger fileDownloaderInvocationCount = new AtomicInteger(0);
+    BlockingFileDownloader blockingFileDownloader =
+        new BlockingFileDownloader(
+            MoreExecutors.listeningDecorator(DOWNLOAD_EXECUTOR),
+            new FileDownloader() {
+              @Override
+              public ListenableFuture<Void> startDownloading(DownloadRequest request) {
+                fileDownloaderInvocationCount.addAndGet(1);
+                return multiSchemeFileDownloaderSupplier.get().startDownloading(request);
+              }
+            });
+
+    createMobileDataDownload(() -> blockingFileDownloader);
+
+    DataFileGroup fileGroup1WithInlineFile =
+        DataFileGroup.newBuilder()
+            .setGroupName(FILE_GROUP_NAME)
+            .addFile(INLINE_DATA_FILE_1)
+            .build();
+
+    DataFileGroup fileGroup2WithInlineFile =
+        DataFileGroup.newBuilder()
+            .setGroupName(FILE_GROUP_NAME + "2")
+            .addFile(INLINE_DATA_FILE_1)
+            .build();
+
+    // Ensure that we add the file groups successfully
+    assertThat(
+            mobileDataDownload
+                .addFileGroup(
+                    AddFileGroupRequest.newBuilder()
+                        .setDataFileGroup(fileGroup1WithInlineFile)
+                        .build())
+                .get())
+        .isTrue();
+    assertThat(
+            mobileDataDownload
+                .addFileGroup(
+                    AddFileGroupRequest.newBuilder()
+                        .setDataFileGroup(fileGroup2WithInlineFile)
+                        .build())
+                .get())
+        .isTrue();
+
+    // Start the first import and keep it in progress
+    ListenableFuture<Void> importFilesFuture1 =
+        mobileDataDownload.importFiles(
+            ImportFilesRequest.newBuilder()
+                .setGroupName(FILE_GROUP_NAME)
+                .setBuildId(fileGroup1WithInlineFile.getBuildId())
+                .setVariantId(fileGroup1WithInlineFile.getVariantId())
+                .setInlineFileMap(ImmutableMap.of(FILE_ID_1, inlineFileSource1))
+                .build());
+
+    blockingFileDownloader.waitForDownloadStarted();
+
+    // Start the second import after the first is already in-progress
+    ListenableFuture<Void> importFilesFuture2 =
+        mobileDataDownload.importFiles(
+            ImportFilesRequest.newBuilder()
+                .setGroupName(FILE_GROUP_NAME + "2")
+                .setBuildId(fileGroup2WithInlineFile.getBuildId())
+                .setVariantId(fileGroup2WithInlineFile.getVariantId())
+                .setInlineFileMap(ImmutableMap.of(FILE_ID_1, inlineFileSource1))
+                .build());
+
+    // Allow the download to continue and trigger our delegate FileDownloader. If the future isn't
+    // cancelled, the onSuccess callback should fail the test.
+    blockingFileDownloader.finishDownloading();
+    blockingFileDownloader.waitForDownloadCompleted();
+
+    // wait for importFilesFuture2 to complete, check that importFiles1 is also complete
+    importFilesFuture2.get();
+    assertThat(importFilesFuture1.isDone()).isTrue();
+
+    // Ensure that file downloader was only invoked once
+    assertThat(fileDownloaderInvocationCount.get()).isEqualTo(1);
+
+    mobileDataDownload.clear().get();
+  }
+
+  @Test
+  public void importFiles_supportsCancellation() throws Exception {
+    // Use BlockingFileDownloader to block the import of a file indefinitely. Check that the future
+    // returned by importFiles fails with a cancellation exception
+    BlockingFileDownloader blockingFileDownloader =
+        new BlockingFileDownloader(
+            MoreExecutors.listeningDecorator(DOWNLOAD_EXECUTOR),
+            new FileDownloader() {
+              @Override
+              public ListenableFuture<Void> startDownloading(DownloadRequest downloadRequest) {
+                ListenableFuture<Void> importTaskFuture = Futures.immediateVoidFuture();
+                Futures.addCallback(
+                    importTaskFuture,
+                    new FutureCallback<Void>() {
+                      @Override
+                      public void onSuccess(Void result) {
+                        // Should not get here since we will cancel the future.
+                        fail();
+                      }
+
+                      @Override
+                      public void onFailure(Throwable t) {
+                        // Even though importTaskFuture was just created, this method should be
+                        // invoked in the future chain that gets cancelled -- Ensure that the
+                        // cancellation propagates to this future.
+                        assertThat(importTaskFuture.isCancelled()).isTrue();
+                      }
+                    },
+                    DOWNLOAD_EXECUTOR);
+                return importTaskFuture;
+              }
+            });
+
+    createMobileDataDownload(() -> blockingFileDownloader);
+
+    DataFileGroup fileGroupWithInlineFile =
+        DataFileGroup.newBuilder()
+            .setGroupName(FILE_GROUP_NAME)
+            .addFile(INLINE_DATA_FILE_1)
+            .build();
+
+    // Ensure that we add the file group successfully
+    assertThat(
+            mobileDataDownload
+                .addFileGroup(
+                    AddFileGroupRequest.newBuilder()
+                        .setDataFileGroup(fileGroupWithInlineFile)
+                        .build())
+                .get())
+        .isTrue();
+
+    // Perform the import
+    ListenableFuture<Void> importFilesFuture =
+        mobileDataDownload.importFiles(
+            ImportFilesRequest.newBuilder()
+                .setGroupName(FILE_GROUP_NAME)
+                .setBuildId(fileGroupWithInlineFile.getBuildId())
+                .setVariantId(fileGroupWithInlineFile.getVariantId())
+                .setInlineFileMap(ImmutableMap.of(FILE_ID_1, inlineFileSource1))
+                .build());
+
+    // Note: We could have a race condition when we call cancel() on the future, since the
+    // FileDownloader's startDownloading() may not have been invoked yet. To prevent this, we first
+    // wait for the file downloader to be invoked before performing the cancel.
+    blockingFileDownloader.waitForDownloadStarted();
+
+    importFilesFuture.cancel(/* mayInterruptIfRunning = */ true);
+
+    // Allow the download to continue and trigger our delegate FileDownloader. If the future isn't
+    // cancelled, the onSuccess callback should fail the test.
+    blockingFileDownloader.finishDownloading();
+    blockingFileDownloader.waitForDownloadCompleted();
+
+    assertThat(importFilesFuture.isCancelled()).isTrue();
+
+    mobileDataDownload.clear().get();
+  }
+
+  private void createMobileDataDownload(Supplier<FileDownloader> fileDownloaderSupplier) {
+    mobileDataDownload =
+        MobileDataDownloadBuilder.newBuilder()
+            .setContext(context)
+            .setControlExecutor(CONTROL_EXECUTOR)
+            .setFileDownloaderSupplier(fileDownloaderSupplier)
+            .setTaskScheduler(Optional.of(mockTaskScheduler))
+            .setDeltaDecoderOptional(Optional.absent())
+            .setFileStorage(fileStorage)
+            .setNetworkUsageMonitor(mockNetworkUsageMonitor)
+            .setDownloadMonitorOptional(Optional.of(mockDownloadProgressMonitor))
+            .setFlagsOptional(Optional.of(flags))
+            .build();
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/MddGarbageCollectionWithAndroidSharingIntegrationTest.java b/javatests/com/google/android/libraries/mobiledatadownload/MddGarbageCollectionWithAndroidSharingIntegrationTest.java
new file mode 100644
index 0000000..3d73e04
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/MddGarbageCollectionWithAndroidSharingIntegrationTest.java
@@ -0,0 +1,361 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.concurrent.TimeUnit.DAYS;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import android.app.blob.BlobStoreManager;
+import android.content.Context;
+import android.net.Uri;
+import android.util.Log;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.android.libraries.mobiledatadownload.downloader.FileDownloader;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.AndroidFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.backends.BlobStoreBackend;
+import com.google.android.libraries.mobiledatadownload.file.backends.BlobUri;
+import com.google.android.libraries.mobiledatadownload.file.backends.JavaFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.transforms.CompressTransform;
+import com.google.android.libraries.mobiledatadownload.internal.MddTestUtil;
+import com.google.android.libraries.mobiledatadownload.monitor.DownloadProgressMonitor;
+import com.google.android.libraries.mobiledatadownload.monitor.NetworkUsageMonitor;
+import com.google.android.libraries.mobiledatadownload.testing.TestFileDownloader;
+import com.google.android.libraries.mobiledatadownload.testing.TestFlags;
+import com.google.common.base.Optional;
+import com.google.common.base.Supplier;
+import com.google.common.collect.ImmutableList;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.mobiledatadownload.ClientConfigProto.ClientFile;
+import com.google.mobiledatadownload.ClientConfigProto.ClientFileGroup;
+import com.google.mobiledatadownload.DownloadConfigProto.DataFileGroup;
+import com.google.mobiledatadownload.DownloadConfigProto.DownloadConditions.DeviceNetworkPolicy;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+@RunWith(AndroidJUnit4.class)
+public final class MddGarbageCollectionWithAndroidSharingIntegrationTest {
+  private static final String TAG = "MddGarbageCollectionWithAndroidSharingIntegrationTest";
+  private static final int MAX_DOWNLOAD_FILE_GROUP_WAIT_TIME_SECS = 300;
+
+  private static final String TEST_DATA_RELATIVE_PATH =
+      "third_party/java_src/android_libs/mobiledatadownload/javatests/com/google/android/libraries/mobiledatadownload/testdata/";
+
+  // Note: Control Executor must not be a single thread executor.
+  private static final ListeningExecutorService CONTROL_EXECUTOR =
+      MoreExecutors.listeningDecorator(Executors.newCachedThreadPool());
+  private static final ScheduledExecutorService DOWNLOAD_EXECUTOR =
+      Executors.newScheduledThreadPool(2);
+
+  private static final String FILE_GROUP_TO_SHARE_1 = "test-group-1";
+  private static final String FILE_ID_1 = "test-file-to-share-1";
+  private static final String FILE_CHECKSUM_1 = "fcc96b272633cdf6c4bbd2d77512cca51bfb1dbd"; // SHA_1
+  static final String FILE_ANDROID_SHARING_CHECKSUM_1 =
+      "225017b5d5ec35732940af813b1ab7be5191e4c52659953e75a1a36a1398c48d"; // SHA_256
+  static final int FILE_SIZE_1 = 57;
+  static final String FILE_URL_1 = "https://www.gstatic.com/icing/idd/sample_group/step1.txt";
+
+  private static final Context context = ApplicationProvider.getApplicationContext();
+
+  @Mock private TaskScheduler mockTaskScheduler;
+  @Mock private NetworkUsageMonitor mockNetworkUsageMonitor;
+  @Mock private DownloadProgressMonitor mockDownloadProgressMonitor;
+  @Mock private Logger mockLogger;
+
+  private SynchronousFileStorage fileStorage;
+  private BlobStoreManager blobStoreManager;
+  private MobileDataDownload mobileDataDownload;
+
+  private final TestFlags flags = new TestFlags();
+
+  @Rule public final MockitoRule mocks = MockitoJUnit.rule();
+
+  @Before
+  public void setUp() throws Exception {
+    flags.mddAndroidSharingSampleInterval = Optional.of(1);
+    flags.mddDefaultSampleInterval = Optional.of(1);
+    BlobStoreBackend blobStoreBackend = new BlobStoreBackend(context);
+    blobStoreManager = (BlobStoreManager) context.getSystemService(Context.BLOB_STORE_SERVICE);
+
+    fileStorage =
+        new SynchronousFileStorage(
+            /* backends= */ ImmutableList.of(
+                AndroidFileBackend.builder(context).build(),
+                blobStoreBackend,
+                new JavaFileBackend()),
+            /* transforms= */ ImmutableList.of(new CompressTransform()),
+            /* monitors= */ ImmutableList.of(mockNetworkUsageMonitor, mockDownloadProgressMonitor));
+    Supplier<FileDownloader> fileDownloaderSupplier =
+        () ->
+            new TestFileDownloader(
+                TEST_DATA_RELATIVE_PATH,
+                fileStorage,
+                MoreExecutors.listeningDecorator(DOWNLOAD_EXECUTOR));
+
+    mobileDataDownload =
+        MobileDataDownloadBuilder.newBuilder()
+            .setContext(context)
+            .setControlExecutor(CONTROL_EXECUTOR)
+            .setFileDownloaderSupplier(fileDownloaderSupplier)
+            .setTaskScheduler(Optional.of(mockTaskScheduler))
+            .setDeltaDecoderOptional(Optional.absent())
+            .setFileStorage(fileStorage)
+            .setNetworkUsageMonitor(mockNetworkUsageMonitor)
+            .setDownloadMonitorOptional(Optional.of(mockDownloadProgressMonitor))
+            .setLoggerOptional(Optional.of(mockLogger))
+            .setFlagsOptional(Optional.of(flags))
+            .build();
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    mobileDataDownload.clear().get();
+    // Commands to clean up the blob storage.
+    MddTestUtil.runShellCmd("cmd blob_store clear-all-sessions");
+    MddTestUtil.runShellCmd("cmd blob_store clear-all-blobs");
+  }
+
+  private void downloadFileGroup(DataFileGroup fileGroup) throws Exception {
+    assertThat(
+            mobileDataDownload
+                .addFileGroup(AddFileGroupRequest.newBuilder().setDataFileGroup(fileGroup).build())
+                .get())
+        .isTrue();
+    mobileDataDownload
+        .downloadFileGroup(
+            DownloadFileGroupRequest.newBuilder()
+                .setGroupName(fileGroup.getGroupName())
+                .setListenerOptional(
+                    Optional.of(
+                        new DownloadListener() {
+                          @Override
+                          public void onProgress(long currentSize) {
+                            Log.i(TAG, "onProgress " + currentSize);
+                          }
+
+                          @Override
+                          public void onComplete(ClientFileGroup clientFileGroup) {
+                            Log.i(TAG, "onComplete " + clientFileGroup.getGroupName());
+                          }
+                        }))
+                .build())
+        .get(MAX_DOWNLOAD_FILE_GROUP_WAIT_TIME_SECS, SECONDS);
+  }
+
+  private ClientFileGroup verifyDownloadedGroupIsDownloaded(DataFileGroup fileGroup, int fileCount)
+      throws Exception {
+    ClientFileGroup clientFileGroup =
+        mobileDataDownload
+            .getFileGroup(
+                GetFileGroupRequest.newBuilder().setGroupName(fileGroup.getGroupName()).build())
+            .get();
+
+    assertThat(clientFileGroup).isNotNull();
+    assertThat(clientFileGroup.getGroupName()).isEqualTo(fileGroup.getGroupName());
+    assertThat(clientFileGroup.getFileCount()).isEqualTo(fileCount);
+    return clientFileGroup;
+  }
+
+  @Test
+  public void deletesStaleGroups_staleLifetimeZero() throws Exception {
+    Uri androidUri =
+        BlobUri.builder(context).setBlobParameters(FILE_ANDROID_SHARING_CHECKSUM_1).build();
+    assertThat(fileStorage.exists(androidUri)).isFalse();
+
+    // Download file group with stale lifetime 0.
+    DataFileGroup fileGroup =
+        TestFileGroupPopulator.createDataFileGroup(
+            FILE_GROUP_TO_SHARE_1,
+            context.getPackageName(),
+            new String[] {FILE_ID_1},
+            new int[] {FILE_SIZE_1},
+            new String[] {FILE_CHECKSUM_1},
+            new String[] {FILE_ANDROID_SHARING_CHECKSUM_1},
+            new String[] {FILE_URL_1},
+            DeviceNetworkPolicy.DOWNLOAD_ON_ANY_NETWORK);
+
+    downloadFileGroup(fileGroup);
+
+    ClientFileGroup clientFileGroup = verifyDownloadedGroupIsDownloaded(fileGroup, 1);
+
+    ClientFile clientFile = clientFileGroup.getFileList().get(0);
+    assertThat(clientFile.getFileId()).isEqualTo(FILE_ID_1);
+    Uri uri = Uri.parse(clientFile.getFileUri());
+
+    // The file is now available in the android shared storage.
+    assertThat(uri).isEqualTo(androidUri);
+    assertThat(fileStorage.exists(uri)).isTrue();
+    assertThat(blobStoreManager.getLeasedBlobs()).hasSize(1);
+
+    // Send an empty group so that the old group is now stale.
+    DataFileGroup emptyFileGroup =
+        TestFileGroupPopulator.createDataFileGroup(
+            FILE_GROUP_TO_SHARE_1,
+            context.getPackageName(),
+            new String[] {},
+            new int[] {},
+            new String[] {},
+            new String[] {},
+            new String[] {},
+            DeviceNetworkPolicy.DOWNLOAD_ON_ANY_NETWORK);
+
+    downloadFileGroup(emptyFileGroup);
+
+    // Run maintenance taks.
+    mobileDataDownload.maintenance().get(MAX_DOWNLOAD_FILE_GROUP_WAIT_TIME_SECS, SECONDS);
+
+    verifyDownloadedGroupIsDownloaded(emptyFileGroup, 0);
+
+    // Old stale file has been released
+    assertThat(blobStoreManager.getLeasedBlobs()).isEmpty();
+
+    // Verify logging events.
+  }
+
+  @Test
+  public void deletesStaleGroups_staleLifetimeTwoDays() throws Exception {
+    Uri androidUri =
+        BlobUri.builder(context).setBlobParameters(FILE_ANDROID_SHARING_CHECKSUM_1).build();
+    assertThat(fileStorage.exists(androidUri)).isFalse();
+
+    // Download file group with stale lifetime +2 days.
+    DataFileGroup fileGroup =
+        TestFileGroupPopulator.createDataFileGroup(
+            FILE_GROUP_TO_SHARE_1,
+            context.getPackageName(),
+            new String[] {FILE_ID_1},
+            new int[] {FILE_SIZE_1},
+            new String[] {FILE_CHECKSUM_1},
+            new String[] {FILE_ANDROID_SHARING_CHECKSUM_1},
+            new String[] {FILE_URL_1},
+            DeviceNetworkPolicy.DOWNLOAD_ON_ANY_NETWORK);
+    fileGroup = fileGroup.toBuilder().setStaleLifetimeSecs(DAYS.toSeconds(2)).build();
+
+    downloadFileGroup(fileGroup);
+
+    ClientFileGroup clientFileGroup = verifyDownloadedGroupIsDownloaded(fileGroup, 1);
+
+    ClientFile clientFile = clientFileGroup.getFileList().get(0);
+    assertThat(clientFile.getFileId()).isEqualTo(FILE_ID_1);
+    Uri uri = Uri.parse(clientFile.getFileUri());
+
+    // The file is now available in the android shared storage.
+    assertThat(uri).isEqualTo(androidUri);
+    assertThat(fileStorage.exists(uri)).isTrue();
+    assertThat(blobStoreManager.getLeasedBlobs()).hasSize(1);
+
+    // Send an empty group so that the old group is now stale.
+    DataFileGroup emptyFileGroup =
+        TestFileGroupPopulator.createDataFileGroup(
+            FILE_GROUP_TO_SHARE_1,
+            context.getPackageName(),
+            new String[] {},
+            new int[] {},
+            new String[] {},
+            new String[] {},
+            new String[] {},
+            DeviceNetworkPolicy.DOWNLOAD_ON_ANY_NETWORK);
+    downloadFileGroup(emptyFileGroup);
+
+    // Run maintenance taks.
+    mobileDataDownload.maintenance().get(MAX_DOWNLOAD_FILE_GROUP_WAIT_TIME_SECS, SECONDS);
+
+    verifyDownloadedGroupIsDownloaded(emptyFileGroup, 0);
+
+    // Old stale file hasn't been released yet
+    assertThat(blobStoreManager.getLeasedBlobs()).hasSize(1);
+
+    // Advance time by 2 days, and verify that the lease on the shared file has been
+    // released.
+    MddTestUtil.timeTravel(context, DAYS.toMillis(2));
+    mobileDataDownload.maintenance().get(MAX_DOWNLOAD_FILE_GROUP_WAIT_TIME_SECS, SECONDS);
+
+    verifyDownloadedGroupIsDownloaded(emptyFileGroup, 0);
+
+    assertThat(blobStoreManager.getLeasedBlobs()).isEmpty();
+
+    // Verify logging events.
+  }
+
+  @Test
+  public void deletesExpiredGroups() throws Exception {
+    Uri androidUri =
+        BlobUri.builder(context).setBlobParameters(FILE_ANDROID_SHARING_CHECKSUM_1).build();
+    assertThat(fileStorage.exists(androidUri)).isFalse();
+
+    // Download file group with stale lifetime +2 days.
+    DataFileGroup fileGroup =
+        TestFileGroupPopulator.createDataFileGroup(
+            FILE_GROUP_TO_SHARE_1,
+            context.getPackageName(),
+            new String[] {FILE_ID_1},
+            new int[] {FILE_SIZE_1},
+            new String[] {FILE_CHECKSUM_1},
+            new String[] {FILE_ANDROID_SHARING_CHECKSUM_1},
+            new String[] {FILE_URL_1},
+            DeviceNetworkPolicy.DOWNLOAD_ON_ANY_NETWORK);
+
+    // It expires in two days.
+    fileGroup = fileGroup.toBuilder().setExpirationDate(MddTestUtil.daysFromNow(2)).build();
+
+    downloadFileGroup(fileGroup);
+
+    ClientFileGroup clientFileGroup = verifyDownloadedGroupIsDownloaded(fileGroup, 1);
+
+    ClientFile clientFile = clientFileGroup.getFileList().get(0);
+    assertThat(clientFile.getFileId()).isEqualTo(FILE_ID_1);
+    Uri uri = Uri.parse(clientFile.getFileUri());
+
+    // The file is now available in the android shared storage.
+    assertThat(uri).isEqualTo(androidUri);
+    assertThat(fileStorage.exists(uri)).isTrue();
+    assertThat(blobStoreManager.getLeasedBlobs()).hasSize(1);
+
+    // Run maintenance tasks and verify that we still own the lease om the shared file.
+    mobileDataDownload.maintenance().get(MAX_DOWNLOAD_FILE_GROUP_WAIT_TIME_SECS, SECONDS);
+
+    verifyDownloadedGroupIsDownloaded(fileGroup, 1);
+
+    assertThat(blobStoreManager.getLeasedBlobs()).hasSize(1);
+
+    // Advance time by 3 days, and verify that the group and files can no longer be read
+    // because they expired.
+    MddTestUtil.timeTravel(context, DAYS.toMillis(3));
+    mobileDataDownload.maintenance().get(MAX_DOWNLOAD_FILE_GROUP_WAIT_TIME_SECS, SECONDS);
+    clientFileGroup =
+        mobileDataDownload
+            .getFileGroup(
+                GetFileGroupRequest.newBuilder().setGroupName(FILE_GROUP_TO_SHARE_1).build())
+            .get();
+    assertThat(clientFileGroup).isNull();
+
+    assertThat(blobStoreManager.getLeasedBlobs()).isEmpty();
+
+    // Verify logging events.
+
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/MobileDataDownloadIntegrationTest.java b/javatests/com/google/android/libraries/mobiledatadownload/MobileDataDownloadIntegrationTest.java
new file mode 100644
index 0000000..c94532e
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/MobileDataDownloadIntegrationTest.java
@@ -0,0 +1,887 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+import static android.system.Os.readlink;
+import static com.google.android.libraries.mobiledatadownload.TestFileGroupPopulator.FILE_CHECKSUM;
+import static com.google.android.libraries.mobiledatadownload.TestFileGroupPopulator.FILE_GROUP_NAME;
+import static com.google.android.libraries.mobiledatadownload.TestFileGroupPopulator.FILE_ID;
+import static com.google.android.libraries.mobiledatadownload.TestFileGroupPopulator.FILE_SIZE;
+import static com.google.android.libraries.mobiledatadownload.TestFileGroupPopulator.FILE_URL;
+import static com.google.android.libraries.mobiledatadownload.tracing.TracePropagation.propagateCallable;
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.fail;
+
+import android.accounts.Account;
+import android.content.Context;
+import android.net.Uri;
+import android.util.Log;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.runner.AndroidJUnit4;
+import com.google.android.libraries.mobiledatadownload.account.AccountUtil;
+import com.google.android.libraries.mobiledatadownload.downloader.FileDownloader;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.AndroidFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.backends.AndroidUri;
+import com.google.android.libraries.mobiledatadownload.file.backends.AndroidUriAdapter;
+import com.google.android.libraries.mobiledatadownload.file.backends.JavaFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.openers.ReadStringOpener;
+import com.google.android.libraries.mobiledatadownload.file.openers.WriteStringOpener;
+import com.google.android.libraries.mobiledatadownload.internal.util.DirectoryUtil;
+import com.google.android.libraries.mobiledatadownload.monitor.NetworkUsageMonitor;
+import com.google.android.libraries.mobiledatadownload.testing.BlockingFileDownloader;
+import com.google.android.libraries.mobiledatadownload.testing.FakeTimeSource;
+import com.google.android.libraries.mobiledatadownload.testing.TestFileDownloader;
+import com.google.android.libraries.mobiledatadownload.testing.TestFlags;
+import com.google.common.base.Optional;
+import com.google.common.base.Supplier;
+import com.google.common.collect.ImmutableList;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.mobiledatadownload.ClientConfigProto.ClientFile;
+import com.google.mobiledatadownload.ClientConfigProto.ClientFileGroup;
+import com.google.mobiledatadownload.DownloadConfigProto.DataFile;
+import com.google.mobiledatadownload.DownloadConfigProto.DataFile.ChecksumType;
+import com.google.mobiledatadownload.DownloadConfigProto.DataFileGroup;
+import com.google.mobiledatadownload.DownloadConfigProto.DownloadConditions;
+import com.google.mobiledatadownload.DownloadConfigProto.DownloadConditions.DeviceNetworkPolicy;
+import java.io.IOException;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeoutException;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+@RunWith(AndroidJUnit4.class)
+public class MobileDataDownloadIntegrationTest {
+
+  private static final String TAG = "MobileDataDownloadIntegrationTest";
+  private static final int MAX_HANDLE_TASK_WAIT_TIME_SECS = 300;
+
+  private static final String TEST_DATA_RELATIVE_PATH =
+      "third_party/java_src/android_libs/mobiledatadownload/javatests/com/google/android/libraries/mobiledatadownload/testdata/";
+
+  // Note: Control Executor must not be a single thread executor.
+  private static final ListeningExecutorService CONTROL_EXECUTOR =
+      MoreExecutors.listeningDecorator(Executors.newCachedThreadPool());
+  private static final ScheduledExecutorService DOWNLOAD_EXECUTOR =
+      Executors.newScheduledThreadPool(2);
+
+  private static final Context context = ApplicationProvider.getApplicationContext();
+  private final NetworkUsageMonitor networkUsageMonitor =
+      new NetworkUsageMonitor(context, new FakeTimeSource());
+
+  private final SynchronousFileStorage fileStorage =
+      new SynchronousFileStorage(
+          ImmutableList.of(AndroidFileBackend.builder(context).build(), new JavaFileBackend()),
+          ImmutableList.of(),
+          ImmutableList.of(networkUsageMonitor));
+
+  private final TestFlags flags = new TestFlags();
+
+  @Mock private Logger mockLogger;
+  @Mock private TaskScheduler mockTaskScheduler;
+
+  @Rule public final MockitoRule mocks = MockitoJUnit.rule();
+
+  @Before
+  public void setUp() throws Exception {
+    flags.enableZipFolder = Optional.of(true);
+  }
+
+  @Test
+  public void download_success_fileGroupDownloaded() throws Exception {
+    MobileDataDownload mobileDataDownload =
+        getMobileDataDownloadAfterDownload(
+            () ->
+                new TestFileDownloader(
+                    TEST_DATA_RELATIVE_PATH,
+                    fileStorage,
+                    MoreExecutors.listeningDecorator(DOWNLOAD_EXECUTOR)),
+            new TestFileGroupPopulator(context));
+
+    // This will trigger refreshing of FileGroupPopulators and downloading.
+    mobileDataDownload
+        .handleTask(TaskScheduler.CELLULAR_CHARGING_PERIODIC_TASK)
+        .get(MAX_HANDLE_TASK_WAIT_TIME_SECS, SECONDS);
+
+    String debugString = mobileDataDownload.getDebugInfoAsString();
+    Log.i(TAG, "MDD Lib dump:");
+    for (String line : debugString.split("\n", -1)) {
+      Log.i(TAG, line);
+    }
+
+    ClientFileGroup clientFileGroup =
+        getAndVerifyClientFileGroup(mobileDataDownload, FILE_GROUP_NAME, 1);
+    verifyClientFile(clientFileGroup.getFileList().get(0), FILE_ID, FILE_SIZE);
+    mobileDataDownload.clear().get();
+  }
+
+  @Test
+  public void download_withCustomValidator() throws Exception {
+    CustomFileGroupValidator validator =
+        fileGroup -> {
+          if (!fileGroup.getGroupName().equals(FILE_GROUP_NAME)) {
+            return Futures.immediateFuture(true);
+          }
+          return MoreExecutors.listeningDecorator(DOWNLOAD_EXECUTOR)
+              .submit(
+                  propagateCallable(
+                      () -> {
+                        SynchronousFileStorage storage =
+                            new SynchronousFileStorage(
+                                ImmutableList.of(AndroidFileBackend.builder(context).build()));
+                        for (ClientFile file : fileGroup.getFileList()) {
+                          if (!storage.exists(Uri.parse(file.getFileUri()))) {
+                            return false;
+                          }
+                        }
+                        return true;
+                      }));
+        };
+
+    MobileDataDownload mobileDataDownload =
+        getMobileDataDownloadBuilder(
+                () ->
+                    new TestFileDownloader(
+                        TEST_DATA_RELATIVE_PATH,
+                        fileStorage,
+                        MoreExecutors.listeningDecorator(DOWNLOAD_EXECUTOR)),
+                new TestFileGroupPopulator(context))
+            .setCustomFileGroupValidatorOptional(Optional.of(validator))
+            .build();
+
+    // This will trigger refreshing of FileGroupPopulators and downloading.
+    mobileDataDownload
+        .handleTask(TaskScheduler.CELLULAR_CHARGING_PERIODIC_TASK)
+        .get(MAX_HANDLE_TASK_WAIT_TIME_SECS, SECONDS);
+
+    ClientFileGroup clientFileGroup =
+        mobileDataDownload
+            .getFileGroup(GetFileGroupRequest.newBuilder().setGroupName(FILE_GROUP_NAME).build())
+            .get();
+    verifyClientFile(clientFileGroup.getFileList().get(0), FILE_ID, FILE_SIZE);
+
+    mobileDataDownload.clear().get();
+  }
+
+  @Test
+  public void download_success_maintenanceLogsNetworkUsage() throws Exception {
+    flags.networkStatsLoggingSampleInterval = Optional.of(1);
+
+    MobileDataDownload mobileDataDownload =
+        getMobileDataDownload(
+            () ->
+                new TestFileDownloader(
+                    TEST_DATA_RELATIVE_PATH,
+                    fileStorage,
+                    MoreExecutors.listeningDecorator(DOWNLOAD_EXECUTOR)),
+            new TestFileGroupPopulator(context));
+
+    // This will trigger refreshing of FileGroupPopulators and downloading.
+    mobileDataDownload
+        .handleTask(TaskScheduler.CELLULAR_CHARGING_PERIODIC_TASK)
+        .get(MAX_HANDLE_TASK_WAIT_TIME_SECS, SECONDS);
+
+    // This should flush the logs from NetworkLogger.
+    mobileDataDownload
+        .handleTask(TaskScheduler.MAINTENANCE_PERIODIC_TASK)
+        .get(MAX_HANDLE_TASK_WAIT_TIME_SECS, SECONDS);
+
+    ClientFileGroup clientFileGroup =
+        getAndVerifyClientFileGroup(mobileDataDownload, FILE_GROUP_NAME, 1);
+    verifyClientFile(clientFileGroup.getFileList().get(0), FILE_ID, FILE_SIZE);
+
+    mobileDataDownload.clear().get();
+  }
+
+  @Test
+  public void corrupted_files_detectedDuringMaintenance() throws Exception {
+    flags.mddDefaultSampleInterval = Optional.of(1);
+    MobileDataDownload mobileDataDownload =
+        getMobileDataDownloadAfterDownload(
+            () ->
+                new TestFileDownloader(
+                    TEST_DATA_RELATIVE_PATH,
+                    fileStorage,
+                    MoreExecutors.listeningDecorator(DOWNLOAD_EXECUTOR)),
+            new TestFileGroupPopulator(context));
+
+    ClientFileGroup clientFileGroup =
+        getAndVerifyClientFileGroup(mobileDataDownload, FILE_GROUP_NAME, 1);
+    fileStorage.open(
+        Uri.parse(clientFileGroup.getFile(0).getFileUri()), WriteStringOpener.create("c0rrupt3d"));
+
+    // Bad file is detected during maintenance.
+    mobileDataDownload
+        .handleTask(TaskScheduler.MAINTENANCE_PERIODIC_TASK)
+        .get(MAX_HANDLE_TASK_WAIT_TIME_SECS, SECONDS);
+
+    // File group is re-downloaded.
+    mobileDataDownload
+        .handleTask(TaskScheduler.CELLULAR_CHARGING_PERIODIC_TASK)
+        .get(MAX_HANDLE_TASK_WAIT_TIME_SECS, SECONDS);
+
+    // Re-load the file group since the on-disk URIs will have changed.
+    clientFileGroup = getAndVerifyClientFileGroup(mobileDataDownload, FILE_GROUP_NAME, 1);
+    assertThat(
+            fileStorage.open(
+                Uri.parse(clientFileGroup.getFile(0).getFileUri()), ReadStringOpener.create()))
+        .isNotEqualTo("c0rrupt3d");
+
+    mobileDataDownload.clear().get();
+  }
+
+  @Test
+  public void delete_files_detectedDuringMaintenance() throws Exception {
+    flags.mddDefaultSampleInterval = Optional.of(1);
+    MobileDataDownload mobileDataDownload =
+        getMobileDataDownloadAfterDownload(
+            () ->
+                new TestFileDownloader(
+                    TEST_DATA_RELATIVE_PATH,
+                    fileStorage,
+                    MoreExecutors.listeningDecorator(DOWNLOAD_EXECUTOR)),
+            new TestFileGroupPopulator(context));
+
+    ClientFileGroup clientFileGroup =
+        getAndVerifyClientFileGroup(mobileDataDownload, FILE_GROUP_NAME, 1);
+    fileStorage.deleteFile(Uri.parse(clientFileGroup.getFile(0).getFileUri()));
+
+    // Bad file is detected during maintenance.
+    mobileDataDownload
+        .handleTask(TaskScheduler.MAINTENANCE_PERIODIC_TASK)
+        .get(MAX_HANDLE_TASK_WAIT_TIME_SECS, SECONDS);
+
+    // File group is re-downloaded.
+    mobileDataDownload
+        .handleTask(TaskScheduler.CELLULAR_CHARGING_PERIODIC_TASK)
+        .get(MAX_HANDLE_TASK_WAIT_TIME_SECS, SECONDS);
+
+    // Re-load the file group since the on-disk URIs will have changed.
+    clientFileGroup = getAndVerifyClientFileGroup(mobileDataDownload, FILE_GROUP_NAME, 1);
+    assertThat(fileStorage.exists(Uri.parse(clientFileGroup.getFile(0).getFileUri()))).isTrue();
+
+    mobileDataDownload.clear().get();
+  }
+
+  @Test
+  public void remove_withAccount_fileGroupRemains() throws Exception {
+    Supplier<FileDownloader> fileDownloaderSupplier =
+        () ->
+            new TestFileDownloader(
+                TEST_DATA_RELATIVE_PATH,
+                fileStorage,
+                MoreExecutors.listeningDecorator(DOWNLOAD_EXECUTOR));
+
+    MobileDataDownload mobileDataDownload =
+        getMobileDataDownloadAfterDownload(
+            fileDownloaderSupplier, new TestFileGroupPopulator(context));
+
+    // This will trigger refreshing of FileGroupPopulators and downloading.
+    mobileDataDownload
+        .handleTask(TaskScheduler.CELLULAR_CHARGING_PERIODIC_TASK)
+        .get(MAX_HANDLE_TASK_WAIT_TIME_SECS, SECONDS);
+
+    // Remove the file group with account doesn't change anything, because the test group is not
+    // associated with any account.
+    Account account = AccountUtil.create("name", "google");
+    assertThat(account).isNotNull();
+    assertThat(
+            mobileDataDownload
+                .removeFileGroup(
+                    RemoveFileGroupRequest.newBuilder()
+                        .setGroupName(FILE_GROUP_NAME)
+                        .setAccountOptional(Optional.of(account))
+                        .build())
+                .get())
+        .isTrue();
+
+    ClientFileGroup clientFileGroup =
+        getAndVerifyClientFileGroup(mobileDataDownload, FILE_GROUP_NAME, 1);
+    verifyClientFile(clientFileGroup.getFileList().get(0), FILE_ID, FILE_SIZE);
+    mobileDataDownload.clear().get();
+  }
+
+  @Test
+  public void remove_withoutAccount_fileGroupRemoved() throws Exception {
+    Supplier<FileDownloader> fileDownloaderSupplier =
+        () ->
+            new TestFileDownloader(
+                TEST_DATA_RELATIVE_PATH,
+                fileStorage,
+                MoreExecutors.listeningDecorator(DOWNLOAD_EXECUTOR));
+
+    MobileDataDownload mobileDataDownload =
+        getMobileDataDownloadAfterDownload(
+            fileDownloaderSupplier, new TestFileGroupPopulator(context));
+
+    // This will trigger refreshing of FileGroupPopulators and downloading.
+    mobileDataDownload
+        .handleTask(TaskScheduler.CELLULAR_CHARGING_PERIODIC_TASK)
+        .get(MAX_HANDLE_TASK_WAIT_TIME_SECS, SECONDS);
+
+    // Remove the file group will make the file group not accessible from clients.
+    assertThat(
+            mobileDataDownload
+                .removeFileGroup(
+                    RemoveFileGroupRequest.newBuilder().setGroupName(FILE_GROUP_NAME).build())
+                .get())
+        .isTrue();
+
+    ClientFileGroup clientFileGroup =
+        mobileDataDownload
+            .getFileGroup(GetFileGroupRequest.newBuilder().setGroupName(FILE_GROUP_NAME).build())
+            .get();
+    assertThat(clientFileGroup).isNull();
+    mobileDataDownload.clear().get();
+  }
+
+  @Test
+  public void
+      removeFileGroupsByFilter_whenAccountNotSpecified_removesMatchingAccountIndependentGroups()
+          throws Exception {
+    Supplier<FileDownloader> fileDownloaderSupplier =
+        () ->
+            new TestFileDownloader(
+                TEST_DATA_RELATIVE_PATH,
+                fileStorage,
+                MoreExecutors.listeningDecorator(DOWNLOAD_EXECUTOR));
+
+    MobileDataDownload mobileDataDownload =
+        getMobileDataDownload(fileDownloaderSupplier, unused -> Futures.immediateVoidFuture());
+
+    // Remove all groups
+    mobileDataDownload
+        .removeFileGroupsByFilter(RemoveFileGroupsByFilterRequest.newBuilder().build())
+        .get();
+
+    // Setup account
+    Account account = AccountUtil.create("name", "google");
+
+    // Setup two groups, 1 with account and 1 without an account
+    DataFileGroup fileGroupWithoutAccount =
+        TestFileGroupPopulator.createDataFileGroup(
+                FILE_GROUP_NAME,
+                context.getPackageName(),
+                new String[] {FILE_ID},
+                new int[] {FILE_SIZE},
+                new String[] {FILE_CHECKSUM},
+                new String[] {FILE_URL},
+                DeviceNetworkPolicy.DOWNLOAD_ON_ANY_NETWORK)
+            .toBuilder()
+            .build();
+    DataFileGroup fileGroupWithAccount =
+        fileGroupWithoutAccount.toBuilder().setGroupName(FILE_GROUP_NAME + "_2").build();
+
+    // Add both groups to MDD
+    mobileDataDownload
+        .addFileGroup(
+            AddFileGroupRequest.newBuilder().setDataFileGroup(fileGroupWithoutAccount).build())
+        .get();
+    mobileDataDownload
+        .addFileGroup(
+            AddFileGroupRequest.newBuilder()
+                .setDataFileGroup(fileGroupWithAccount)
+                .setAccountOptional(Optional.of(account))
+                .build())
+        .get();
+
+    // Verify that both groups are present
+    assertThat(
+            mobileDataDownload
+                .getFileGroupsByFilter(
+                    GetFileGroupsByFilterRequest.newBuilder().setIncludeAllGroups(true).build())
+                .get())
+        .hasSize(2);
+
+    // Remove file groups with given source only
+    mobileDataDownload
+        .removeFileGroupsByFilter(RemoveFileGroupsByFilterRequest.newBuilder().build())
+        .get();
+
+    // Check that only account-dependent group remains
+    ImmutableList<ClientFileGroup> remainingGroups =
+        mobileDataDownload
+            .getFileGroupsByFilter(
+                GetFileGroupsByFilterRequest.newBuilder().setIncludeAllGroups(true).build())
+            .get();
+    assertThat(remainingGroups).hasSize(1);
+    assertThat(remainingGroups.get(0).getGroupName()).isEqualTo(FILE_GROUP_NAME + "_2");
+
+    // Tear down: remove remaining group to prevent cross test errors
+    mobileDataDownload
+        .removeFileGroupsByFilter(
+            RemoveFileGroupsByFilterRequest.newBuilder()
+                .setAccountOptional(Optional.of(account))
+                .build())
+        .get();
+  }
+
+  @Test
+  public void download_failure_throwsDownloadException() throws Exception {
+    flags.mddDefaultSampleInterval = Optional.of(1);
+
+    Supplier<FileDownloader> fileDownloaderSupplier =
+        () ->
+            new TestFileDownloader(
+                TEST_DATA_RELATIVE_PATH,
+                fileStorage,
+                MoreExecutors.listeningDecorator(DOWNLOAD_EXECUTOR));
+
+    MobileDataDownload mobileDataDownload =
+        getMobileDataDownload(fileDownloaderSupplier, new TestFileGroupPopulator(context));
+
+    DataFileGroup dataFileGroup =
+        TestFileGroupPopulator.createDataFileGroup(
+            FILE_GROUP_NAME,
+            context.getPackageName(),
+            new String[] {"one", "two"},
+            new int[] {1000, 2000},
+            new String[] {"checksum1", "checksum2"},
+            new String[] {
+              "http://www.gstatic.com/", // This url is not secure.
+              "https://www.gstatic.com/does_not_exist" // This url does not exist.
+            },
+            DeviceNetworkPolicy.DOWNLOAD_ON_ANY_NETWORK);
+
+    assertThat(
+            mobileDataDownload
+                .addFileGroup(
+                    AddFileGroupRequest.newBuilder().setDataFileGroup(dataFileGroup).build())
+                .get())
+        .isTrue();
+
+    ListenableFuture<ClientFileGroup> downloadFuture =
+        mobileDataDownload.downloadFileGroup(
+            DownloadFileGroupRequest.newBuilder().setGroupName(FILE_GROUP_NAME).build());
+
+    ExecutionException exception = assertThrows(ExecutionException.class, downloadFuture::get);
+    assertThat(exception).hasCauseThat().isInstanceOf(AggregateException.class);
+    AggregateException cause = (AggregateException) exception.getCause();
+    assertThat(cause).isNotNull();
+    ImmutableList<Throwable> failures = cause.getFailures();
+    assertThat(failures).hasSize(2);
+    assertThat(failures.get(0)).isInstanceOf(DownloadException.class);
+    assertThat(failures.get(1)).isInstanceOf(DownloadException.class);
+  }
+
+  @Test
+  public void download_failure_logsEvent() throws Exception {
+    flags.mddDefaultSampleInterval = Optional.of(1);
+
+    Supplier<FileDownloader> fileDownloaderSupplier =
+        () ->
+            new TestFileDownloader(
+                TEST_DATA_RELATIVE_PATH,
+                fileStorage,
+                MoreExecutors.listeningDecorator(DOWNLOAD_EXECUTOR));
+
+    MobileDataDownload mobileDataDownload =
+        getMobileDataDownload(fileDownloaderSupplier, new TestFileGroupPopulator(context));
+
+    DataFileGroup dataFileGroup =
+        TestFileGroupPopulator.createDataFileGroup(
+            FILE_GROUP_NAME,
+            context.getPackageName(),
+            new String[] {"one", "two"},
+            new int[] {1000, 2000},
+            new String[] {"checksum1", "checksum2"},
+            new String[] {
+              "http://www.gstatic.com/", // This url is not secure.
+              "https://www.gstatic.com/does_not_exist" // This url does not exist.
+            },
+            DeviceNetworkPolicy.DOWNLOAD_ON_ANY_NETWORK);
+
+    assertThat(
+            mobileDataDownload
+                .addFileGroup(
+                    AddFileGroupRequest.newBuilder().setDataFileGroup(dataFileGroup).build())
+                .get())
+        .isTrue();
+
+    ListenableFuture<ClientFileGroup> downloadFuture =
+        mobileDataDownload.downloadFileGroup(
+            DownloadFileGroupRequest.newBuilder().setGroupName(FILE_GROUP_NAME).build());
+
+    assertThrows(ExecutionException.class, downloadFuture::get);
+  }
+
+  @Test
+  public void download_zipFile_unzippedAfterDownload() throws Exception {
+    Supplier<FileDownloader> fileDownloaderSupplier =
+        () ->
+            new TestFileDownloader(
+                TEST_DATA_RELATIVE_PATH,
+                fileStorage,
+                MoreExecutors.listeningDecorator(DOWNLOAD_EXECUTOR));
+
+    MobileDataDownload mobileDataDownload =
+        getMobileDataDownloadAfterDownload(
+            fileDownloaderSupplier, new ZipFolderFileGroupPopulator(context));
+    ClientFileGroup clientFileGroup =
+        getAndVerifyClientFileGroup(
+            mobileDataDownload, ZipFolderFileGroupPopulator.FILE_GROUP_NAME, 3);
+
+    for (ClientFile clientFile : clientFileGroup.getFileList()) {
+      if ("/zip1.txt".equals(clientFile.getFileId())) {
+        verifyClientFile(clientFile, "/zip1.txt", 11);
+      } else if ("/zip2.txt".equals(clientFile.getFileId())) {
+        verifyClientFile(clientFile, "/zip2.txt", 11);
+      } else if ("/sub_folder/zip3.txt".equals(clientFile.getFileId())) {
+        verifyClientFile(clientFile, "/sub_folder/zip3.txt", 25);
+      } else {
+        fail("Unexpect file:" + clientFile.getFileId());
+      }
+    }
+  }
+
+  @Test
+  public void download_cancelDuringDownload_downloadCancelled() throws Exception {
+    BlockingFileDownloader blockingFileDownloader = new BlockingFileDownloader(CONTROL_EXECUTOR);
+
+    Supplier<FileDownloader> fakeFileDownloaderSupplier = () -> blockingFileDownloader;
+
+    MobileDataDownload mobileDataDownload =
+        getMobileDataDownload(fakeFileDownloaderSupplier, new TestFileGroupPopulator(context));
+
+    // Register the file group and trigger download.
+    mobileDataDownload
+        .addFileGroup(
+            AddFileGroupRequest.newBuilder()
+                .setDataFileGroup(
+                    TestFileGroupPopulator.createDataFileGroup(
+                        FILE_GROUP_NAME,
+                        context.getPackageName(),
+                        new String[] {FILE_ID},
+                        new int[] {FILE_SIZE},
+                        new String[] {FILE_CHECKSUM},
+                        new String[] {FILE_URL},
+                        DeviceNetworkPolicy.DOWNLOAD_ON_ANY_NETWORK))
+                .build())
+        .get();
+    ListenableFuture<ClientFileGroup> downloadFuture =
+        mobileDataDownload.downloadFileGroup(
+            DownloadFileGroupRequest.newBuilder().setGroupName(FILE_GROUP_NAME).build());
+
+    // Wait for download to be scheduled. The future shouldn't be done yet.
+    blockingFileDownloader.waitForDownloadStarted();
+    assertThat(downloadFuture.isDone()).isFalse();
+
+    // Now remove the file group from MDD, which would cancel any ongoing download.
+    mobileDataDownload
+        .removeFileGroup(RemoveFileGroupRequest.newBuilder().setGroupName(FILE_GROUP_NAME).build())
+        .get();
+    // Now let the download future finish.
+    blockingFileDownloader.finishDownloading();
+
+    // Make sure that the download has been canceled and leads to cancelled future.
+    ExecutionException exception =
+        assertThrows(
+            ExecutionException.class,
+            () -> downloadFuture.get(MAX_HANDLE_TASK_WAIT_TIME_SECS, SECONDS));
+    assertThat(exception).hasCauseThat().isInstanceOf(AggregateException.class);
+    AggregateException cause = (AggregateException) exception.getCause();
+    assertThat(cause).isNotNull();
+    ImmutableList<Throwable> failures = cause.getFailures();
+    assertThat(failures).hasSize(1);
+    assertThat(failures.get(0)).isInstanceOf(CancellationException.class);
+  }
+
+  @Test
+  public void download_twoStepDownload_targetFileDownloaded() throws Exception {
+    Supplier<FileDownloader> fileDownloaderSupplier =
+        () ->
+            new TestFileDownloader(
+                TEST_DATA_RELATIVE_PATH,
+                fileStorage,
+                MoreExecutors.listeningDecorator(DOWNLOAD_EXECUTOR));
+
+    MobileDataDownload mobileDataDownload =
+        getMobileDataDownload(fileDownloaderSupplier, new TwoStepPopulator(context, fileStorage));
+
+    // Add step1 file group to MDD.
+    DataFileGroup step1FileGroup =
+        createDataFileGroup(
+            "step1-file-group",
+            context.getPackageName(),
+            new String[] {"step1_id"},
+            new int[] {57},
+            new String[] {""},
+            new ChecksumType[] {ChecksumType.NONE},
+            new String[] {"https://www.gstatic.com/icing/idd/sample_group/step1.txt"},
+            DeviceNetworkPolicy.DOWNLOAD_ON_ANY_NETWORK);
+
+    ListenableFuture<Boolean> unused =
+        mobileDataDownload.addFileGroup(
+            AddFileGroupRequest.newBuilder().setDataFileGroup(step1FileGroup).build());
+
+    // This will trigger refreshing of FileGroupPopulators and downloading.
+    mobileDataDownload
+        .handleTask(TaskScheduler.CELLULAR_CHARGING_PERIODIC_TASK)
+        .get(MAX_HANDLE_TASK_WAIT_TIME_SECS, SECONDS);
+
+    // Now verify that the step1-file-group is downloaded and then TwoStepPopulator will add
+    // step2-file-group and it was downloaded too in one cycle (one call of handleTask).
+
+    // Verify step1-file-group.
+    ClientFileGroup clientFileGroup =
+        getAndVerifyClientFileGroup(mobileDataDownload, "step1-file-group", 1);
+    verifyClientFile(clientFileGroup.getFile(0), "step1_id", 57);
+
+    // Verify step2-file-group.
+    clientFileGroup = getAndVerifyClientFileGroup(mobileDataDownload, "step2-file-group", 1);
+    verifyClientFile(clientFileGroup.getFile(0), "step2_id", 13);
+
+    mobileDataDownload.clear().get();
+  }
+
+  @Test
+  public void download_relativeFilePaths_createsSymlinks() throws Exception {
+    AndroidUriAdapter adapter = AndroidUriAdapter.forContext(context);
+    MobileDataDownload mobileDataDownload =
+        getMobileDataDownload(
+            () -> new TestFileDownloader(TEST_DATA_RELATIVE_PATH, fileStorage, CONTROL_EXECUTOR),
+            new TestFileGroupPopulator(context));
+
+    DataFileGroup fileGroup =
+        DataFileGroup.newBuilder()
+            .setGroupName(FILE_GROUP_NAME)
+            .setOwnerPackage(context.getPackageName())
+            .setPreserveFilenamesAndIsolateFiles(true)
+            .addFile(
+                DataFile.newBuilder()
+                    .setFileId(FILE_ID)
+                    .setByteSize(FILE_SIZE)
+                    .setChecksumType(DataFile.ChecksumType.DEFAULT)
+                    .setChecksum(FILE_CHECKSUM)
+                    .setUrlToDownload(FILE_URL)
+                    .setRelativeFilePath("relative_path")
+                    .build())
+            .build();
+
+    mobileDataDownload
+        .addFileGroup(AddFileGroupRequest.newBuilder().setDataFileGroup(fileGroup).build())
+        .get();
+
+    mobileDataDownload
+        .downloadFileGroup(
+            DownloadFileGroupRequest.newBuilder().setGroupName(FILE_GROUP_NAME).build())
+        .get();
+
+    // verify symlink structure
+    Uri expectedFileUri =
+        DirectoryUtil.getBaseDownloadDirectory(context, Optional.absent())
+            .buildUpon()
+            .appendPath(DirectoryUtil.MDD_STORAGE_SYMLINKS)
+            .appendPath(DirectoryUtil.MDD_STORAGE_ALL_GOOGLE_APPS)
+            .appendPath(FILE_GROUP_NAME)
+            .appendPath("relative_path")
+            .build();
+    // we can't get access to the full internal target file uri, but we know the start of it
+    Uri expectedStartTargetUri =
+        DirectoryUtil.getBaseDownloadDirectory(context, Optional.absent())
+            .buildUpon()
+            .appendPath(DirectoryUtil.MDD_STORAGE_ALL_GOOGLE_APPS)
+            .appendPath("datadownloadfile_")
+            .build();
+
+    ClientFileGroup clientFileGroup =
+        mobileDataDownload
+            .getFileGroup(GetFileGroupRequest.newBuilder().setGroupName(FILE_GROUP_NAME).build())
+            .get();
+
+    Uri fileUri = Uri.parse(clientFileGroup.getFile(0).getFileUri());
+    Uri targetUri =
+        AndroidUri.builder(context)
+            .fromAbsolutePath(readlink(adapter.toFile(fileUri).getAbsolutePath()))
+            .build();
+
+    assertThat(fileUri).isEqualTo(expectedFileUri);
+    assertThat(targetUri.toString()).contains(expectedStartTargetUri.toString());
+    assertThat(fileStorage.exists(fileUri)).isTrue();
+    assertThat(fileStorage.exists(targetUri)).isTrue();
+  }
+
+  @Test
+  public void remove_relativeFilePaths_removesSymlinks() throws Exception {
+    MobileDataDownload mobileDataDownload =
+        getMobileDataDownload(
+            () -> new TestFileDownloader(TEST_DATA_RELATIVE_PATH, fileStorage, CONTROL_EXECUTOR),
+            new TestFileGroupPopulator(context));
+
+    DataFileGroup fileGroup =
+        DataFileGroup.newBuilder()
+            .setGroupName(FILE_GROUP_NAME)
+            .setOwnerPackage(context.getPackageName())
+            .setPreserveFilenamesAndIsolateFiles(true)
+            .addFile(
+                DataFile.newBuilder()
+                    .setFileId(FILE_ID)
+                    .setByteSize(FILE_SIZE)
+                    .setChecksumType(DataFile.ChecksumType.DEFAULT)
+                    .setChecksum(FILE_CHECKSUM)
+                    .setUrlToDownload(FILE_URL)
+                    .setRelativeFilePath("relative_path")
+                    .build())
+            .build();
+
+    mobileDataDownload
+        .addFileGroup(AddFileGroupRequest.newBuilder().setDataFileGroup(fileGroup).build())
+        .get();
+
+    mobileDataDownload
+        .downloadFileGroup(
+            DownloadFileGroupRequest.newBuilder().setGroupName(FILE_GROUP_NAME).build())
+        .get();
+
+    ClientFileGroup clientFileGroup =
+        mobileDataDownload
+            .getFileGroup(GetFileGroupRequest.newBuilder().setGroupName(FILE_GROUP_NAME).build())
+            .get();
+
+    Uri fileUri = Uri.parse(clientFileGroup.getFile(0).getFileUri());
+
+    // Verify that file uri gets created
+    assertThat(fileStorage.exists(fileUri)).isTrue();
+
+    mobileDataDownload
+        .removeFileGroup(RemoveFileGroupRequest.newBuilder().setGroupName(FILE_GROUP_NAME).build())
+        .get();
+
+    // Verify that file uri still exists even though file group is stale
+    assertThat(fileStorage.exists(fileUri)).isTrue();
+
+    mobileDataDownload.maintenance().get();
+
+    // Verify that file uri gets removed, once maintenance runs
+    assertThat(fileStorage.exists(fileUri)).isFalse();
+  }
+
+  // TODO: Improve this helper by getting rid of the need to new arrays when invoking
+  // and unnamed params. Something along this line:
+  // createDataFileGroup(name,package).addFile(..).addFile()...
+  // A helper function to create a DataFilegroup.
+  public static DataFileGroup createDataFileGroup(
+      String groupName,
+      String ownerPackage,
+      String[] fileId,
+      int[] byteSize,
+      String[] checksum,
+      ChecksumType[] checksumType,
+      String[] url,
+      DeviceNetworkPolicy deviceNetworkPolicy) {
+    if (fileId.length != byteSize.length
+        || fileId.length != checksum.length
+        || fileId.length != url.length
+        || checksumType.length != fileId.length) {
+      throw new IllegalArgumentException();
+    }
+
+    DataFileGroup.Builder dataFileGroupBuilder =
+        DataFileGroup.newBuilder()
+            .setGroupName(groupName)
+            .setOwnerPackage(ownerPackage)
+            .setDownloadConditions(
+                DownloadConditions.newBuilder().setDeviceNetworkPolicy(deviceNetworkPolicy));
+
+    for (int i = 0; i < fileId.length; ++i) {
+      DataFile file =
+          DataFile.newBuilder()
+              .setFileId(fileId[i])
+              .setByteSize(byteSize[i])
+              .setChecksum(checksum[i])
+              .setChecksumType(checksumType[i])
+              .setUrlToDownload(url[i])
+              .build();
+      dataFileGroupBuilder.addFile(file);
+    }
+
+    return dataFileGroupBuilder.build();
+  }
+
+  private MobileDataDownload getMobileDataDownload(
+      Supplier<FileDownloader> fileDownloaderSupplier, FileGroupPopulator fileGroupPopulator) {
+    return getMobileDataDownloadBuilder(fileDownloaderSupplier, fileGroupPopulator).build();
+  }
+
+  private MobileDataDownloadBuilder getMobileDataDownloadBuilder(
+      Supplier<FileDownloader> fileDownloaderSupplier, FileGroupPopulator fileGroupPopulator) {
+    return MobileDataDownloadBuilder.newBuilder()
+        .setContext(context)
+        .setControlExecutor(CONTROL_EXECUTOR)
+        .setFileDownloaderSupplier(fileDownloaderSupplier)
+        .addFileGroupPopulator(fileGroupPopulator)
+        .setTaskScheduler(Optional.of(mockTaskScheduler))
+        .setLoggerOptional(Optional.of(mockLogger))
+        .setDeltaDecoderOptional(Optional.absent())
+        .setFileStorage(fileStorage)
+        .setNetworkUsageMonitor(networkUsageMonitor)
+        .setFlagsOptional(Optional.of(flags));
+  }
+
+  /** Creates MDD object and triggers handleTask to refresh and download file groups. */
+  private MobileDataDownload getMobileDataDownloadAfterDownload(
+      Supplier<FileDownloader> fileDownloaderSupplier, FileGroupPopulator fileGroupPopulator)
+      throws InterruptedException, ExecutionException, TimeoutException {
+    MobileDataDownload mobileDataDownload =
+        getMobileDataDownload(fileDownloaderSupplier, fileGroupPopulator);
+
+    // This will trigger refreshing of FileGroupPopulators and downloading.
+    mobileDataDownload
+        .handleTask(TaskScheduler.CELLULAR_CHARGING_PERIODIC_TASK)
+        .get(MAX_HANDLE_TASK_WAIT_TIME_SECS, SECONDS);
+
+    String debugString = mobileDataDownload.getDebugInfoAsString();
+    Log.i(TAG, "MDD Lib dump:");
+    for (String line : debugString.split("\n", -1)) {
+      Log.i(TAG, line);
+    }
+    return mobileDataDownload;
+  }
+
+  private static ClientFileGroup getAndVerifyClientFileGroup(
+      MobileDataDownload mobileDataDownload, String fileGroupName, int fileCount)
+      throws ExecutionException, InterruptedException {
+    ClientFileGroup clientFileGroup =
+        mobileDataDownload
+            .getFileGroup(GetFileGroupRequest.newBuilder().setGroupName(fileGroupName).build())
+            .get();
+    assertThat(clientFileGroup).isNotNull();
+    assertThat(clientFileGroup.getGroupName()).isEqualTo(fileGroupName);
+    assertThat(clientFileGroup.getFileCount()).isEqualTo(fileCount);
+
+    return clientFileGroup;
+  }
+
+  private void verifyClientFile(ClientFile clientFile, String fileId, int fileSize)
+      throws IOException {
+    assertThat(clientFile.getFileId()).isEqualTo(fileId);
+    Uri androidUri = Uri.parse(clientFile.getFileUri());
+    assertThat(fileStorage.fileSize(androidUri)).isEqualTo(fileSize);
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/MobileDataDownloadTest.java b/javatests/com/google/android/libraries/mobiledatadownload/MobileDataDownloadTest.java
new file mode 100644
index 0000000..b98cd36
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/MobileDataDownloadTest.java
@@ -0,0 +1,3540 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+import static com.google.common.labs.truth.FutureSubject.assertThat;
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+import android.accounts.Account;
+import android.content.Context;
+import android.net.Uri;
+import android.util.Pair;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.android.libraries.mobiledatadownload.DownloadException.DownloadResultCode;
+import com.google.android.libraries.mobiledatadownload.TaskScheduler.ConstraintOverrides;
+import com.google.android.libraries.mobiledatadownload.TaskScheduler.NetworkState;
+import com.google.android.libraries.mobiledatadownload.account.AccountUtil;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.AndroidFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.openers.WriteStreamOpener;
+import com.google.android.libraries.mobiledatadownload.internal.MobileDataDownloadManager;
+import com.google.android.libraries.mobiledatadownload.internal.logging.EventLogger;
+import com.google.android.libraries.mobiledatadownload.internal.util.ProtoConversionUtil;
+import com.google.android.libraries.mobiledatadownload.lite.Downloader;
+import com.google.android.libraries.mobiledatadownload.monitor.DownloadProgressMonitor;
+import com.google.common.base.Optional;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.labs.concurrent.LabsFutures;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.mobiledatadownload.ClientConfigProto.ClientFile;
+import com.google.mobiledatadownload.ClientConfigProto.ClientFileGroup;
+import com.google.mobiledatadownload.ClientConfigProto.ClientFileGroup.Status;
+import com.google.mobiledatadownload.DownloadConfigProto.DataFile;
+import com.google.mobiledatadownload.DownloadConfigProto.DataFileGroup;
+import com.google.mobiledatadownload.DownloadConfigProto.DownloadConditions;
+import com.google.mobiledatadownload.DownloadConfigProto.DownloadConditions.DeviceNetworkPolicy;
+import com.google.mobiledatadownload.internal.MetadataProto;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal;
+import com.google.mobiledatadownload.internal.MetadataProto.GroupKey;
+import com.google.protobuf.Any;
+import com.google.protobuf.ByteString;
+import com.google.protobuf.StringValue;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.RobolectricTestRunner;
+
+/** Tests for {@link com.google.android.libraries.mobiledatadownload.MobileDataDownload}. */
+@RunWith(RobolectricTestRunner.class)
+public class MobileDataDownloadTest {
+  // Note: Control Executor must not be a single thread executor.
+  private static final Executor EXECUTOR = Executors.newCachedThreadPool();
+  private static final long LATCH_WAIT_TIME_MS = 1000L;
+
+  private static final String FILE_GROUP_NAME_1 = "test-group-1";
+  private static final String FILE_GROUP_NAME_2 = "test-group-2";
+  private static final String FILE_ID_1 = "test-file-1";
+  private static final String FILE_ID_2 = "test-file-2";
+  private static final String FILE_CHECKSUM_1 = "c1ef7864c76a99ae738ddad33882ed65972c99cc";
+  private static final String FILE_URL_1 = "https://www.gstatic.com/suggest-dev/odws1_test_4.jar";
+  private static final int FILE_SIZE_1 = 85769;
+
+  private static final String FILE_CHECKSUM_2 = "a1cba9d87b1440f41ce9e7da38c43e1f6bd7d5df";
+  private static final String FILE_URL_2 = "https://www.gstatic.com/suggest-dev/odws1_empty.jar";
+  private static final int FILE_SIZE_2 = 554;
+
+  private final Uri onDeviceUri1 =
+      Uri.parse(
+          "android://com.google.android.libraries.mobiledatadownload/files/datadownload/shared/public/file_1");
+  private final Uri onDeviceDirUri =
+      Uri.parse(
+          "android://com.google.android.libraries.mobiledatadownload/files/datadownload/shared/public/dir");
+  private final Uri onDeviceDirFileUri1 =
+      Uri.parse(
+          "android://com.google.android.libraries.mobiledatadownload/files/datadownload/shared/public/dir/file_1");
+  private final String onDeviceDirFile1Content = "Test file 1.";
+  private final Uri onDeviceDirFileUri2 =
+      Uri.parse(
+          "android://com.google.android.libraries.mobiledatadownload/files/datadownload/shared/public/dir/file_2");
+  private final String onDeviceDirFile2Content = "Test file 2.";
+  private final Uri onDeviceDirFileUri3 =
+      Uri.parse(
+          "android://com.google.android.libraries.mobiledatadownload/files/datadownload/shared/public/dir/sub/file");
+  private final String onDeviceDirFile3Content = "Test file 3 in sub-dir.";
+
+  private final Flags flags = new Flags() {};
+  private Context context;
+  private SynchronousFileStorage fileStorage;
+
+  @Mock EventLogger mockEventLogger;
+  @Mock MobileDataDownloadManager mockMobileDataDownloadManager;
+  @Mock TaskScheduler mockTaskScheduler;
+  @Mock FileGroupPopulator mockFileGroupPopulator;
+  @Mock DownloadProgressMonitor mockDownloadMonitor;
+  @Mock Downloader singleFileDownloader;
+
+  @Captor ArgumentCaptor<GroupKey> groupKeyCaptor;
+  @Captor ArgumentCaptor<List<GroupKey>> groupKeysCaptor;
+
+  @Rule public final MockitoRule mocks = MockitoJUnit.rule();
+
+  @Before
+  public void setUp() throws IOException {
+    context = ApplicationProvider.getApplicationContext();
+    fileStorage =
+        new SynchronousFileStorage(
+            ImmutableList.of(AndroidFileBackend.builder(context).build()) /*backends*/);
+    createFile(onDeviceUri1, "test");
+    fileStorage.createDirectory(onDeviceDirUri);
+    createFile(onDeviceDirFileUri1, onDeviceDirFile1Content);
+    createFile(onDeviceDirFileUri2, onDeviceDirFile2Content);
+    createFile(onDeviceDirFileUri3, onDeviceDirFile3Content);
+  }
+
+  private void createFile(Uri uri, String content) throws IOException {
+    try (OutputStream out = fileStorage.open(uri, WriteStreamOpener.create())) {
+      out.write(content.getBytes(UTF_8));
+    }
+  }
+
+  @Test
+  public void buildGetFileGroupsByFilterRequest() throws Exception {
+    Account account = AccountUtil.create("account-name", "account-type");
+    GetFileGroupsByFilterRequest request1 =
+        GetFileGroupsByFilterRequest.newBuilder()
+            .setGroupNameOptional(Optional.of(FILE_GROUP_NAME_1))
+            .setAccountOptional(Optional.of(account))
+            .build();
+    assertThat(request1.groupNameOptional()).hasValue(FILE_GROUP_NAME_1);
+    assertThat(request1.accountOptional()).hasValue(account);
+
+    GetFileGroupsByFilterRequest request2 =
+        GetFileGroupsByFilterRequest.newBuilder()
+            .setGroupNameOptional(Optional.of(FILE_GROUP_NAME_1))
+            .build();
+    assertThat(request2.groupNameOptional()).hasValue(FILE_GROUP_NAME_1);
+    assertThat(request2.accountOptional()).isAbsent();
+
+    GetFileGroupsByFilterRequest.Builder builder = GetFileGroupsByFilterRequest.newBuilder();
+
+    assertThrows(IllegalArgumentException.class, builder::build);
+  }
+
+  @Test
+  public void buildGetFileGroupsByFilterRequest_groupWithNoAccountOnly() {
+    Account account = AccountUtil.create("account-name", "account-type");
+    GetFileGroupsByFilterRequest.Builder builder =
+        GetFileGroupsByFilterRequest.newBuilder()
+            .setGroupWithNoAccountOnly(true)
+            .setGroupNameOptional(Optional.of(FILE_GROUP_NAME_1))
+            .setAccountOptional(Optional.of(account));
+
+    // Make sure that when request account independent groups, accountOptional should be absent.
+    assertThrows(IllegalArgumentException.class, builder::build);
+  }
+
+  @Test
+  public void addFileGroup() throws Exception {
+    when(mockMobileDataDownloadManager.addGroupForDownloadInternal(
+            any(GroupKey.class), any(DataFileGroupInternal.class), any()))
+        .thenReturn(Futures.immediateFuture(true));
+    DataFileGroup dataFileGroup =
+        createDataFileGroup(
+            FILE_GROUP_NAME_1,
+            context.getPackageName(),
+            1 /* versionNumber */,
+            new String[] {FILE_ID_1},
+            new int[] {FILE_SIZE_1},
+            new String[] {FILE_CHECKSUM_1},
+            new String[] {FILE_URL_1},
+            DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI);
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            ImmutableList.of() /* fileGroupPopulatorList */,
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            Optional.absent() /* downloadMonitorOptional */,
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    assertThat(
+            mobileDataDownload
+                .addFileGroup(
+                    AddFileGroupRequest.newBuilder().setDataFileGroup(dataFileGroup).build())
+                .get())
+        .isTrue();
+  }
+
+  @Test
+  public void addFileGroup_onFailure() throws Exception {
+    when(mockMobileDataDownloadManager.addGroupForDownloadInternal(
+            any(GroupKey.class), any(DataFileGroupInternal.class), any()))
+        .thenReturn(Futures.immediateFuture(false));
+    DataFileGroup dataFileGroup =
+        createDataFileGroup(
+            FILE_GROUP_NAME_1,
+            context.getPackageName(),
+            1 /* versionNumber */,
+            new String[] {FILE_ID_1},
+            new int[] {FILE_SIZE_1},
+            new String[] {FILE_CHECKSUM_1},
+            new String[] {FILE_URL_1},
+            DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI);
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            ImmutableList.of() /* fileGroupPopulatorList */,
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            Optional.absent() /* downloadMonitorOptional */,
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    assertThat(
+            mobileDataDownload
+                .addFileGroup(
+                    AddFileGroupRequest.newBuilder().setDataFileGroup(dataFileGroup).build())
+                .get())
+        .isFalse();
+  }
+
+  @Test
+  public void addFileGroup_invalidOwnerPackageName() throws Exception {
+    when(mockMobileDataDownloadManager.addGroupForDownloadInternal(
+            any(GroupKey.class), any(DataFileGroupInternal.class), any()))
+        .thenReturn(Futures.immediateFuture(true));
+
+    // Owner Package should be same as the app package.
+    DataFileGroup dataFileGroup =
+        createDataFileGroup(
+            FILE_GROUP_NAME_1,
+            "PACKAGE_NAME",
+            1 /* versionNumber */,
+            new String[] {FILE_ID_1},
+            new int[] {FILE_SIZE_1},
+            new String[] {FILE_CHECKSUM_1},
+            new String[] {FILE_URL_1},
+            DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI);
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            null /* fileGroupPopulatorList */,
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            Optional.absent() /* downloadMonitorOptional */,
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    assertThat(
+            mobileDataDownload
+                .addFileGroup(
+                    AddFileGroupRequest.newBuilder().setDataFileGroup(dataFileGroup).build())
+                .get())
+        .isFalse();
+  }
+
+  @Test
+  public void addFileGroupWithFileGroupKey() throws Exception {
+    ArgumentCaptor<GroupKey> groupKeyCaptor = ArgumentCaptor.forClass(GroupKey.class);
+    when(mockMobileDataDownloadManager.addGroupForDownloadInternal(
+            groupKeyCaptor.capture(), any(DataFileGroupInternal.class), any()))
+        .thenReturn(Futures.immediateFuture(true));
+
+    DataFileGroup dataFileGroup =
+        createDataFileGroup(
+            FILE_GROUP_NAME_1,
+            context.getPackageName(),
+            1 /* versionNumber */,
+            new String[] {FILE_ID_1},
+            new int[] {FILE_SIZE_1},
+            new String[] {FILE_CHECKSUM_1},
+            new String[] {FILE_URL_1},
+            DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI);
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            ImmutableList.of() /* fileGroupPopulatorList */,
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            Optional.absent() /* downloadMonitorOptional */,
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    Account account = AccountUtil.create("account-name", "account-type");
+    AddFileGroupRequest addFileGroupRequest =
+        AddFileGroupRequest.newBuilder()
+            .setDataFileGroup(dataFileGroup)
+            .setAccountOptional(Optional.of(account))
+            .build();
+
+    assertThat(mobileDataDownload.addFileGroup(addFileGroupRequest).get()).isTrue();
+
+    GroupKey groupKey =
+        GroupKey.newBuilder()
+            .setGroupName(FILE_GROUP_NAME_1)
+            .setOwnerPackage(context.getPackageName())
+            .setAccount(AccountUtil.serialize(account))
+            .build();
+    assertThat(groupKeyCaptor.getValue()).isEqualTo(groupKey);
+    verify(mockMobileDataDownloadManager)
+        .addGroupForDownloadInternal(
+            eq(groupKey), eq(ProtoConversionUtil.convert(dataFileGroup)), any());
+  }
+
+  @Test
+  public void addFileGroupWithFileGroupKey_onFailure() throws Exception {
+    ArgumentCaptor<GroupKey> groupKeyCaptor = ArgumentCaptor.forClass(GroupKey.class);
+    when(mockMobileDataDownloadManager.addGroupForDownloadInternal(
+            groupKeyCaptor.capture(), any(DataFileGroupInternal.class), any()))
+        .thenReturn(Futures.immediateFuture(false));
+
+    DataFileGroup dataFileGroup =
+        createDataFileGroup(
+            FILE_GROUP_NAME_1,
+            context.getPackageName(),
+            1 /* versionNumber */,
+            new String[] {FILE_ID_1},
+            new int[] {FILE_SIZE_1},
+            new String[] {FILE_CHECKSUM_1},
+            new String[] {FILE_URL_1},
+            DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI);
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            ImmutableList.of() /* fileGroupPopulatorList */,
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            Optional.absent() /* downloadMonitorOptional */,
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    Account account = AccountUtil.create("account-name", "account-type");
+    AddFileGroupRequest addFileGroupRequest =
+        AddFileGroupRequest.newBuilder()
+            .setDataFileGroup(dataFileGroup)
+            .setAccountOptional(Optional.of(account))
+            .build();
+
+    assertThat(mobileDataDownload.addFileGroup(addFileGroupRequest).get()).isFalse();
+
+    GroupKey groupKey =
+        GroupKey.newBuilder()
+            .setGroupName(FILE_GROUP_NAME_1)
+            .setOwnerPackage(context.getPackageName())
+            .setAccount(AccountUtil.serialize(account))
+            .build();
+    assertThat(groupKeyCaptor.getValue()).isEqualTo(groupKey);
+    verify(mockMobileDataDownloadManager)
+        .addGroupForDownloadInternal(
+            eq(groupKey), eq(ProtoConversionUtil.convert(dataFileGroup)), any());
+  }
+
+  @Test
+  public void addFileGroupWithFileGroupKey_nullAccount() throws Exception {
+    ArgumentCaptor<GroupKey> groupKeyCaptor = ArgumentCaptor.forClass(GroupKey.class);
+    when(mockMobileDataDownloadManager.addGroupForDownloadInternal(
+            groupKeyCaptor.capture(), any(DataFileGroupInternal.class), any()))
+        .thenReturn(Futures.immediateFuture(true));
+
+    DataFileGroup dataFileGroup =
+        createDataFileGroup(
+            FILE_GROUP_NAME_1,
+            context.getPackageName(),
+            1 /* versionNumber */,
+            new String[] {FILE_ID_1},
+            new int[] {FILE_SIZE_1},
+            new String[] {FILE_CHECKSUM_1},
+            new String[] {FILE_URL_1},
+            DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI);
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            ImmutableList.of() /* fileGroupPopulatorList */,
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            Optional.absent() /* downloadMonitorOptional */,
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    AddFileGroupRequest addFileGroupRequest =
+        AddFileGroupRequest.newBuilder().setDataFileGroup(dataFileGroup).build();
+
+    assertThat(mobileDataDownload.addFileGroup(addFileGroupRequest).get()).isTrue();
+
+    GroupKey groupKey =
+        GroupKey.newBuilder()
+            .setGroupName(FILE_GROUP_NAME_1)
+            .setOwnerPackage(context.getPackageName())
+            .build();
+    assertThat(groupKeyCaptor.getValue()).isEqualTo(groupKey);
+    verify(mockMobileDataDownloadManager)
+        .addGroupForDownloadInternal(
+            eq(groupKey), eq(ProtoConversionUtil.convert(dataFileGroup)), any());
+  }
+
+  @Test
+  public void addFileGroupWithFileGroupKey_withVariant() throws Exception {
+    ArgumentCaptor<GroupKey> groupKeyCaptor = ArgumentCaptor.forClass(GroupKey.class);
+    when(mockMobileDataDownloadManager.addGroupForDownloadInternal(
+            groupKeyCaptor.capture(), any(), any()))
+        .thenReturn(Futures.immediateFuture(true));
+
+    DataFileGroup dataFileGroupWithVariant =
+        createDataFileGroup(
+                FILE_GROUP_NAME_1,
+                context.getPackageName(),
+                1 /* versionNumber */,
+                new String[] {FILE_ID_1},
+                new int[] {FILE_SIZE_1},
+                new String[] {FILE_CHECKSUM_1},
+                new String[] {FILE_URL_1},
+                DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI)
+            .toBuilder()
+            .setVariantId("en")
+            .build();
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            ImmutableList.of() /* fileGroupPopulatorList */,
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            Optional.absent() /* downloadMonitorOptional */,
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    AddFileGroupRequest addFileGroupRequest =
+        AddFileGroupRequest.newBuilder()
+            .setDataFileGroup(dataFileGroupWithVariant)
+            .setVariantIdOptional(Optional.of("en"))
+            .build();
+
+    assertThat(mobileDataDownload.addFileGroup(addFileGroupRequest).get()).isTrue();
+
+    GroupKey groupKey =
+        GroupKey.newBuilder()
+            .setGroupName(FILE_GROUP_NAME_1)
+            .setOwnerPackage(context.getPackageName())
+            .setVariantId("en")
+            .build();
+    assertThat(groupKeyCaptor.getValue()).isEqualTo(groupKey);
+    verify(mockMobileDataDownloadManager)
+        .addGroupForDownloadInternal(
+            eq(groupKey), eq(ProtoConversionUtil.convert(dataFileGroupWithVariant)), any());
+  }
+
+  @Test
+  public void removeFileGroup_onSuccess_returnsTrue() throws Exception {
+    ArgumentCaptor<GroupKey> groupKeyCaptor = ArgumentCaptor.forClass(GroupKey.class);
+    when(mockMobileDataDownloadManager.removeFileGroup(groupKeyCaptor.capture(), eq(false)))
+        .thenReturn(Futures.immediateFuture(null /* Void */));
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            ImmutableList.of() /* fileGroupPopulatorList */,
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            Optional.absent() /* downloadMonitorOptional */,
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    RemoveFileGroupRequest removeFileGroupRequest =
+        RemoveFileGroupRequest.newBuilder().setGroupName(FILE_GROUP_NAME_1).build();
+
+    assertThat(mobileDataDownload.removeFileGroup(removeFileGroupRequest).get()).isTrue();
+
+    GroupKey groupKey =
+        GroupKey.newBuilder()
+            .setGroupName(FILE_GROUP_NAME_1)
+            .setOwnerPackage(context.getPackageName())
+            .build();
+    assertThat(groupKeyCaptor.getValue()).isEqualTo(groupKey);
+  }
+
+  @Test
+  public void removeFileGroup_onFailure_returnsFalse() throws Exception {
+    ArgumentCaptor<GroupKey> groupKeyCaptor = ArgumentCaptor.forClass(GroupKey.class);
+    doThrow(new IOException())
+        .when(mockMobileDataDownloadManager)
+        .removeFileGroup(groupKeyCaptor.capture(), /* pendingOnly= */ eq(false));
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            ImmutableList.of() /* fileGroupPopulatorList */,
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            Optional.absent() /* downloadMonitorOptional */,
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    RemoveFileGroupRequest removeFileGroupRequest =
+        RemoveFileGroupRequest.newBuilder().setGroupName(FILE_GROUP_NAME_1).build();
+
+    ExecutionException exception =
+        assertThrows(
+            ExecutionException.class,
+            () -> mobileDataDownload.removeFileGroup(removeFileGroupRequest).get());
+    assertThat(exception).hasCauseThat().isInstanceOf(IOException.class);
+
+    GroupKey groupKey =
+        GroupKey.newBuilder()
+            .setGroupName(FILE_GROUP_NAME_1)
+            .setOwnerPackage(context.getPackageName())
+            .build();
+    assertThat(groupKeyCaptor.getValue()).isEqualTo(groupKey);
+  }
+
+  @Test
+  public void removeFileGroup_withAccount_returnsTrue() throws Exception {
+    ArgumentCaptor<GroupKey> groupKeyCaptor = ArgumentCaptor.forClass(GroupKey.class);
+    when(mockMobileDataDownloadManager.removeFileGroup(
+            groupKeyCaptor.capture(), /* pendingOnly= */ eq(false)))
+        .thenReturn(Futures.immediateFuture(null /* Void */));
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            ImmutableList.of() /* fileGroupPopulatorList */,
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            Optional.absent() /* downloadMonitorOptional */,
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    Account account = AccountUtil.create("account-name", "account-type");
+    RemoveFileGroupRequest removeFileGroupRequest =
+        RemoveFileGroupRequest.newBuilder()
+            .setGroupName(FILE_GROUP_NAME_1)
+            .setAccountOptional(Optional.of(account))
+            .build();
+
+    assertThat(mobileDataDownload.removeFileGroup(removeFileGroupRequest).get()).isTrue();
+
+    GroupKey groupKey =
+        GroupKey.newBuilder()
+            .setGroupName(FILE_GROUP_NAME_1)
+            .setOwnerPackage(context.getPackageName())
+            .setAccount(AccountUtil.serialize(account))
+            .build();
+    assertThat(groupKeyCaptor.getValue()).isEqualTo(groupKey);
+  }
+
+  @Test
+  public void removeFileGroup_withVariantId_returnsTrue() throws Exception {
+    ArgumentCaptor<GroupKey> groupKeyCaptor = ArgumentCaptor.forClass(GroupKey.class);
+    when(mockMobileDataDownloadManager.removeFileGroup(
+            groupKeyCaptor.capture(), /* pendingOnly= */ eq(false)))
+        .thenReturn(Futures.immediateFuture(null /* Void */));
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            ImmutableList.of() /* fileGroupPopulatorList */,
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            Optional.absent() /* downloadMonitorOptional */,
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    RemoveFileGroupRequest removeFileGroupRequest =
+        RemoveFileGroupRequest.newBuilder()
+            .setGroupName(FILE_GROUP_NAME_1)
+            .setVariantIdOptional(Optional.of("en"))
+            .build();
+
+    assertThat(mobileDataDownload.removeFileGroup(removeFileGroupRequest).get()).isTrue();
+
+    GroupKey groupKey =
+        GroupKey.newBuilder()
+            .setGroupName(FILE_GROUP_NAME_1)
+            .setOwnerPackage(context.getPackageName())
+            .setVariantId("en")
+            .build();
+    assertThat(groupKeyCaptor.getValue()).isEqualTo(groupKey);
+  }
+
+  @Test
+  public void getFileGroup() throws Exception {
+    DataFileGroupInternal dataFileGroup =
+        createDataFileGroupInternal(
+                FILE_GROUP_NAME_1,
+                context.getPackageName(),
+                5 /* versionNumber */,
+                new String[] {FILE_ID_1},
+                new int[] {FILE_SIZE_1},
+                new String[] {FILE_CHECKSUM_1},
+                new String[] {FILE_URL_1},
+                DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI)
+            .toBuilder()
+            .setBuildId(10)
+            .setVariantId("test-variant")
+            .build();
+    when(mockMobileDataDownloadManager.getFileGroup(any(GroupKey.class), eq(true)))
+        .thenReturn(Futures.immediateFuture(dataFileGroup));
+    when(mockMobileDataDownloadManager.getDataFileUri(dataFileGroup.getFile(0), dataFileGroup))
+        .thenReturn(Futures.immediateFuture(onDeviceUri1));
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            ImmutableList.of() /* fileGroupPopulatorList */,
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            Optional.absent() /* downloadMonitorOptional */,
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    ClientFileGroup clientFileGroup =
+        mobileDataDownload
+            .getFileGroup(GetFileGroupRequest.newBuilder().setGroupName(FILE_GROUP_NAME_1).build())
+            .get();
+    assertThat(clientFileGroup.getGroupName()).isEqualTo(FILE_GROUP_NAME_1);
+    assertThat(clientFileGroup.getOwnerPackage()).isEqualTo(context.getPackageName());
+    assertThat(clientFileGroup.getVersionNumber()).isEqualTo(5);
+    assertThat(clientFileGroup.getFileCount()).isEqualTo(1);
+    assertThat(clientFileGroup.hasAccount()).isFalse();
+
+    ClientFile clientFile = clientFileGroup.getFileList().get(0);
+    assertThat(clientFile.getFileId()).isEqualTo(FILE_ID_1);
+    assertThat(clientFile.getFileUri()).isEqualTo(onDeviceUri1.toString());
+  }
+
+  @Test
+  public void getFileGroup_withDirectory() throws Exception {
+    DataFileGroupInternal dataFileGroup =
+        createDataFileGroupInternal(
+            FILE_GROUP_NAME_1,
+            context.getPackageName(),
+            5 /* versionNumber */,
+            new String[] {FILE_ID_1},
+            new int[] {FILE_SIZE_1},
+            new String[] {FILE_CHECKSUM_1},
+            new String[] {FILE_URL_1},
+            DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI);
+    when(mockMobileDataDownloadManager.getFileGroup(any(GroupKey.class), eq(true)))
+        .thenReturn(Futures.immediateFuture(dataFileGroup));
+    when(mockMobileDataDownloadManager.getDataFileUri(dataFileGroup.getFile(0), dataFileGroup))
+        .thenReturn(Futures.immediateFuture(onDeviceDirUri));
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            ImmutableList.of() /* fileGroupPopulatorList */,
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            Optional.absent() /* downloadMonitorOptional */,
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    ClientFileGroup clientFileGroup =
+        mobileDataDownload
+            .getFileGroup(GetFileGroupRequest.newBuilder().setGroupName(FILE_GROUP_NAME_1).build())
+            .get();
+    assertThat(clientFileGroup.getGroupName()).isEqualTo(FILE_GROUP_NAME_1);
+    assertThat(clientFileGroup.getOwnerPackage()).isEqualTo(context.getPackageName());
+    assertThat(clientFileGroup.getVersionNumber()).isEqualTo(5);
+    assertThat(clientFileGroup.getFileCount()).isEqualTo(3);
+    assertThat(clientFileGroup.hasAccount()).isFalse();
+
+    List<ClientFile> clientFileList = clientFileGroup.getFileList();
+    assertThat(clientFileList)
+        .contains(
+            ClientFile.newBuilder()
+                .setFileId("/file_1")
+                .setFileUri(onDeviceDirFileUri1.toString())
+                .setFullSizeInBytes(onDeviceDirFile1Content.getBytes(UTF_8).length)
+                .build());
+    assertThat(clientFileList)
+        .contains(
+            ClientFile.newBuilder()
+                .setFileId("/file_2")
+                .setFileUri(onDeviceDirFileUri2.toString())
+                .setFullSizeInBytes(onDeviceDirFile2Content.getBytes(UTF_8).length)
+                .build());
+    assertThat(clientFileList)
+        .contains(
+            ClientFile.newBuilder()
+                .setFileId("/sub/file")
+                .setFileUri(onDeviceDirFileUri3.toString())
+                .setFullSizeInBytes(onDeviceDirFile3Content.getBytes(UTF_8).length)
+                .build());
+  }
+
+  @Test
+  public void getFileGroup_withDirectory_withTraverseDisabled() throws Exception {
+    DataFileGroupInternal dataFileGroup =
+        createDataFileGroupInternal(
+            FILE_GROUP_NAME_1,
+            context.getPackageName(),
+            5 /* versionNumber */,
+            new String[] {FILE_ID_1},
+            new int[] {FILE_SIZE_1},
+            new String[] {FILE_CHECKSUM_1},
+            new String[] {FILE_URL_1},
+            DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI);
+    when(mockMobileDataDownloadManager.getFileGroup(any(GroupKey.class), eq(true)))
+        .thenReturn(Futures.immediateFuture(dataFileGroup));
+    when(mockMobileDataDownloadManager.getDataFileUri(dataFileGroup.getFile(0), dataFileGroup))
+        .thenReturn(Futures.immediateFuture(onDeviceDirUri));
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            ImmutableList.of() /* fileGroupPopulatorList */,
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            Optional.absent() /* downloadMonitorOptional */,
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    ClientFileGroup clientFileGroup =
+        mobileDataDownload
+            .getFileGroup(
+                GetFileGroupRequest.newBuilder()
+                    .setPreserveZipDirectories(true)
+                    .setGroupName(FILE_GROUP_NAME_1)
+                    .build())
+            .get();
+    assertThat(clientFileGroup.getGroupName()).isEqualTo(FILE_GROUP_NAME_1);
+    assertThat(clientFileGroup.getOwnerPackage()).isEqualTo(context.getPackageName());
+    assertThat(clientFileGroup.getVersionNumber()).isEqualTo(5);
+    assertThat(clientFileGroup.getFileCount()).isEqualTo(1);
+    assertThat(clientFileGroup.hasAccount()).isFalse();
+
+    List<ClientFile> clientFileList = clientFileGroup.getFileList();
+    assertThat(clientFileList)
+        .contains(
+            ClientFile.newBuilder()
+                .setFileId("test-file-1")
+                .setFileUri(onDeviceDirUri.toString())
+                .setFullSizeInBytes(FILE_SIZE_1)
+                .build());
+    assertThat(fileStorage.isDirectory(Uri.parse(clientFileList.get(0).getFileUri()))).isTrue();
+  }
+
+  @Test
+  public void removeFileGroupsByFilter_withAccountSpecified_removesMatchingAccountGroups()
+      throws Exception {
+    List<Pair<GroupKey, DataFileGroupInternal>> keyToGroupList = new ArrayList<>();
+    Account account1 = AccountUtil.create("account-name", "account-type");
+    Account account2 = AccountUtil.create("account-name2", "account-type");
+
+    DataFileGroupInternal downloadedFileGroup =
+        createDataFileGroupInternal(
+                FILE_GROUP_NAME_1,
+                context.getPackageName(),
+                /* versionNumber = */ 5,
+                new String[] {FILE_ID_1},
+                new int[] {FILE_SIZE_1},
+                new String[] {FILE_CHECKSUM_1},
+                new String[] {FILE_URL_1},
+                DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI)
+            .toBuilder()
+            .build();
+    DataFileGroupInternal pendingFileGroup =
+        createDataFileGroupInternal(
+                FILE_GROUP_NAME_1,
+                context.getPackageName(),
+                /* versionNumber = */ 6,
+                new String[] {FILE_ID_1},
+                new int[] {FILE_SIZE_1},
+                new String[] {FILE_CHECKSUM_1},
+                new String[] {FILE_URL_1},
+                DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI)
+            .toBuilder()
+            .build();
+
+    GroupKey account1GroupKey =
+        GroupKey.newBuilder()
+            .setGroupName(FILE_GROUP_NAME_1)
+            .setOwnerPackage(context.getPackageName())
+            .setAccount(AccountUtil.serialize(account1))
+            .build();
+    GroupKey downloadedAccount1GroupKey = account1GroupKey.toBuilder().setDownloaded(true).build();
+    GroupKey pendingAccount1GroupKey = account1GroupKey.toBuilder().setDownloaded(false).build();
+
+    GroupKey account2GroupKey =
+        GroupKey.newBuilder()
+            .setGroupName(FILE_GROUP_NAME_1)
+            .setOwnerPackage(context.getPackageName())
+            .setAccount(AccountUtil.serialize(account2))
+            .build();
+    GroupKey downloadedAccount2GroupKey = account2GroupKey.toBuilder().setDownloaded(true).build();
+    GroupKey pendingAccount2GroupKey = account2GroupKey.toBuilder().setDownloaded(false).build();
+
+    GroupKey noAccountGroupKey =
+        GroupKey.newBuilder()
+            .setGroupName(FILE_GROUP_NAME_1)
+            .setOwnerPackage(context.getPackageName())
+            .build();
+    GroupKey downloadedGroupKey = noAccountGroupKey.toBuilder().setDownloaded(true).build();
+    GroupKey pendingGroupKey = noAccountGroupKey.toBuilder().setDownloaded(false).build();
+
+    keyToGroupList.add(Pair.create(downloadedGroupKey, downloadedFileGroup));
+    keyToGroupList.add(Pair.create(downloadedAccount1GroupKey, downloadedFileGroup));
+    keyToGroupList.add(Pair.create(downloadedAccount2GroupKey, downloadedFileGroup));
+    keyToGroupList.add(Pair.create(pendingGroupKey, pendingFileGroup));
+    keyToGroupList.add(Pair.create(pendingAccount1GroupKey, pendingFileGroup));
+    keyToGroupList.add(Pair.create(pendingAccount2GroupKey, pendingFileGroup));
+
+    when(mockMobileDataDownloadManager.getAllFreshGroups())
+        .thenReturn(Futures.immediateFuture(keyToGroupList));
+    when(mockMobileDataDownloadManager.removeFileGroups(groupKeysCaptor.capture()))
+        .thenReturn(Futures.immediateVoidFuture());
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            /* fileGroupPopulatorList = */ ImmutableList.of(),
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            /* downloadMonitorOptional = */ Optional.absent(),
+            /* foregroundDownloadServiceClassOptional = */ Optional.absent(),
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    // Setup request that matches all fresh groups, but also include account to make sure only
+    // account associated file groups are removed
+    RemoveFileGroupsByFilterRequest removeFileGroupsByFilterRequest =
+        RemoveFileGroupsByFilterRequest.newBuilder()
+            .setAccountOptional(Optional.of(account1))
+            .build();
+
+    RemoveFileGroupsByFilterResponse response =
+        mobileDataDownload.removeFileGroupsByFilter(removeFileGroupsByFilterRequest).get();
+
+    assertThat(response.removedFileGroupsCount()).isEqualTo(1);
+    verify(mockMobileDataDownloadManager, times(1)).removeFileGroups(anyList());
+    List<GroupKey> removedGroupKeys = groupKeysCaptor.getValue();
+    assertThat(removedGroupKeys).containsExactly(account1GroupKey);
+  }
+
+  @Test
+  public void getFileGroup_nullFileUri() throws Exception {
+    DataFileGroupInternal dataFileGroup =
+        createDataFileGroupInternal(
+            FILE_GROUP_NAME_1,
+            context.getPackageName(),
+            5 /* versionNumber */,
+            new String[] {FILE_ID_1},
+            new int[] {FILE_SIZE_1},
+            new String[] {FILE_CHECKSUM_1},
+            new String[] {FILE_URL_1},
+            DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI);
+    when(mockMobileDataDownloadManager.getFileGroup(any(GroupKey.class), eq(true)))
+        .thenReturn(Futures.immediateFuture(dataFileGroup));
+    when(mockMobileDataDownloadManager.getDataFileUri(dataFileGroup.getFile(0), dataFileGroup))
+        .thenReturn(
+            Futures.immediateFailedFuture(
+                DownloadException.builder()
+                    .setDownloadResultCode(DownloadResultCode.UNKNOWN_ERROR)
+                    .setMessage("Fail to download file group")
+                    .build()));
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            ImmutableList.of() /* fileGroupPopulatorList */,
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            Optional.absent() /* downloadMonitorOptional */,
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    assertNull(
+        mobileDataDownload
+            .getFileGroup(GetFileGroupRequest.newBuilder().setGroupName(FILE_GROUP_NAME_1).build())
+            .get());
+  }
+
+  @Test
+  public void getFileGroup_null() throws Exception {
+    when(mockMobileDataDownloadManager.getFileGroup(any(GroupKey.class), eq(true)))
+        .thenReturn(Futures.immediateFuture(null));
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            ImmutableList.of() /* fileGroupPopulatorList */,
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            Optional.absent() /* downloadMonitorOptional */,
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    assertNull(
+        mobileDataDownload
+            .getFileGroup(GetFileGroupRequest.newBuilder().setGroupName(FILE_GROUP_NAME_1).build())
+            .get());
+
+    verifyNoInteractions(mockEventLogger);
+  }
+
+  @Test
+  public void getFileGroup_withAccount() throws Exception {
+    DataFileGroupInternal dataFileGroup =
+        createDataFileGroupInternal(
+            FILE_GROUP_NAME_1,
+            context.getPackageName(),
+            5 /* versionNumber */,
+            new String[] {FILE_ID_1},
+            new int[] {FILE_SIZE_1},
+            new String[] {FILE_CHECKSUM_1},
+            new String[] {FILE_URL_1},
+            DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI);
+    ArgumentCaptor<GroupKey> groupKeyCaptor = ArgumentCaptor.forClass(GroupKey.class);
+    when(mockMobileDataDownloadManager.getFileGroup(groupKeyCaptor.capture(), eq(true)))
+        .thenReturn(Futures.immediateFuture(dataFileGroup));
+    when(mockMobileDataDownloadManager.getDataFileUri(dataFileGroup.getFile(0), dataFileGroup))
+        .thenReturn(Futures.immediateFuture(onDeviceUri1));
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            ImmutableList.of() /* fileGroupPopulatorList */,
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            Optional.absent() /* downloadMonitorOptional */,
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    Account account = AccountUtil.create("account-name", "account-type");
+    ClientFileGroup clientFileGroup =
+        mobileDataDownload
+            .getFileGroup(
+                GetFileGroupRequest.newBuilder()
+                    .setGroupName(FILE_GROUP_NAME_1)
+                    .setAccountOptional(Optional.of(account))
+                    .build())
+            .get();
+    assertThat(clientFileGroup.getGroupName()).isEqualTo(FILE_GROUP_NAME_1);
+    assertThat(clientFileGroup.getOwnerPackage()).isEqualTo(context.getPackageName());
+    assertThat(clientFileGroup.getVersionNumber()).isEqualTo(5);
+    assertThat(clientFileGroup.getAccount()).isEqualTo(AccountUtil.serialize(account));
+    assertThat(clientFileGroup.getFileCount()).isEqualTo(1);
+
+    ClientFile clientFile = clientFileGroup.getFileList().get(0);
+    assertThat(clientFile.getFileId()).isEqualTo(FILE_ID_1);
+    assertThat(clientFile.getFileUri()).isEqualTo(onDeviceUri1.toString());
+
+    GroupKey groupKey =
+        GroupKey.newBuilder()
+            .setGroupName(FILE_GROUP_NAME_1)
+            .setOwnerPackage(context.getPackageName())
+            .setAccount(AccountUtil.serialize(account))
+            .build();
+    assertThat(groupKeyCaptor.getValue()).isEqualTo(groupKey);
+  }
+
+  @Test
+  public void getFileGroup_withVariantId() throws Exception {
+    DataFileGroupInternal dataFileGroup =
+        createDataFileGroupInternal(
+                FILE_GROUP_NAME_1,
+                context.getPackageName(),
+                5 /* versionNumber */,
+                new String[] {FILE_ID_1},
+                new int[] {FILE_SIZE_1},
+                new String[] {FILE_CHECKSUM_1},
+                new String[] {FILE_URL_1},
+                DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI)
+            .toBuilder()
+            .setVariantId("en")
+            .build();
+
+    ArgumentCaptor<GroupKey> groupKeyCaptor = ArgumentCaptor.forClass(GroupKey.class);
+    when(mockMobileDataDownloadManager.getFileGroup(groupKeyCaptor.capture(), eq(true)))
+        .thenReturn(Futures.immediateFuture(dataFileGroup));
+    when(mockMobileDataDownloadManager.getDataFileUri(dataFileGroup.getFile(0), dataFileGroup))
+        .thenReturn(Futures.immediateFuture(onDeviceUri1));
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            ImmutableList.of() /* fileGroupPopulatorList */,
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            Optional.absent() /* downloadMonitorOptional */,
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    ClientFileGroup clientFileGroup =
+        mobileDataDownload
+            .getFileGroup(
+                GetFileGroupRequest.newBuilder()
+                    .setGroupName(FILE_GROUP_NAME_1)
+                    .setVariantIdOptional(Optional.of("en"))
+                    .build())
+            .get();
+
+    assertThat(clientFileGroup.getGroupName()).isEqualTo(FILE_GROUP_NAME_1);
+    assertThat(clientFileGroup.getOwnerPackage()).isEqualTo(context.getPackageName());
+    assertThat(clientFileGroup.getVersionNumber()).isEqualTo(5);
+    assertThat(clientFileGroup.getAccount()).isEmpty();
+    assertThat(clientFileGroup.getVariantId()).isEqualTo("en");
+    assertThat(clientFileGroup.getFileCount()).isEqualTo(1);
+
+    GroupKey groupKey =
+        GroupKey.newBuilder()
+            .setGroupName(FILE_GROUP_NAME_1)
+            .setOwnerPackage(context.getPackageName())
+            .setVariantId("en")
+            .build();
+    assertThat(groupKeyCaptor.getValue()).isEqualTo(groupKey);
+  }
+
+  @Test
+  public void getFileGroup_includesIdentifyingProperties() throws Exception {
+    Any customProperty =
+        Any.newBuilder()
+            .setTypeUrl("type.googleapis.com/google.protobuf.stringvalue")
+            .setValue(StringValue.of("TEST_PROPERTY").toByteString())
+            .build();
+    DataFileGroupInternal dataFileGroup =
+        createDataFileGroupInternal(
+                FILE_GROUP_NAME_1,
+                context.getPackageName(),
+                5 /* versionNumber */,
+                new String[] {FILE_ID_1},
+                new int[] {FILE_SIZE_1},
+                new String[] {FILE_CHECKSUM_1},
+                new String[] {FILE_URL_1},
+                DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI)
+            .toBuilder()
+            .setBuildId(1L)
+            .setVariantId("testvariant")
+            .setCustomProperty(customProperty)
+            .build();
+
+    when(mockMobileDataDownloadManager.getFileGroup(any(GroupKey.class), eq(true)))
+        .thenReturn(Futures.immediateFuture(dataFileGroup));
+    when(mockMobileDataDownloadManager.getDataFileUri(dataFileGroup.getFile(0), dataFileGroup))
+        .thenReturn(Futures.immediateFuture(onDeviceUri1));
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            ImmutableList.of() /* fileGroupPopulatorList */,
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            Optional.absent() /* downloadMonitorOptional */,
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    ClientFileGroup clientFileGroup =
+        mobileDataDownload
+            .getFileGroup(GetFileGroupRequest.newBuilder().setGroupName(FILE_GROUP_NAME_1).build())
+            .get();
+
+    assertThat(clientFileGroup.getGroupName()).isEqualTo(FILE_GROUP_NAME_1);
+    assertThat(clientFileGroup.getVersionNumber()).isEqualTo(5);
+    assertThat(clientFileGroup.getBuildId()).isEqualTo(1L);
+    assertThat(clientFileGroup.getVariantId()).isEqualTo("testvariant");
+    assertThat(clientFileGroup.getCustomProperty()).isEqualTo(customProperty);
+  }
+
+  @Test
+  public void getFileGroup_includesLocale() throws Exception {
+    DataFileGroupInternal dataFileGroup =
+        createDataFileGroupInternal(
+                FILE_GROUP_NAME_1,
+                context.getPackageName(),
+                5 /* versionNumber */,
+                new String[] {FILE_ID_1},
+                new int[] {FILE_SIZE_1},
+                new String[] {FILE_CHECKSUM_1},
+                new String[] {FILE_URL_1},
+                DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI)
+            .toBuilder()
+            .addLocale("en-US")
+            .addLocale("en-CA")
+            .build();
+
+    when(mockMobileDataDownloadManager.getFileGroup(any(GroupKey.class), eq(true)))
+        .thenReturn(Futures.immediateFuture(dataFileGroup));
+    when(mockMobileDataDownloadManager.getDataFileUri(dataFileGroup.getFile(0), dataFileGroup))
+        .thenReturn(Futures.immediateFuture(onDeviceUri1));
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            ImmutableList.of() /* fileGroupPopulatorList */,
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            Optional.absent() /* downloadMonitorOptional */,
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    ClientFileGroup clientFileGroup =
+        mobileDataDownload
+            .getFileGroup(GetFileGroupRequest.newBuilder().setGroupName(FILE_GROUP_NAME_1).build())
+            .get();
+
+    assertThat(clientFileGroup.getGroupName()).isEqualTo(FILE_GROUP_NAME_1);
+    assertThat(clientFileGroup.getLocaleList()).containsExactly("en-US", "en-CA");
+  }
+
+  @Test
+  public void getFileGroup_includesGroupLevelMetadataWhenProvided() throws Exception {
+    Any customMetadata =
+        Any.newBuilder()
+            .setTypeUrl("type.googleapis.com/google.protobuf.stringvalue")
+            .setValue(StringValue.of("TEST_METADATA").toByteString())
+            .build();
+    DataFileGroupInternal dataFileGroup =
+        createDataFileGroupInternal(
+                FILE_GROUP_NAME_1,
+                context.getPackageName(),
+                5 /* versionNumber */,
+                new String[] {FILE_ID_1},
+                new int[] {FILE_SIZE_1},
+                new String[] {FILE_CHECKSUM_1},
+                new String[] {FILE_URL_1},
+                DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI)
+            .toBuilder()
+            .setCustomMetadata(customMetadata)
+            .build();
+
+    when(mockMobileDataDownloadManager.getFileGroup(any(GroupKey.class), eq(true)))
+        .thenReturn(Futures.immediateFuture(dataFileGroup));
+    when(mockMobileDataDownloadManager.getDataFileUri(dataFileGroup.getFile(0), dataFileGroup))
+        .thenReturn(Futures.immediateFuture(onDeviceUri1));
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            ImmutableList.of() /* fileGroupPopulatorList */,
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            Optional.absent() /* downloadMonitorOptional */,
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    ClientFileGroup clientFileGroup =
+        mobileDataDownload
+            .getFileGroup(GetFileGroupRequest.newBuilder().setGroupName(FILE_GROUP_NAME_1).build())
+            .get();
+
+    assertThat(clientFileGroup.getGroupName()).isEqualTo(FILE_GROUP_NAME_1);
+    assertThat(clientFileGroup.getCustomMetadata()).isEqualTo(customMetadata);
+  }
+
+  @Test
+  public void getFileGroup_includesFileLevelMetadataWhenProvided() throws Exception {
+    Any customMetadata =
+        Any.newBuilder()
+            .setTypeUrl("type.googleapis.com/google.protobuf.stringvalue")
+            .setValue(StringValue.of("TEST_METADATA").toByteString())
+            .build();
+    DataFileGroupInternal dataFileGroup =
+        DataFileGroupInternal.newBuilder()
+            .setGroupName(FILE_GROUP_NAME_1)
+            .setOwnerPackage(context.getPackageName())
+            .addFile(
+                MetadataProto.DataFile.newBuilder()
+                    .setFileId(FILE_ID_1)
+                    .setUrlToDownload(FILE_URL_1)
+                    .setCustomMetadata(customMetadata)
+                    .build())
+            .addFile(
+                MetadataProto.DataFile.newBuilder()
+                    .setFileId(FILE_ID_2)
+                    .setUrlToDownload(FILE_URL_2)
+                    .build())
+            .build();
+
+    when(mockMobileDataDownloadManager.getFileGroup(any(GroupKey.class), eq(true)))
+        .thenReturn(Futures.immediateFuture(dataFileGroup));
+    when(mockMobileDataDownloadManager.getDataFileUri(dataFileGroup.getFile(0), dataFileGroup))
+        .thenReturn(Futures.immediateFuture(onDeviceUri1));
+    when(mockMobileDataDownloadManager.getDataFileUri(dataFileGroup.getFile(1), dataFileGroup))
+        .thenReturn(Futures.immediateFuture(onDeviceUri1));
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            ImmutableList.of() /* fileGroupPopulatorList */,
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            Optional.absent() /* downloadMonitorOptional */,
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    ClientFileGroup clientFileGroup =
+        mobileDataDownload
+            .getFileGroup(GetFileGroupRequest.newBuilder().setGroupName(FILE_GROUP_NAME_1).build())
+            .get();
+
+    assertThat(clientFileGroup.getGroupName()).isEqualTo(FILE_GROUP_NAME_1);
+    assertThat(clientFileGroup.hasCustomMetadata()).isFalse();
+
+    ClientFile clientFile1 =
+        clientFileGroup.getFile(0).getFileId().equals(FILE_ID_1)
+            ? clientFileGroup.getFile(0)
+            : clientFileGroup.getFile(1);
+    ClientFile clientFile2 =
+        clientFileGroup.getFile(0).getFileId().equals(FILE_ID_1)
+            ? clientFileGroup.getFile(1)
+            : clientFileGroup.getFile(0);
+
+    assertThat(clientFile1.hasCustomMetadata()).isTrue();
+    assertThat(clientFile1.getCustomMetadata()).isEqualTo(customMetadata);
+    assertThat(clientFile2.hasCustomMetadata()).isFalse();
+  }
+
+  @Test
+  public void getFileGroupsByFilter_singleGroup() throws Exception {
+    List<Pair<GroupKey, DataFileGroupInternal>> keyDataFileGroupList = new ArrayList<>();
+
+    DataFileGroupInternal downloadedFileGroup =
+        createDataFileGroupInternal(
+            FILE_GROUP_NAME_1,
+            context.getPackageName(),
+            5 /* versionNumber */,
+            new String[] {FILE_ID_1},
+            new int[] {FILE_SIZE_1},
+            new String[] {FILE_CHECKSUM_1},
+            new String[] {FILE_URL_1},
+            DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI);
+
+    GroupKey groupKey =
+        GroupKey.newBuilder()
+            .setGroupName(FILE_GROUP_NAME_1)
+            .setOwnerPackage(context.getPackageName())
+            .build();
+    when(mockMobileDataDownloadManager.getFileGroup(eq(groupKey), eq(true)))
+        .thenReturn(Futures.immediateFuture(downloadedFileGroup));
+    when(mockMobileDataDownloadManager.getDataFileUri(
+            downloadedFileGroup.getFile(0), downloadedFileGroup))
+        .thenReturn(Futures.immediateFuture(onDeviceUri1));
+    keyDataFileGroupList.add(
+        Pair.create(groupKey.toBuilder().setDownloaded(true).build(), downloadedFileGroup));
+
+    DataFileGroupInternal pendingFileGroup =
+        createDataFileGroupInternal(
+            FILE_GROUP_NAME_1,
+            context.getPackageName(),
+            7 /* versionNumber */,
+            new String[] {FILE_ID_1},
+            new int[] {FILE_SIZE_2},
+            new String[] {FILE_CHECKSUM_2},
+            new String[] {FILE_URL_2},
+            DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI);
+    pendingFileGroup =
+        pendingFileGroup.toBuilder()
+            .setFile(0, pendingFileGroup.getFile(0).toBuilder().setDownloadedFileByteSize(222222))
+            .build();
+    when(mockMobileDataDownloadManager.getFileGroup(groupKey, false))
+        .thenReturn(Futures.immediateFuture(pendingFileGroup));
+    keyDataFileGroupList.add(
+        Pair.create(groupKey.toBuilder().setDownloaded(false).build(), pendingFileGroup));
+
+    DataFileGroupInternal pendingFileGroup2 =
+        createDataFileGroupInternal(
+            FILE_GROUP_NAME_2,
+            context.getPackageName(),
+            4 /* versionNumber */,
+            new String[] {FILE_ID_1, FILE_ID_2},
+            new int[] {FILE_SIZE_1, FILE_SIZE_2},
+            new String[] {FILE_CHECKSUM_1, FILE_CHECKSUM_2},
+            new String[] {FILE_URL_1, FILE_URL_2},
+            DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI);
+    GroupKey groupKey2 =
+        GroupKey.newBuilder()
+            .setGroupName(FILE_GROUP_NAME_2)
+            .setOwnerPackage(context.getPackageName())
+            .build();
+    when(mockMobileDataDownloadManager.getFileGroup(groupKey2, false))
+        .thenReturn(Futures.immediateFuture(pendingFileGroup2));
+    keyDataFileGroupList.add(
+        Pair.create(groupKey2.toBuilder().setDownloaded(false).build(), pendingFileGroup2));
+
+    when(mockMobileDataDownloadManager.getAllFreshGroups())
+        .thenReturn(Futures.immediateFuture(keyDataFileGroupList));
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            ImmutableList.of() /* fileGroupPopulatorList */,
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            Optional.absent() /* downloadMonitorOptional */,
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    // We should get back 2 groups for FILE_GROUP_NAME_1.
+    GetFileGroupsByFilterRequest getFileGroupsByFilterRequest =
+        GetFileGroupsByFilterRequest.newBuilder()
+            .setGroupNameOptional(Optional.of(FILE_GROUP_NAME_1))
+            .build();
+    ImmutableList<ClientFileGroup> clientFileGroups =
+        mobileDataDownload.getFileGroupsByFilter(getFileGroupsByFilterRequest).get();
+    assertThat(clientFileGroups).hasSize(2);
+
+    ClientFileGroup downloadedClientFileGroup = clientFileGroups.get(0);
+    assertThat(downloadedClientFileGroup.getGroupName()).isEqualTo(FILE_GROUP_NAME_1);
+    assertThat(downloadedClientFileGroup.getOwnerPackage()).isEqualTo(context.getPackageName());
+    assertThat(downloadedClientFileGroup.getVersionNumber()).isEqualTo(5);
+    assertThat(downloadedClientFileGroup.getStatus()).isEqualTo(Status.DOWNLOADED);
+    assertThat(downloadedClientFileGroup.getFileCount()).isEqualTo(1);
+    assertThat(downloadedClientFileGroup.hasAccount()).isFalse();
+
+    ClientFile clientFile = downloadedClientFileGroup.getFileList().get(0);
+    assertThat(clientFile.getFileId()).isEqualTo(FILE_ID_1);
+    assertThat(clientFile.getFileUri()).isEqualTo(onDeviceUri1.toString());
+    assertThat(clientFile.getFullSizeInBytes()).isEqualTo(FILE_SIZE_1);
+
+    ClientFileGroup pendingClientFileGroup = clientFileGroups.get(1);
+    assertThat(pendingClientFileGroup.getGroupName()).isEqualTo(FILE_GROUP_NAME_1);
+    assertThat(pendingClientFileGroup.getOwnerPackage()).isEqualTo(context.getPackageName());
+    assertThat(pendingClientFileGroup.getVersionNumber()).isEqualTo(7);
+    assertThat(pendingClientFileGroup.getStatus()).isEqualTo(Status.PENDING);
+    assertThat(pendingClientFileGroup.getFileCount()).isEqualTo(1);
+    assertThat(pendingClientFileGroup.hasAccount()).isFalse();
+
+    clientFile = pendingClientFileGroup.getFileList().get(0);
+    assertThat(clientFile.getFileId()).isEqualTo(FILE_ID_1);
+    assertThat(clientFile.getDownloadSizeInBytes()).isEqualTo(222222);
+    assertThat(clientFile.getFileUri()).isEmpty();
+
+    // We should get back 1 group for FILE_GROUP_NAME_2.
+    getFileGroupsByFilterRequest =
+        GetFileGroupsByFilterRequest.newBuilder()
+            .setGroupNameOptional(Optional.of(FILE_GROUP_NAME_2))
+            .build();
+    clientFileGroups = mobileDataDownload.getFileGroupsByFilter(getFileGroupsByFilterRequest).get();
+    ClientFileGroup pendingClientFileGroup2 = clientFileGroups.get(0);
+    assertThat(pendingClientFileGroup2.getGroupName()).isEqualTo(FILE_GROUP_NAME_2);
+    assertThat(pendingClientFileGroup2.getOwnerPackage()).isEqualTo(context.getPackageName());
+    assertThat(pendingClientFileGroup2.getVersionNumber()).isEqualTo(4);
+    assertThat(pendingClientFileGroup2.getStatus()).isEqualTo(Status.PENDING);
+    assertThat(pendingClientFileGroup2.getFileCount()).isEqualTo(2);
+    assertThat(pendingClientFileGroup2.hasAccount()).isFalse();
+  }
+
+  @Test
+  public void getFileGroupsByFilter_includeAllGroups() throws Exception {
+    List<Pair<GroupKey, DataFileGroupInternal>> keyDataFileGroupList = new ArrayList<>();
+
+    Account account = AccountUtil.create("account-name", "account-type");
+
+    DataFileGroupInternal downloadedFileGroup =
+        createDataFileGroupInternal(
+            FILE_GROUP_NAME_1,
+            context.getPackageName(),
+            5 /* versionNumber */,
+            new String[] {FILE_ID_1},
+            new int[] {FILE_SIZE_1},
+            new String[] {FILE_CHECKSUM_1},
+            new String[] {FILE_URL_1},
+            DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI);
+    GroupKey groupKey =
+        GroupKey.newBuilder()
+            .setGroupName(FILE_GROUP_NAME_1)
+            .setOwnerPackage(context.getPackageName())
+            .setAccount(AccountUtil.serialize(account))
+            .build();
+    when(mockMobileDataDownloadManager.getDataFileUri(
+            downloadedFileGroup.getFile(0), downloadedFileGroup))
+        .thenReturn(Futures.immediateFuture(onDeviceUri1));
+    keyDataFileGroupList.add(
+        Pair.create(groupKey.toBuilder().setDownloaded(true).build(), downloadedFileGroup));
+
+    DataFileGroupInternal pendingFileGroup =
+        createDataFileGroupInternal(
+            FILE_GROUP_NAME_1,
+            context.getPackageName(),
+            7,
+            new String[] {FILE_ID_1},
+            new int[] {FILE_SIZE_2},
+            new String[] {FILE_CHECKSUM_2},
+            new String[] {FILE_URL_2},
+            DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI);
+    keyDataFileGroupList.add(
+        Pair.create(groupKey.toBuilder().setDownloaded(false).build(), pendingFileGroup));
+
+    DataFileGroupInternal pendingFileGroup2 =
+        createDataFileGroupInternal(
+            FILE_GROUP_NAME_2,
+            context.getPackageName(),
+            4 /* versionNumber */,
+            new String[] {FILE_ID_1, FILE_ID_2},
+            new int[] {FILE_SIZE_1, FILE_SIZE_2},
+            new String[] {FILE_CHECKSUM_1, FILE_CHECKSUM_2},
+            new String[] {FILE_URL_1, FILE_URL_2},
+            DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI);
+    GroupKey groupKey2 =
+        GroupKey.newBuilder()
+            .setGroupName(FILE_GROUP_NAME_2)
+            .setOwnerPackage(context.getPackageName())
+            .build();
+    when(mockMobileDataDownloadManager.getFileGroup(groupKey2, false))
+        .thenReturn(Futures.immediateFuture(pendingFileGroup2));
+    keyDataFileGroupList.add(
+        Pair.create(groupKey2.toBuilder().setDownloaded(false).build(), pendingFileGroup2));
+
+    when(mockMobileDataDownloadManager.getAllFreshGroups())
+        .thenReturn(Futures.immediateFuture(keyDataFileGroupList));
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            ImmutableList.of() /* fileGroupPopulatorList */,
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            Optional.of(mockDownloadMonitor),
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    // We should get back all 3 groups for this key.
+    GetFileGroupsByFilterRequest getFileGroupsByFilterRequest =
+        GetFileGroupsByFilterRequest.newBuilder().setIncludeAllGroups(true).build();
+    List<ClientFileGroup> clientFileGroups =
+        mobileDataDownload.getFileGroupsByFilter(getFileGroupsByFilterRequest).get();
+    assertThat(clientFileGroups).hasSize(3);
+
+    ClientFileGroup downloadedClientFileGroup = clientFileGroups.get(0);
+    assertThat(downloadedClientFileGroup.getGroupName()).isEqualTo(FILE_GROUP_NAME_1);
+    assertThat(downloadedClientFileGroup.getOwnerPackage()).isEqualTo(context.getPackageName());
+    assertThat(downloadedClientFileGroup.getVersionNumber()).isEqualTo(5);
+    assertThat(downloadedClientFileGroup.getStatus()).isEqualTo(Status.DOWNLOADED);
+    assertThat(downloadedClientFileGroup.getFileCount()).isEqualTo(1);
+    assertThat(downloadedClientFileGroup.getAccount()).isEqualTo(AccountUtil.serialize(account));
+
+    ClientFile clientFile = downloadedClientFileGroup.getFileList().get(0);
+    assertThat(clientFile.getFileId()).isEqualTo(FILE_ID_1);
+    assertThat(clientFile.getFileUri()).isEqualTo(onDeviceUri1.toString());
+    assertThat(clientFile.getFullSizeInBytes()).isEqualTo(FILE_SIZE_1);
+
+    ClientFileGroup pendingClientFileGroup = clientFileGroups.get(1);
+    assertThat(pendingClientFileGroup.getGroupName()).isEqualTo(FILE_GROUP_NAME_1);
+    assertThat(pendingClientFileGroup.getOwnerPackage()).isEqualTo(context.getPackageName());
+    assertThat(pendingClientFileGroup.getVersionNumber()).isEqualTo(7);
+    assertThat(pendingClientFileGroup.getStatus()).isEqualTo(Status.PENDING);
+    assertThat(pendingClientFileGroup.getFileCount()).isEqualTo(1);
+    assertThat(pendingClientFileGroup.getAccount()).isEqualTo(AccountUtil.serialize(account));
+
+    clientFile = pendingClientFileGroup.getFileList().get(0);
+    assertThat(clientFile.getFileId()).isEqualTo(FILE_ID_1);
+    assertThat(clientFile.getFileUri()).isEmpty();
+
+    ClientFileGroup pendingClientFileGroup2 = clientFileGroups.get(2);
+    assertThat(pendingClientFileGroup2.getGroupName()).isEqualTo(FILE_GROUP_NAME_2);
+    assertThat(pendingClientFileGroup2.getOwnerPackage()).isEqualTo(context.getPackageName());
+    assertThat(pendingClientFileGroup2.getVersionNumber()).isEqualTo(4);
+    assertThat(pendingClientFileGroup2.getStatus()).isEqualTo(Status.PENDING);
+    assertThat(pendingClientFileGroup2.getFileCount()).isEqualTo(2);
+    assertThat(pendingClientFileGroup2.hasAccount()).isFalse();
+  }
+
+  @Test
+  public void getFileGroupsByFilter_noData() throws Exception {
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            ImmutableList.of() /* fileGroupPopulatorList */,
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            Optional.absent() /* downloadMonitorOptional */,
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+    when(mockMobileDataDownloadManager.getAllFreshGroups())
+        .thenReturn(Futures.immediateFuture(ImmutableList.of()));
+
+    GetFileGroupsByFilterRequest getFileGroupsByFilterRequest =
+        GetFileGroupsByFilterRequest.newBuilder().setIncludeAllGroups(true).build();
+    List<ClientFileGroup> clientFileGroups =
+        mobileDataDownload.getFileGroupsByFilter(getFileGroupsByFilterRequest).get();
+    assertThat(clientFileGroups).isEmpty();
+
+    getFileGroupsByFilterRequest =
+        GetFileGroupsByFilterRequest.newBuilder()
+            .setGroupNameOptional(Optional.of(FILE_GROUP_NAME_1))
+            .build();
+    clientFileGroups = mobileDataDownload.getFileGroupsByFilter(getFileGroupsByFilterRequest).get();
+    assertThat(clientFileGroups).isEmpty();
+
+    verifyNoInteractions(mockEventLogger);
+  }
+
+  @Test
+  public void getFileGroupsByFilter_withAccount() throws Exception {
+    List<Pair<GroupKey, DataFileGroupInternal>> keyDataFileGroupList = new ArrayList<>();
+
+    Account account1 = AccountUtil.create("account-name-1", "account-type");
+    Account account2 = AccountUtil.create("account-name-2", "account-type");
+
+    DataFileGroupInternal downloadedFileGroup =
+        createDataFileGroupInternal(
+            FILE_GROUP_NAME_1,
+            context.getPackageName(),
+            5 /* versionNumber */,
+            new String[] {FILE_ID_1},
+            new int[] {FILE_SIZE_1},
+            new String[] {FILE_CHECKSUM_1},
+            new String[] {FILE_URL_1},
+            DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI);
+    GroupKey groupKey =
+        GroupKey.newBuilder()
+            .setGroupName(FILE_GROUP_NAME_1)
+            .setOwnerPackage(context.getPackageName())
+            .setAccount(AccountUtil.serialize(account1))
+            .build();
+    when(mockMobileDataDownloadManager.getFileGroup(groupKey, true))
+        .thenReturn(Futures.immediateFuture(downloadedFileGroup));
+    when(mockMobileDataDownloadManager.getDataFileUri(
+            downloadedFileGroup.getFile(0), downloadedFileGroup))
+        .thenReturn(Futures.immediateFuture(onDeviceUri1));
+    keyDataFileGroupList.add(
+        Pair.create(groupKey.toBuilder().setDownloaded(true).build(), downloadedFileGroup));
+
+    DataFileGroupInternal pendingFileGroup =
+        createDataFileGroupInternal(
+            FILE_GROUP_NAME_1,
+            context.getPackageName(),
+            7 /* versionNumber */,
+            new String[] {FILE_ID_1},
+            new int[] {FILE_SIZE_2},
+            new String[] {FILE_CHECKSUM_2},
+            new String[] {FILE_URL_2},
+            DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI);
+    when(mockMobileDataDownloadManager.getFileGroup(groupKey, false))
+        .thenReturn(Futures.immediateFuture(pendingFileGroup));
+    keyDataFileGroupList.add(
+        Pair.create(groupKey.toBuilder().setDownloaded(false).build(), pendingFileGroup));
+
+    DataFileGroupInternal pendingFileGroup2 =
+        createDataFileGroupInternal(
+            FILE_GROUP_NAME_1,
+            context.getPackageName(),
+            4 /* versionNumber */,
+            new String[] {FILE_ID_1, FILE_ID_2},
+            new int[] {FILE_SIZE_1, FILE_SIZE_2},
+            new String[] {FILE_CHECKSUM_1, FILE_CHECKSUM_2},
+            new String[] {FILE_URL_1, FILE_URL_2},
+            DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI);
+    GroupKey groupKey2 =
+        GroupKey.newBuilder()
+            .setGroupName(FILE_GROUP_NAME_1)
+            .setOwnerPackage(context.getPackageName())
+            .setAccount(AccountUtil.serialize(account2))
+            .build();
+    when(mockMobileDataDownloadManager.getFileGroup(groupKey2, false))
+        .thenReturn(Futures.immediateFuture(pendingFileGroup2));
+    keyDataFileGroupList.add(
+        Pair.create(groupKey2.toBuilder().setDownloaded(false).build(), pendingFileGroup2));
+
+    when(mockMobileDataDownloadManager.getAllFreshGroups())
+        .thenReturn(Futures.immediateFuture(keyDataFileGroupList));
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            ImmutableList.of() /* fileGroupPopulatorList */,
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            Optional.absent() /* downloadMonitorOptional */,
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    // We should get back 2 groups for FILE_GROUP_NAME_1 with account1.
+    GetFileGroupsByFilterRequest getFileGroupsByFilterRequest =
+        GetFileGroupsByFilterRequest.newBuilder()
+            .setGroupNameOptional(Optional.of(FILE_GROUP_NAME_1))
+            .setAccountOptional(Optional.of(account1))
+            .build();
+    ImmutableList<ClientFileGroup> clientFileGroups =
+        mobileDataDownload.getFileGroupsByFilter(getFileGroupsByFilterRequest).get();
+    assertThat(clientFileGroups).hasSize(2);
+
+    ClientFileGroup downloadedClientFileGroup = clientFileGroups.get(0);
+    assertThat(downloadedClientFileGroup.getGroupName()).isEqualTo(FILE_GROUP_NAME_1);
+    assertThat(downloadedClientFileGroup.getOwnerPackage()).isEqualTo(context.getPackageName());
+    assertThat(downloadedClientFileGroup.getVersionNumber()).isEqualTo(5);
+    assertThat(downloadedClientFileGroup.getAccount()).isEqualTo(AccountUtil.serialize(account1));
+    assertThat(downloadedClientFileGroup.getStatus()).isEqualTo(Status.DOWNLOADED);
+    assertThat(downloadedClientFileGroup.getFileCount()).isEqualTo(1);
+
+    ClientFile clientFile = downloadedClientFileGroup.getFileList().get(0);
+    assertThat(clientFile.getFileId()).isEqualTo(FILE_ID_1);
+    assertThat(clientFile.getFileUri()).isEqualTo(onDeviceUri1.toString());
+    assertThat(clientFile.getFullSizeInBytes()).isEqualTo(FILE_SIZE_1);
+
+    ClientFileGroup pendingClientFileGroup = clientFileGroups.get(1);
+    assertThat(pendingClientFileGroup.getGroupName()).isEqualTo(FILE_GROUP_NAME_1);
+    assertThat(pendingClientFileGroup.getOwnerPackage()).isEqualTo(context.getPackageName());
+    assertThat(pendingClientFileGroup.getVersionNumber()).isEqualTo(7);
+    assertThat(pendingClientFileGroup.getAccount()).isEqualTo(AccountUtil.serialize(account1));
+    assertThat(pendingClientFileGroup.getStatus()).isEqualTo(Status.PENDING);
+    assertThat(pendingClientFileGroup.getFileCount()).isEqualTo(1);
+
+    clientFile = pendingClientFileGroup.getFileList().get(0);
+    assertThat(clientFile.getFileId()).isEqualTo(FILE_ID_1);
+    assertThat(clientFile.getFileUri()).isEmpty();
+
+    // We should get back 1 group for FILE_GROUP_NAME_1 with account2.
+    getFileGroupsByFilterRequest =
+        GetFileGroupsByFilterRequest.newBuilder()
+            .setGroupNameOptional(Optional.of(FILE_GROUP_NAME_1))
+            .setAccountOptional(Optional.of(account2))
+            .build();
+    clientFileGroups = mobileDataDownload.getFileGroupsByFilter(getFileGroupsByFilterRequest).get();
+    ClientFileGroup pendingClientFileGroup2 = clientFileGroups.get(0);
+    assertThat(pendingClientFileGroup2.getGroupName()).isEqualTo(FILE_GROUP_NAME_1);
+    assertThat(pendingClientFileGroup2.getOwnerPackage()).isEqualTo(context.getPackageName());
+    assertThat(pendingClientFileGroup2.getVersionNumber()).isEqualTo(4);
+    assertThat(pendingClientFileGroup2.getAccount()).isEqualTo(AccountUtil.serialize(account2));
+    assertThat(pendingClientFileGroup2.getStatus()).isEqualTo(Status.PENDING);
+    assertThat(pendingClientFileGroup2.getFileCount()).isEqualTo(2);
+  }
+
+  @Test
+  public void getFileGroupsByFilter_groupWithNoAccountOnly() throws Exception {
+    List<Pair<GroupKey, DataFileGroupInternal>> keyDataFileGroupList = new ArrayList<>();
+
+    Account account1 = AccountUtil.create("account-name-1", "account-type");
+    Account account2 = AccountUtil.create("account-name-2", "account-type");
+
+    // downloadedFileGroup is associated with account1.
+    DataFileGroupInternal downloadedFileGroup =
+        createDataFileGroupInternal(
+            FILE_GROUP_NAME_1,
+            context.getPackageName(),
+            /*versionNumber=*/ 5,
+            new String[] {FILE_ID_1},
+            new int[] {FILE_SIZE_1},
+            new String[] {FILE_CHECKSUM_1},
+            new String[] {FILE_URL_1},
+            DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI);
+    GroupKey groupKey =
+        GroupKey.newBuilder()
+            .setGroupName(FILE_GROUP_NAME_1)
+            .setOwnerPackage(context.getPackageName())
+            .setAccount(AccountUtil.serialize(account1))
+            .build();
+    when(mockMobileDataDownloadManager.getFileGroup(groupKey, true))
+        .thenReturn(Futures.immediateFuture(downloadedFileGroup));
+    when(mockMobileDataDownloadManager.getDataFileUri(
+            downloadedFileGroup.getFile(0), downloadedFileGroup))
+        .thenReturn(Futures.immediateFuture(onDeviceUri1));
+    keyDataFileGroupList.add(
+        Pair.create(groupKey.toBuilder().setDownloaded(true).build(), downloadedFileGroup));
+
+    // pendingFileGroup is associated with account2.
+    DataFileGroupInternal pendingFileGroup =
+        createDataFileGroupInternal(
+            FILE_GROUP_NAME_1,
+            context.getPackageName(),
+            /*versionNumber=*/ 7,
+            new String[] {FILE_ID_1},
+            new int[] {FILE_SIZE_2},
+            new String[] {FILE_CHECKSUM_2},
+            new String[] {FILE_URL_2},
+            DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI);
+    GroupKey groupKey2 =
+        GroupKey.newBuilder()
+            .setGroupName(FILE_GROUP_NAME_1)
+            .setOwnerPackage(context.getPackageName())
+            .setAccount(AccountUtil.serialize(account2))
+            .build();
+    when(mockMobileDataDownloadManager.getFileGroup(groupKey2, false))
+        .thenReturn(Futures.immediateFuture(pendingFileGroup));
+    keyDataFileGroupList.add(
+        Pair.create(groupKey2.toBuilder().setDownloaded(false).build(), pendingFileGroup));
+
+    // pendingFileGroup2 is an account independent group.
+    DataFileGroupInternal pendingFileGroup2 =
+        createDataFileGroupInternal(
+            FILE_GROUP_NAME_1,
+            context.getPackageName(),
+            /*versionNumber=*/ 4,
+            new String[] {FILE_ID_1, FILE_ID_2},
+            new int[] {FILE_SIZE_1, FILE_SIZE_2},
+            new String[] {FILE_CHECKSUM_1, FILE_CHECKSUM_2},
+            new String[] {FILE_URL_1, FILE_URL_2},
+            DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI);
+    GroupKey groupKey3 =
+        GroupKey.newBuilder()
+            .setGroupName(FILE_GROUP_NAME_1)
+            .setOwnerPackage(context.getPackageName())
+            .build();
+    when(mockMobileDataDownloadManager.getFileGroup(groupKey3, false))
+        .thenReturn(Futures.immediateFuture(pendingFileGroup2));
+    keyDataFileGroupList.add(
+        Pair.create(groupKey3.toBuilder().setDownloaded(false).build(), pendingFileGroup2));
+
+    when(mockMobileDataDownloadManager.getAllFreshGroups())
+        .thenReturn(Futures.immediateFuture(keyDataFileGroupList));
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            /*fileGroupPopulatorList=*/ ImmutableList.of(),
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            /*downloadMonitorOptional=*/ Optional.absent(),
+            /* foregroundDownloadServiceClassOptional = */ Optional.absent(),
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    // We should get back only 1 group for FILE_GROUP_NAME_1 with groupWithNoAccountOnly being set
+    // to true.
+    GetFileGroupsByFilterRequest getFileGroupsByFilterRequest =
+        GetFileGroupsByFilterRequest.newBuilder()
+            .setGroupWithNoAccountOnly(true)
+            .setGroupNameOptional(Optional.of(FILE_GROUP_NAME_1))
+            .build();
+    ImmutableList<ClientFileGroup> clientFileGroups =
+        mobileDataDownload.getFileGroupsByFilter(getFileGroupsByFilterRequest).get();
+    assertThat(clientFileGroups).hasSize(1);
+
+    ClientFileGroup pendingClientFileGroup = clientFileGroups.get(0);
+    assertThat(pendingClientFileGroup.getGroupName()).isEqualTo(FILE_GROUP_NAME_1);
+    assertThat(pendingClientFileGroup.getOwnerPackage()).isEqualTo(context.getPackageName());
+    assertThat(pendingClientFileGroup.getVersionNumber()).isEqualTo(4);
+    assertThat(pendingClientFileGroup.getStatus()).isEqualTo(Status.PENDING);
+    assertThat(pendingClientFileGroup.getFileCount()).isEqualTo(2);
+    assertThat(pendingClientFileGroup.hasAccount()).isFalse();
+  }
+
+  @Test
+  public void importFiles_whenSuccessful_returns() throws Exception {
+    String inlineFileUrl = String.format("inlinefile:sha1:%s", FILE_CHECKSUM_1);
+    DataFile inlineFile =
+        DataFile.newBuilder()
+            .setFileId(FILE_ID_1)
+            .setByteSize(FILE_SIZE_1)
+            .setChecksum(FILE_CHECKSUM_1)
+            .setUrlToDownload(inlineFileUrl)
+            .build();
+    ImmutableList<DataFile> updatedDataFileList = ImmutableList.of(inlineFile);
+    ImmutableMap<String, FileSource> inlineFileMap =
+        ImmutableMap.of(
+            FILE_ID_1, FileSource.ofByteString(ByteString.copyFromUtf8("TEST_CONTENT")));
+
+    // TODO: rely on actual implementation once feature is fully implemented.
+    when(mockMobileDataDownloadManager.importFiles(
+            any(), anyLong(), any(), any(), any(), any(), any()))
+        .thenReturn(Futures.immediateVoidFuture());
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            /* fileGroupPopulatorList = */ ImmutableList.of(),
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            Optional.of(mockDownloadMonitor),
+            Optional.of(this.getClass()),
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    // Since we use mocks, just call the method directly, no need to call addFileGroup first
+    mobileDataDownload
+        .importFiles(
+            ImportFilesRequest.newBuilder()
+                .setGroupName(FILE_GROUP_NAME_1)
+                .setBuildId(1)
+                .setVariantId("testvariant")
+                .setUpdatedDataFileList(updatedDataFileList)
+                .setInlineFileMap(inlineFileMap)
+                .build())
+        .get();
+
+    // Verify mocks were called
+    GroupKey expectedGroupKey =
+        GroupKey.newBuilder()
+            .setGroupName(FILE_GROUP_NAME_1)
+            .setOwnerPackage(context.getPackageName())
+            .build();
+    ImmutableList<MetadataProto.DataFile> expectedDataFileList =
+        ImmutableList.of(ProtoConversionUtil.convertDataFile(inlineFile));
+    verify(mockMobileDataDownloadManager)
+        .importFiles(
+            eq(expectedGroupKey),
+            eq(1L),
+            eq("testvariant"),
+            eq(expectedDataFileList),
+            eq(inlineFileMap),
+            eq(Optional.absent()),
+            any());
+  }
+
+  @Test
+  public void importFiles_whenAccountIsSpecified_usesAccount() throws Exception {
+    Account account = AccountUtil.create("account-name", "account-type");
+    String inlineFileUrl = String.format("inlinefile:sha1:%s", FILE_CHECKSUM_1);
+    DataFile inlineFile =
+        DataFile.newBuilder()
+            .setFileId(FILE_ID_1)
+            .setByteSize(FILE_SIZE_1)
+            .setChecksum(FILE_CHECKSUM_1)
+            .setUrlToDownload(inlineFileUrl)
+            .build();
+    ImmutableList<DataFile> updatedDataFileList = ImmutableList.of(inlineFile);
+    ImmutableMap<String, FileSource> inlineFileMap =
+        ImmutableMap.of(
+            FILE_ID_1, FileSource.ofByteString(ByteString.copyFromUtf8("TEST_CONTENT")));
+
+    // TODO: rely on actual implementation once feature is fully implemented.
+    when(mockMobileDataDownloadManager.importFiles(
+            any(), anyLong(), any(), any(), any(), any(), any()))
+        .thenReturn(Futures.immediateVoidFuture());
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            /* fileGroupPopulatorList = */ ImmutableList.of(),
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            Optional.of(mockDownloadMonitor),
+            Optional.of(this.getClass()),
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    // Since we use mocks, just call the method directly, no need to call addFileGroup first
+    mobileDataDownload
+        .importFiles(
+            ImportFilesRequest.newBuilder()
+                .setGroupName(FILE_GROUP_NAME_1)
+                .setAccountOptional(Optional.of(account))
+                .setBuildId(1)
+                .setVariantId("testvariant")
+                .setUpdatedDataFileList(updatedDataFileList)
+                .setInlineFileMap(inlineFileMap)
+                .build())
+        .get();
+
+    // Verify mocks were called
+    GroupKey expectedGroupKey =
+        GroupKey.newBuilder()
+            .setGroupName(FILE_GROUP_NAME_1)
+            .setAccount(AccountUtil.serialize(account))
+            .setOwnerPackage(context.getPackageName())
+            .build();
+    ImmutableList<MetadataProto.DataFile> expectedDataFileList =
+        ImmutableList.of(ProtoConversionUtil.convertDataFile(inlineFile));
+    verify(mockMobileDataDownloadManager)
+        .importFiles(
+            eq(expectedGroupKey),
+            eq(1L),
+            eq("testvariant"),
+            eq(expectedDataFileList),
+            eq(inlineFileMap),
+            eq(Optional.absent()),
+            any());
+  }
+
+  @Test
+  public void importFiles_whenFails_returnsFailure() throws Exception {
+    String inlineFileUrl = String.format("inlinefile:%s", FILE_CHECKSUM_1);
+    DataFile inlineFile =
+        DataFile.newBuilder()
+            .setFileId(FILE_ID_1)
+            .setByteSize(FILE_SIZE_1)
+            .setChecksum(FILE_CHECKSUM_1)
+            .setUrlToDownload(inlineFileUrl)
+            .build();
+    ImmutableList<DataFile> updatedDataFileList = ImmutableList.of(inlineFile);
+    ImmutableMap<String, FileSource> inlineFileMap =
+        ImmutableMap.of(
+            FILE_ID_1, FileSource.ofByteString(ByteString.copyFromUtf8("TEST_CONTENT")));
+
+    // TODO: rely on actual implementation once feature is fully implemented.
+    when(mockMobileDataDownloadManager.importFiles(
+            any(), anyLong(), any(), any(), any(), any(), any()))
+        .thenReturn(Futures.immediateFailedFuture(new Exception("Test failure")));
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            /* fileGroupPopulatorList = */ ImmutableList.of(),
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            Optional.of(mockDownloadMonitor),
+            Optional.of(this.getClass()),
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    // Since we use mocks, just call the method directly, no need to call addFileGroup first
+    ExecutionException ex =
+        assertThrows(
+            ExecutionException.class,
+            () ->
+                mobileDataDownload
+                    .importFiles(
+                        ImportFilesRequest.newBuilder()
+                            .setGroupName(FILE_GROUP_NAME_1)
+                            .setBuildId(1)
+                            .setVariantId("testvariant")
+                            .setUpdatedDataFileList(updatedDataFileList)
+                            .setInlineFileMap(inlineFileMap)
+                            .build())
+                    .get());
+    assertThat(ex).hasMessageThat().contains("Test failure");
+
+    // Verify mocks were called
+    GroupKey expectedGroupKey =
+        GroupKey.newBuilder()
+            .setGroupName(FILE_GROUP_NAME_1)
+            .setOwnerPackage(context.getPackageName())
+            .build();
+    ImmutableList<MetadataProto.DataFile> expectedDataFileList =
+        ImmutableList.of(ProtoConversionUtil.convertDataFile(inlineFile));
+    verify(mockMobileDataDownloadManager)
+        .importFiles(
+            eq(expectedGroupKey),
+            eq(1L),
+            eq("testvariant"),
+            eq(expectedDataFileList),
+            eq(inlineFileMap),
+            eq(Optional.absent()),
+            any());
+  }
+
+  @Test
+  public void downloadFileGroup() throws Exception {
+    DataFileGroupInternal dataFileGroup =
+        createDataFileGroupInternal(
+            FILE_GROUP_NAME_1,
+            context.getPackageName(),
+            5 /* versionNumber */,
+            new String[] {FILE_ID_1},
+            new int[] {FILE_SIZE_1},
+            new String[] {FILE_CHECKSUM_1},
+            new String[] {FILE_URL_1},
+            DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI);
+    ArgumentCaptor<GroupKey> groupKeyCaptor = ArgumentCaptor.forClass(GroupKey.class);
+    when(mockMobileDataDownloadManager.downloadFileGroup(groupKeyCaptor.capture(), any(), any()))
+        .thenReturn(Futures.immediateFuture(dataFileGroup));
+    when(mockMobileDataDownloadManager.getDataFileUri(dataFileGroup.getFile(0), dataFileGroup))
+        .thenReturn(Futures.immediateFuture(onDeviceUri1));
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            ImmutableList.of() /* fileGroupPopulatorList */,
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            Optional.of(mockDownloadMonitor),
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    CountDownLatch onCompleteLatch = new CountDownLatch(1);
+
+    ClientFileGroup clientFileGroup =
+        mobileDataDownload
+            .downloadFileGroup(
+                DownloadFileGroupRequest.newBuilder()
+                    .setGroupName(FILE_GROUP_NAME_1)
+                    .setListenerOptional(
+                        Optional.of(
+                            new DownloadListener() {
+                              @Override
+                              public void onProgress(long currentSize) {}
+
+                              @Override
+                              public void onComplete(ClientFileGroup clientFileGroup) {
+                                assertThat(clientFileGroup.getGroupName())
+                                    .isEqualTo(FILE_GROUP_NAME_1);
+                                assertThat(clientFileGroup.getOwnerPackage())
+                                    .isEqualTo(context.getPackageName());
+                                assertThat(clientFileGroup.getVersionNumber()).isEqualTo(5);
+                                assertThat(clientFileGroup.getFileCount()).isEqualTo(1);
+
+                                // This is to verify that onComplete is called.
+                                onCompleteLatch.countDown();
+                              }
+                            }))
+                    .build())
+            .get();
+
+    // Verify that onComplete is called.
+    if (!onCompleteLatch.await(LATCH_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) {
+      throw new RuntimeException("onComplete is not called");
+    }
+
+    assertThat(clientFileGroup.getGroupName()).isEqualTo(FILE_GROUP_NAME_1);
+    assertThat(clientFileGroup.getOwnerPackage()).isEqualTo(context.getPackageName());
+    assertThat(clientFileGroup.getVersionNumber()).isEqualTo(5);
+    assertThat(clientFileGroup.getFileCount()).isEqualTo(1);
+    assertThat(clientFileGroup.hasAccount()).isFalse();
+
+    ClientFile clientFile = clientFileGroup.getFileList().get(0);
+    assertThat(clientFile.getFileId()).isEqualTo(FILE_ID_1);
+    assertThat(clientFile.getFileUri()).isEqualTo(onDeviceUri1.toString());
+
+    verify(mockMobileDataDownloadManager).downloadFileGroup(any(GroupKey.class), any(), any());
+    verify(mockDownloadMonitor)
+        .addDownloadListener(eq(FILE_GROUP_NAME_1), any(DownloadListener.class));
+    verify(mockDownloadMonitor).removeDownloadListener(eq(FILE_GROUP_NAME_1));
+
+    assertThat(groupKeyCaptor.getValue().getGroupName()).isEqualTo(FILE_GROUP_NAME_1);
+    assertThat(groupKeyCaptor.getValue().hasAccount()).isFalse();
+  }
+
+  @Test
+  public void downloadFileGroup_failed() throws Exception {
+    ArgumentCaptor<GroupKey> groupKeyCaptor = ArgumentCaptor.forClass(GroupKey.class);
+    when(mockMobileDataDownloadManager.downloadFileGroup(groupKeyCaptor.capture(), any(), any()))
+        .thenReturn(
+            Futures.immediateFailedFuture(
+                DownloadException.builder()
+                    .setDownloadResultCode(DownloadResultCode.UNKNOWN_ERROR)
+                    .setMessage("Fail to download file group")
+                    .build()));
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            ImmutableList.of() /* fileGroupPopulatorList */,
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            Optional.of(mockDownloadMonitor),
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    ListenableFuture<ClientFileGroup> downloadFuture =
+        mobileDataDownload.downloadFileGroup(
+            DownloadFileGroupRequest.newBuilder()
+                .setGroupName(FILE_GROUP_NAME_1)
+                .setListenerOptional(
+                    Optional.of(
+                        new DownloadListener() {
+                          @Override
+                          public void onProgress(long currentSize) {}
+
+                          @Override
+                          public void onComplete(ClientFileGroup clientFileGroup) {}
+                        }))
+                .build());
+
+    CountDownLatch onFailureLatch = new CountDownLatch(1);
+
+    Futures.addCallback(
+        downloadFuture,
+        new FutureCallback<ClientFileGroup>() {
+          @Override
+          public void onSuccess(ClientFileGroup result) {}
+
+          @Override
+          public void onFailure(Throwable t) {
+            // This is to ensure that onFailure is called.
+            onFailureLatch.countDown();
+          }
+        },
+        MoreExecutors.directExecutor());
+
+    assertThrows(ExecutionException.class, downloadFuture::get);
+    DownloadException e = LabsFutures.getFailureCauseAs(downloadFuture, DownloadException.class);
+    assertThat(e).hasMessageThat().contains("Fail");
+
+    if (!onFailureLatch.await(LATCH_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) {
+      throw new RuntimeException("latch timeout: onFailure is not called");
+    }
+
+    verify(mockMobileDataDownloadManager).downloadFileGroup(any(GroupKey.class), any(), any());
+    verify(mockDownloadMonitor)
+        .addDownloadListener(eq(FILE_GROUP_NAME_1), any(DownloadListener.class));
+    verify(mockDownloadMonitor).removeDownloadListener(eq(FILE_GROUP_NAME_1));
+
+    assertThat(groupKeyCaptor.getValue().getGroupName()).isEqualTo(FILE_GROUP_NAME_1);
+    assertThat(groupKeyCaptor.getValue().hasAccount()).isFalse();
+  }
+
+  @Test
+  public void downloadFileGroup_withAccount() throws Exception {
+    DataFileGroupInternal dataFileGroup =
+        createDataFileGroupInternal(
+            FILE_GROUP_NAME_1,
+            context.getPackageName(),
+            5 /* versionNumber */,
+            new String[] {FILE_ID_1},
+            new int[] {FILE_SIZE_1},
+            new String[] {FILE_CHECKSUM_1},
+            new String[] {FILE_URL_1},
+            DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI);
+    ArgumentCaptor<GroupKey> groupKeyCaptor = ArgumentCaptor.forClass(GroupKey.class);
+    when(mockMobileDataDownloadManager.downloadFileGroup(groupKeyCaptor.capture(), any(), any()))
+        .thenReturn(Futures.immediateFuture(dataFileGroup));
+    when(mockMobileDataDownloadManager.getDataFileUri(dataFileGroup.getFile(0), dataFileGroup))
+        .thenReturn(Futures.immediateFuture(onDeviceUri1));
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            ImmutableList.of() /* fileGroupPopulatorList */,
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            Optional.of(mockDownloadMonitor),
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    CountDownLatch onCompleteLatch = new CountDownLatch(1);
+
+    Account account = AccountUtil.create("account-name", "account-type");
+    ClientFileGroup clientFileGroup =
+        mobileDataDownload
+            .downloadFileGroup(
+                DownloadFileGroupRequest.newBuilder()
+                    .setGroupName(FILE_GROUP_NAME_1)
+                    .setAccountOptional(Optional.of(account))
+                    .setListenerOptional(
+                        Optional.of(
+                            new DownloadListener() {
+                              @Override
+                              public void onProgress(long currentSize) {}
+
+                              @Override
+                              public void onComplete(ClientFileGroup clientFileGroup) {
+                                assertThat(clientFileGroup.getGroupName())
+                                    .isEqualTo(FILE_GROUP_NAME_1);
+                                assertThat(clientFileGroup.getOwnerPackage())
+                                    .isEqualTo(context.getPackageName());
+                                assertThat(clientFileGroup.getVersionNumber()).isEqualTo(5);
+                                assertThat(clientFileGroup.getFileCount()).isEqualTo(1);
+
+                                // This is to verify that onComplete is called.
+                                onCompleteLatch.countDown();
+                              }
+                            }))
+                    .build())
+            .get();
+
+    // Verify that onComplete is called.
+    if (!onCompleteLatch.await(LATCH_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) {
+      throw new RuntimeException("onComplete is not called");
+    }
+
+    assertThat(clientFileGroup.getGroupName()).isEqualTo(FILE_GROUP_NAME_1);
+    assertThat(clientFileGroup.getOwnerPackage()).isEqualTo(context.getPackageName());
+    assertThat(clientFileGroup.getVersionNumber()).isEqualTo(5);
+    assertThat(clientFileGroup.getAccount()).isEqualTo(AccountUtil.serialize(account));
+    assertThat(clientFileGroup.getFileCount()).isEqualTo(1);
+
+    ClientFile clientFile = clientFileGroup.getFileList().get(0);
+    assertThat(clientFile.getFileId()).isEqualTo(FILE_ID_1);
+    assertThat(clientFile.getFileUri()).isEqualTo(onDeviceUri1.toString());
+
+    verify(mockMobileDataDownloadManager).downloadFileGroup(any(GroupKey.class), any(), any());
+    verify(mockDownloadMonitor)
+        .addDownloadListener(eq(FILE_GROUP_NAME_1), any(DownloadListener.class));
+    verify(mockDownloadMonitor).removeDownloadListener(eq(FILE_GROUP_NAME_1));
+
+    assertThat(groupKeyCaptor.getValue().getGroupName()).isEqualTo(FILE_GROUP_NAME_1);
+    assertThat(groupKeyCaptor.getValue().getAccount()).isEqualTo(AccountUtil.serialize(account));
+  }
+
+  @Test
+  public void downloadFileGroup_withVariantId() throws Exception {
+    DataFileGroupInternal dataFileGroup =
+        createDataFileGroupInternal(
+                FILE_GROUP_NAME_1,
+                context.getPackageName(),
+                /* versionNumber = */ 5,
+                new String[] {FILE_ID_1},
+                new int[] {FILE_SIZE_1},
+                new String[] {FILE_CHECKSUM_1},
+                new String[] {FILE_URL_1},
+                DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI)
+            .toBuilder()
+            .setVariantId("en")
+            .build();
+
+    ArgumentCaptor<GroupKey> groupKeyCaptor = ArgumentCaptor.forClass(GroupKey.class);
+    when(mockMobileDataDownloadManager.downloadFileGroup(groupKeyCaptor.capture(), any(), any()))
+        .thenReturn(Futures.immediateFuture(dataFileGroup));
+    when(mockMobileDataDownloadManager.getDataFileUri(dataFileGroup.getFile(0), dataFileGroup))
+        .thenReturn(Futures.immediateFuture(onDeviceUri1));
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            ImmutableList.of() /* fileGroupPopulatorList */,
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            Optional.of(mockDownloadMonitor),
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    ClientFileGroup clientFileGroup =
+        mobileDataDownload
+            .downloadFileGroup(
+                DownloadFileGroupRequest.newBuilder()
+                    .setGroupName(FILE_GROUP_NAME_1)
+                    .setVariantIdOptional(Optional.of("en"))
+                    .build())
+            .get();
+
+    assertThat(clientFileGroup.getGroupName()).isEqualTo(FILE_GROUP_NAME_1);
+    assertThat(clientFileGroup.getVariantId()).isEqualTo("en");
+
+    verify(mockMobileDataDownloadManager).downloadFileGroup(any(GroupKey.class), any(), any());
+
+    GroupKey expectedGroupKey =
+        GroupKey.newBuilder()
+            .setGroupName(FILE_GROUP_NAME_1)
+            .setOwnerPackage(context.getPackageName())
+            .setVariantId("en")
+            .build();
+    assertThat(groupKeyCaptor.getValue()).isEqualTo(expectedGroupKey);
+  }
+
+  @Test
+  public void downloadFileGroupWithForegroundService() throws Exception {
+    DataFileGroupInternal dataFileGroup =
+        createDataFileGroupInternal(
+            FILE_GROUP_NAME_1,
+            context.getPackageName(),
+            /* versionNumber = */ 5,
+            new String[] {FILE_ID_1},
+            new int[] {FILE_SIZE_1},
+            new String[] {FILE_CHECKSUM_1},
+            new String[] {FILE_URL_1},
+            DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI);
+    ArgumentCaptor<GroupKey> groupKeyCaptor = ArgumentCaptor.forClass(GroupKey.class);
+    when(mockMobileDataDownloadManager.downloadFileGroup(groupKeyCaptor.capture(), any(), any()))
+        .thenReturn(Futures.immediateFuture(dataFileGroup));
+    when(mockMobileDataDownloadManager.getDataFileUri(dataFileGroup.getFile(0), dataFileGroup))
+        .thenReturn(Futures.immediateFuture(onDeviceUri1));
+    when(mockMobileDataDownloadManager.getFileGroup(groupKeyCaptor.capture(), anyBoolean()))
+        .thenReturn(Futures.immediateFuture(dataFileGroup));
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            /* fileGroupPopulatorList = */ ImmutableList.of(),
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            Optional.of(mockDownloadMonitor),
+            Optional.of(this.getClass()),
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    CountDownLatch onCompleteLatch = new CountDownLatch(1);
+
+    ClientFileGroup clientFileGroup =
+        mobileDataDownload
+            .downloadFileGroupWithForegroundService(
+                DownloadFileGroupRequest.newBuilder()
+                    .setGroupName(FILE_GROUP_NAME_1)
+                    .setListenerOptional(
+                        Optional.of(
+                            new DownloadListener() {
+                              @Override
+                              public void onProgress(long currentSize) {}
+
+                              @Override
+                              public void onComplete(ClientFileGroup clientFileGroup) {
+                                assertThat(clientFileGroup.getGroupName())
+                                    .isEqualTo(FILE_GROUP_NAME_1);
+                                assertThat(clientFileGroup.getOwnerPackage())
+                                    .isEqualTo(context.getPackageName());
+                                assertThat(clientFileGroup.getVersionNumber()).isEqualTo(5);
+                                assertThat(clientFileGroup.getFileCount()).isEqualTo(1);
+
+                                // This is to verify that onComplete is called.
+                                onCompleteLatch.countDown();
+                              }
+                            }))
+                    .build())
+            .get();
+
+    // Verify that onComplete is called.
+    if (!onCompleteLatch.await(LATCH_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) {
+      throw new RuntimeException("onComplete is not called");
+    }
+
+    assertThat(clientFileGroup.getGroupName()).isEqualTo(FILE_GROUP_NAME_1);
+    assertThat(clientFileGroup.getOwnerPackage()).isEqualTo(context.getPackageName());
+    assertThat(clientFileGroup.getVersionNumber()).isEqualTo(5);
+    assertThat(clientFileGroup.getFileCount()).isEqualTo(1);
+    assertThat(clientFileGroup.hasAccount()).isFalse();
+
+    ClientFile clientFile = clientFileGroup.getFileList().get(0);
+    assertThat(clientFile.getFileId()).isEqualTo(FILE_ID_1);
+    assertThat(clientFile.getFileUri()).isEqualTo(onDeviceUri1.toString());
+
+    verify(mockMobileDataDownloadManager).downloadFileGroup(any(GroupKey.class), any(), any());
+    verify(mockDownloadMonitor)
+        .addDownloadListener(eq(FILE_GROUP_NAME_1), any(DownloadListener.class));
+    verify(mockDownloadMonitor).removeDownloadListener(eq(FILE_GROUP_NAME_1));
+
+    assertThat(groupKeyCaptor.getValue().getGroupName()).isEqualTo(FILE_GROUP_NAME_1);
+    assertThat(groupKeyCaptor.getValue().hasAccount()).isFalse();
+  }
+
+  @Test
+  public void downloadFileGroupWithForegroundService_failed() throws Exception {
+    DataFileGroupInternal dataFileGroup =
+        createDataFileGroupInternal(
+            FILE_GROUP_NAME_1,
+            context.getPackageName(),
+            /* versionNumber = */ 5,
+            new String[] {FILE_ID_1},
+            new int[] {FILE_SIZE_1},
+            new String[] {FILE_CHECKSUM_1},
+            new String[] {FILE_URL_1},
+            DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI);
+    ArgumentCaptor<GroupKey> groupKeyCaptor = ArgumentCaptor.forClass(GroupKey.class);
+    when(mockMobileDataDownloadManager.downloadFileGroup(groupKeyCaptor.capture(), any(), any()))
+        .thenReturn(
+            Futures.immediateFailedFuture(
+                DownloadException.builder()
+                    .setDownloadResultCode(DownloadResultCode.UNKNOWN_ERROR)
+                    .setMessage("Fail to download file group")
+                    .build()));
+    when(mockMobileDataDownloadManager.getFileGroup(groupKeyCaptor.capture(), eq(false)))
+        .thenReturn(Futures.immediateFuture(dataFileGroup));
+    when(mockMobileDataDownloadManager.getFileGroup(groupKeyCaptor.capture(), eq(true)))
+        .thenReturn(Futures.immediateFuture(null));
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            /* fileGroupPopulatorList = */ ImmutableList.of(),
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            Optional.of(mockDownloadMonitor),
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    ListenableFuture<ClientFileGroup> downloadFuture =
+        mobileDataDownload.downloadFileGroupWithForegroundService(
+            DownloadFileGroupRequest.newBuilder()
+                .setGroupName(FILE_GROUP_NAME_1)
+                .setListenerOptional(
+                    Optional.of(
+                        new DownloadListener() {
+                          @Override
+                          public void onProgress(long currentSize) {}
+
+                          @Override
+                          public void onComplete(ClientFileGroup clientFileGroup) {}
+                        }))
+                .build());
+
+    CountDownLatch onFailureLatch = new CountDownLatch(1);
+
+    Futures.addCallback(
+        downloadFuture,
+        new FutureCallback<ClientFileGroup>() {
+          @Override
+          public void onSuccess(ClientFileGroup result) {}
+
+          @Override
+          public void onFailure(Throwable t) {
+            // This is to ensure that onFailure is called.
+            onFailureLatch.countDown();
+          }
+        },
+        MoreExecutors.directExecutor());
+
+    assertThrows(ExecutionException.class, downloadFuture::get);
+    DownloadException e = LabsFutures.getFailureCauseAs(downloadFuture, DownloadException.class);
+    assertThat(e).hasMessageThat().contains("Fail");
+
+    if (!onFailureLatch.await(LATCH_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) {
+      throw new RuntimeException("latch timeout: onFailure is not called");
+    }
+
+    verify(mockMobileDataDownloadManager).downloadFileGroup(any(GroupKey.class), any(), any());
+    verify(mockDownloadMonitor)
+        .addDownloadListener(eq(FILE_GROUP_NAME_1), any(DownloadListener.class));
+
+    // Sleep for 1 sec to wait for the listener.onFailure to finish.
+    Thread.sleep(/*millis=*/ 1000);
+    verify(mockDownloadMonitor).removeDownloadListener(eq(FILE_GROUP_NAME_1));
+
+    assertThat(groupKeyCaptor.getValue().getGroupName()).isEqualTo(FILE_GROUP_NAME_1);
+    assertThat(groupKeyCaptor.getValue().hasAccount()).isFalse();
+  }
+
+  @Test
+  public void downloadFileGroupWithForegroundService_withAccount() throws Exception {
+    DataFileGroupInternal dataFileGroup =
+        createDataFileGroupInternal(
+            FILE_GROUP_NAME_1,
+            context.getPackageName(),
+            5 /* versionNumber */,
+            new String[] {FILE_ID_1},
+            new int[] {FILE_SIZE_1},
+            new String[] {FILE_CHECKSUM_1},
+            new String[] {FILE_URL_1},
+            DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI);
+    ArgumentCaptor<GroupKey> groupKeyCaptor = ArgumentCaptor.forClass(GroupKey.class);
+    when(mockMobileDataDownloadManager.downloadFileGroup(groupKeyCaptor.capture(), any(), any()))
+        .thenReturn(Futures.immediateFuture(dataFileGroup));
+    when(mockMobileDataDownloadManager.getDataFileUri(dataFileGroup.getFile(0), dataFileGroup))
+        .thenReturn(Futures.immediateFuture(onDeviceUri1));
+    when(mockMobileDataDownloadManager.getFileGroup(groupKeyCaptor.capture(), eq(false)))
+        .thenReturn(Futures.immediateFuture(dataFileGroup));
+    when(mockMobileDataDownloadManager.getFileGroup(groupKeyCaptor.capture(), eq(true)))
+        .thenReturn(Futures.immediateFuture(null));
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            /* fileGroupPopulatorList = */ ImmutableList.of(),
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            Optional.of(mockDownloadMonitor),
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    CountDownLatch onCompleteLatch = new CountDownLatch(1);
+
+    Account account = AccountUtil.create("account-name", "account-type");
+    ClientFileGroup clientFileGroup =
+        mobileDataDownload
+            .downloadFileGroupWithForegroundService(
+                DownloadFileGroupRequest.newBuilder()
+                    .setGroupName(FILE_GROUP_NAME_1)
+                    .setAccountOptional(Optional.of(account))
+                    .setListenerOptional(
+                        Optional.of(
+                            new DownloadListener() {
+                              @Override
+                              public void onProgress(long currentSize) {}
+
+                              @Override
+                              public void onComplete(ClientFileGroup clientFileGroup) {
+                                assertThat(clientFileGroup.getGroupName())
+                                    .isEqualTo(FILE_GROUP_NAME_1);
+                                assertThat(clientFileGroup.getOwnerPackage())
+                                    .isEqualTo(context.getPackageName());
+                                assertThat(clientFileGroup.getVersionNumber()).isEqualTo(5);
+                                assertThat(clientFileGroup.getFileCount()).isEqualTo(1);
+
+                                // This is to verify that onComplete is called.
+                                onCompleteLatch.countDown();
+                              }
+                            }))
+                    .build())
+            .get();
+
+    // Verify that onComplete is called.
+    if (!onCompleteLatch.await(LATCH_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) {
+      throw new RuntimeException("onComplete is not called");
+    }
+
+    assertThat(clientFileGroup.getGroupName()).isEqualTo(FILE_GROUP_NAME_1);
+    assertThat(clientFileGroup.getOwnerPackage()).isEqualTo(context.getPackageName());
+    assertThat(clientFileGroup.getVersionNumber()).isEqualTo(5);
+    assertThat(clientFileGroup.getAccount()).isEqualTo(AccountUtil.serialize(account));
+    assertThat(clientFileGroup.getFileCount()).isEqualTo(1);
+
+    ClientFile clientFile = clientFileGroup.getFileList().get(0);
+    assertThat(clientFile.getFileId()).isEqualTo(FILE_ID_1);
+    assertThat(clientFile.getFileUri()).isEqualTo(onDeviceUri1.toString());
+
+    verify(mockMobileDataDownloadManager).downloadFileGroup(any(GroupKey.class), any(), any());
+    verify(mockDownloadMonitor)
+        .addDownloadListener(eq(FILE_GROUP_NAME_1), any(DownloadListener.class));
+    verify(mockDownloadMonitor).removeDownloadListener(eq(FILE_GROUP_NAME_1));
+
+    assertThat(groupKeyCaptor.getValue().getGroupName()).isEqualTo(FILE_GROUP_NAME_1);
+    assertThat(groupKeyCaptor.getValue().getAccount()).isEqualTo(AccountUtil.serialize(account));
+  }
+
+  @Test
+  public void downloadFileGroupWithForegroundService_withVariantId() throws Exception {
+    DataFileGroupInternal dataFileGroup =
+        createDataFileGroupInternal(
+                FILE_GROUP_NAME_1,
+                context.getPackageName(),
+                /* versionNumber = */ 5,
+                new String[] {FILE_ID_1},
+                new int[] {FILE_SIZE_1},
+                new String[] {FILE_CHECKSUM_1},
+                new String[] {FILE_URL_1},
+                DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI)
+            .toBuilder()
+            .setVariantId("en")
+            .build();
+
+    ArgumentCaptor<GroupKey> groupKeyCaptor = ArgumentCaptor.forClass(GroupKey.class);
+    when(mockMobileDataDownloadManager.downloadFileGroup(groupKeyCaptor.capture(), any(), any()))
+        .thenReturn(Futures.immediateFuture(dataFileGroup));
+    when(mockMobileDataDownloadManager.getDataFileUri(dataFileGroup.getFile(0), dataFileGroup))
+        .thenReturn(Futures.immediateFuture(onDeviceUri1));
+    when(mockMobileDataDownloadManager.getFileGroup(groupKeyCaptor.capture(), eq(false)))
+        .thenReturn(Futures.immediateFuture(dataFileGroup));
+    when(mockMobileDataDownloadManager.getFileGroup(groupKeyCaptor.capture(), eq(true)))
+        .thenReturn(Futures.immediateFuture(null));
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            /* fileGroupPopulatorList = */ ImmutableList.of(),
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            Optional.of(mockDownloadMonitor),
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    ClientFileGroup clientFileGroup =
+        mobileDataDownload
+            .downloadFileGroupWithForegroundService(
+                DownloadFileGroupRequest.newBuilder()
+                    .setGroupName(FILE_GROUP_NAME_1)
+                    .setVariantIdOptional(Optional.of("en"))
+                    .build())
+            .get();
+
+    assertThat(clientFileGroup.getGroupName()).isEqualTo(FILE_GROUP_NAME_1);
+    assertThat(clientFileGroup.getVariantId()).isEqualTo("en");
+
+    verify(mockMobileDataDownloadManager).downloadFileGroup(any(GroupKey.class), any(), any());
+
+    GroupKey expectedGroupKey =
+        GroupKey.newBuilder()
+            .setGroupName(FILE_GROUP_NAME_1)
+            .setOwnerPackage(context.getPackageName())
+            .setVariantId("en")
+            .build();
+    assertThat(groupKeyCaptor.getValue()).isEqualTo(expectedGroupKey);
+  }
+
+  @Test
+  public void downloadFileGroupWithForegroundService_whenAlreadyDownloaded() throws Exception {
+    DataFileGroupInternal dataFileGroup =
+        createDataFileGroupInternal(
+            FILE_GROUP_NAME_1,
+            context.getPackageName(),
+            /* versionNumber = */ 5,
+            new String[] {FILE_ID_1},
+            new int[] {FILE_SIZE_1},
+            new String[] {FILE_CHECKSUM_1},
+            new String[] {FILE_URL_1},
+            DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI);
+    when(mockMobileDataDownloadManager.getDataFileUri(dataFileGroup.getFile(0), dataFileGroup))
+        .thenReturn(Futures.immediateFuture(onDeviceUri1));
+
+    // Mock situation: no pending group but there is a downloaded group
+    when(mockMobileDataDownloadManager.getFileGroup(any(), eq(false)))
+        .thenReturn(Futures.immediateFuture(null));
+    when(mockMobileDataDownloadManager.getFileGroup(any(), eq(true)))
+        .thenReturn(Futures.immediateFuture(dataFileGroup));
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            /* fileGroupPopulatorList = */ ImmutableList.of(),
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            Optional.of(mockDownloadMonitor),
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    CountDownLatch onCompleteLatch = new CountDownLatch(1);
+
+    ClientFileGroup clientFileGroup =
+        mobileDataDownload
+            .downloadFileGroupWithForegroundService(
+                DownloadFileGroupRequest.newBuilder()
+                    .setGroupName(FILE_GROUP_NAME_1)
+                    .setListenerOptional(
+                        Optional.of(
+                            new DownloadListener() {
+                              @Override
+                              public void onProgress(long currentSize) {}
+
+                              @Override
+                              public void onComplete(ClientFileGroup clientFileGroup) {
+                                assertThat(clientFileGroup.getGroupName())
+                                    .isEqualTo(FILE_GROUP_NAME_1);
+                                assertThat(clientFileGroup.getOwnerPackage())
+                                    .isEqualTo(context.getPackageName());
+                                assertThat(clientFileGroup.getVersionNumber()).isEqualTo(5);
+                                assertThat(clientFileGroup.getFileCount()).isEqualTo(1);
+
+                                // This is to verify that onComplete is called.
+                                onCompleteLatch.countDown();
+                              }
+                            }))
+                    .build())
+            .get();
+
+    // Verify that onComplete is called.
+    if (!onCompleteLatch.await(LATCH_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) {
+      throw new RuntimeException("onComplete is not called");
+    }
+
+    assertThat(clientFileGroup.getGroupName()).isEqualTo(FILE_GROUP_NAME_1);
+    assertThat(clientFileGroup.getOwnerPackage()).isEqualTo(context.getPackageName());
+    assertThat(clientFileGroup.getVersionNumber()).isEqualTo(5);
+    assertThat(clientFileGroup.getFileCount()).isEqualTo(1);
+    assertThat(clientFileGroup.hasAccount()).isFalse();
+
+    ClientFile clientFile = clientFileGroup.getFileList().get(0);
+    assertThat(clientFile.getFileId()).isEqualTo(FILE_ID_1);
+    assertThat(clientFile.getFileUri()).isEqualTo(onDeviceUri1.toString());
+
+    verify(mockMobileDataDownloadManager, times(1)).getFileGroup(any(GroupKey.class), eq(true));
+    verify(mockMobileDataDownloadManager, times(1)).getFileGroup(any(GroupKey.class), eq(false));
+    verify(mockMobileDataDownloadManager, times(0))
+        .downloadFileGroup(any(GroupKey.class), any(), any());
+    verify(mockDownloadMonitor)
+        .addDownloadListener(eq(FILE_GROUP_NAME_1), any(DownloadListener.class));
+    verify(mockDownloadMonitor).removeDownloadListener(eq(FILE_GROUP_NAME_1));
+  }
+
+  @Test
+  public void downloadFileGroupWithForegroundService_whenNoVersionFound_fails() throws Exception {
+    when(mockMobileDataDownloadManager.getFileGroup(groupKeyCaptor.capture(), anyBoolean()))
+        .thenReturn(Futures.immediateFuture(null));
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            /* fileGroupPopulatorList = */ ImmutableList.of(),
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            Optional.of(mockDownloadMonitor),
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    CountDownLatch onFailureLatch = new CountDownLatch(1);
+
+    ListenableFuture<ClientFileGroup> downloadFuture =
+        mobileDataDownload.downloadFileGroupWithForegroundService(
+            DownloadFileGroupRequest.newBuilder()
+                .setGroupName(FILE_GROUP_NAME_1)
+                .setListenerOptional(
+                    Optional.of(
+                        new DownloadListener() {
+                          @Override
+                          public void onProgress(long currentSize) {}
+
+                          @Override
+                          public void onComplete(ClientFileGroup clientFileGroup) {
+                            fail("onComplete should not be called");
+                          }
+
+                          @Override
+                          public void onFailure(Throwable t) {
+                            assertThat(t).isInstanceOf(DownloadException.class);
+                            assertThat(((DownloadException) t).getDownloadResultCode())
+                                .isEqualTo(DownloadResultCode.GROUP_NOT_FOUND_ERROR);
+
+                            // This is to verify onFailure is called.
+                            onFailureLatch.countDown();
+                          }
+                        }))
+                .build());
+
+    assertThrows(ExecutionException.class, downloadFuture::get);
+
+    // Verify onFailure is called
+    if (!onFailureLatch.await(LATCH_WAIT_TIME_MS, TimeUnit.MILLISECONDS)) {
+      fail("onFailure should be called");
+    }
+
+    DownloadException e = LabsFutures.getFailureCauseAs(downloadFuture, DownloadException.class);
+    assertThat(e.getDownloadResultCode()).isEqualTo(DownloadResultCode.GROUP_NOT_FOUND_ERROR);
+
+    // Verify did not attempt a download
+    verify(mockMobileDataDownloadManager, times(0))
+        .downloadFileGroup(any(GroupKey.class), any(), any());
+    verify(mockDownloadMonitor, times(0))
+        .addDownloadListener(eq(FILE_GROUP_NAME_1), any(DownloadListener.class));
+  }
+
+  @Test
+  public void maintenance_success() throws Exception {
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            /*fileGroupPopulatorList=*/ ImmutableList.of(),
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            /*downloadMonitorOptional=*/ Optional.absent(),
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    when(mockMobileDataDownloadManager.maintenance()).thenReturn(Futures.immediateFuture(null));
+
+    mobileDataDownload.maintenance().get();
+
+    verify(mockMobileDataDownloadManager).maintenance();
+    verifyNoMoreInteractions(mockMobileDataDownloadManager);
+  }
+
+  @Test
+  public void maintenance_failure() throws Exception {
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            /*fileGroupPopulatorList=*/ ImmutableList.of(),
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            /*downloadMonitorOptional=*/ Optional.absent(),
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    when(mockMobileDataDownloadManager.maintenance())
+        .thenReturn(Futures.immediateFailedFuture(new IOException("test-failure")));
+
+    ExecutionException e =
+        assertThrows(ExecutionException.class, () -> mobileDataDownload.maintenance().get());
+    assertThat(e).hasCauseThat().isInstanceOf(IOException.class);
+    assertThat(e).hasCauseThat().hasMessageThat().isEqualTo("test-failure");
+
+    verify(mockMobileDataDownloadManager).maintenance();
+    verifyNoMoreInteractions(mockMobileDataDownloadManager);
+  }
+
+  @Test
+  public void schedulePeriodicTasks() throws Exception {
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            /* fileGroupPopulatorList = */ ImmutableList.of(),
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            /* downloadMonitorOptional = */ Optional.absent(),
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    mobileDataDownload.schedulePeriodicTasks();
+
+    verify(mockTaskScheduler)
+        .schedulePeriodicTask(
+            TaskScheduler.CHARGING_PERIODIC_TASK,
+            (new Flags() {}).chargingGcmTaskPeriod(),
+            NetworkState.NETWORK_STATE_ANY,
+            /* constraintOverrides = */ Optional.absent());
+
+    verify(mockTaskScheduler)
+        .schedulePeriodicTask(
+            TaskScheduler.MAINTENANCE_PERIODIC_TASK,
+            (new Flags() {}).maintenanceGcmTaskPeriod(),
+            NetworkState.NETWORK_STATE_ANY,
+            /* constraintOverrides = */ Optional.absent());
+
+    verify(mockTaskScheduler)
+        .schedulePeriodicTask(
+            TaskScheduler.CELLULAR_CHARGING_PERIODIC_TASK,
+            (new Flags() {}).cellularChargingGcmTaskPeriod(),
+            NetworkState.NETWORK_STATE_CONNECTED,
+            /* constraintOverrides = */ Optional.absent());
+
+    verify(mockTaskScheduler)
+        .schedulePeriodicTask(
+            TaskScheduler.WIFI_CHARGING_PERIODIC_TASK,
+            (new Flags() {}).wifiChargingGcmTaskPeriod(),
+            NetworkState.NETWORK_STATE_UNMETERED,
+            /* constraintOverrides = */ Optional.absent());
+
+    verifyNoMoreInteractions(mockTaskScheduler);
+  }
+
+  @Test
+  public void schedulePeriodicTasks_nullTaskScheduler() throws Exception {
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            /* fileGroupPopulatorList = */ ImmutableList.of(),
+            /* taskSchedulerOptional = */ Optional.absent(),
+            fileStorage,
+            /* downloadMonitorOptional = */ Optional.absent(),
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    mobileDataDownload.schedulePeriodicTasks();
+
+    verifyNoInteractions(mockTaskScheduler);
+  }
+
+  @Test
+  public void schedulePeriodicBackgroundTasks() throws Exception {
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            /* fileGroupPopulatorList = */ ImmutableList.of(),
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            /* downloadMonitorOptional = */ Optional.absent(),
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    mobileDataDownload.schedulePeriodicBackgroundTasks().get();
+
+    verify(mockTaskScheduler)
+        .schedulePeriodicTask(
+            TaskScheduler.CHARGING_PERIODIC_TASK,
+            (new Flags() {}).chargingGcmTaskPeriod(),
+            NetworkState.NETWORK_STATE_ANY,
+            /* constraintOverrides = */ Optional.absent());
+
+    verify(mockTaskScheduler)
+        .schedulePeriodicTask(
+            TaskScheduler.MAINTENANCE_PERIODIC_TASK,
+            (new Flags() {}).maintenanceGcmTaskPeriod(),
+            NetworkState.NETWORK_STATE_ANY,
+            /* constraintOverrides = */ Optional.absent());
+
+    verify(mockTaskScheduler)
+        .schedulePeriodicTask(
+            TaskScheduler.CELLULAR_CHARGING_PERIODIC_TASK,
+            (new Flags() {}).cellularChargingGcmTaskPeriod(),
+            NetworkState.NETWORK_STATE_CONNECTED,
+            /* constraintOverrides = */ Optional.absent());
+
+    verify(mockTaskScheduler)
+        .schedulePeriodicTask(
+            TaskScheduler.WIFI_CHARGING_PERIODIC_TASK,
+            (new Flags() {}).wifiChargingGcmTaskPeriod(),
+            NetworkState.NETWORK_STATE_UNMETERED,
+            /* constraintOverrides = */ Optional.absent());
+
+    verifyNoMoreInteractions(mockTaskScheduler);
+  }
+
+  @Test
+  public void schedulePeriodicBackgroundTasks_nullTaskScheduler() throws Exception {
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            /* fileGroupPopulatorList = */ ImmutableList.of(),
+            /* taskSchedulerOptional = */ Optional.absent(),
+            fileStorage,
+            /* downloadMonitorOptional = */ Optional.absent(),
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    mobileDataDownload.schedulePeriodicBackgroundTasks().get();
+
+    verifyNoInteractions(mockTaskScheduler);
+  }
+
+  @Test
+  public void schedulePeriodicBackgroundTasks_withConstraintOverrides() throws Exception {
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            /* fileGroupPopulatorList = */ ImmutableList.of(),
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            /* downloadMonitorOptional = */ Optional.absent(),
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    ConstraintOverrides wifiOverrides =
+        ConstraintOverrides.newBuilder()
+            .setRequiresCharging(false)
+            .setRequiresDeviceIdle(true)
+            .build();
+    ConstraintOverrides cellularOverrides =
+        ConstraintOverrides.newBuilder()
+            .setRequiresCharging(true)
+            .setRequiresDeviceIdle(false)
+            .build();
+
+    Map<String, ConstraintOverrides> constraintOverridesMap = new HashMap<>();
+    constraintOverridesMap.put(TaskScheduler.WIFI_CHARGING_PERIODIC_TASK, wifiOverrides);
+    constraintOverridesMap.put(TaskScheduler.CELLULAR_CHARGING_PERIODIC_TASK, cellularOverrides);
+
+    mobileDataDownload.schedulePeriodicBackgroundTasks(Optional.of(constraintOverridesMap)).get();
+
+    verify(mockTaskScheduler)
+        .schedulePeriodicTask(
+            TaskScheduler.CHARGING_PERIODIC_TASK,
+            (new Flags() {}).chargingGcmTaskPeriod(),
+            NetworkState.NETWORK_STATE_ANY,
+            Optional.absent());
+
+    verify(mockTaskScheduler)
+        .schedulePeriodicTask(
+            TaskScheduler.MAINTENANCE_PERIODIC_TASK,
+            (new Flags() {}).maintenanceGcmTaskPeriod(),
+            NetworkState.NETWORK_STATE_ANY,
+            Optional.absent());
+
+    verify(mockTaskScheduler)
+        .schedulePeriodicTask(
+            TaskScheduler.CELLULAR_CHARGING_PERIODIC_TASK,
+            (new Flags() {}).cellularChargingGcmTaskPeriod(),
+            NetworkState.NETWORK_STATE_CONNECTED,
+            Optional.of(cellularOverrides));
+
+    verify(mockTaskScheduler)
+        .schedulePeriodicTask(
+            TaskScheduler.WIFI_CHARGING_PERIODIC_TASK,
+            (new Flags() {}).wifiChargingGcmTaskPeriod(),
+            NetworkState.NETWORK_STATE_UNMETERED,
+            Optional.of(wifiOverrides));
+
+    verifyNoMoreInteractions(mockTaskScheduler);
+  }
+
+  @Test
+  public void schedulePeriodicBackgroundTasks_nullTaskScheduler_andOverrides() throws Exception {
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            /* fileGroupPopulatorList = */ ImmutableList.of(),
+            /* taskSchedulerOptional = */ Optional.absent(),
+            fileStorage,
+            /* downloadMonitorOptional = */ Optional.absent(),
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    mobileDataDownload.schedulePeriodicBackgroundTasks(Optional.absent()).get();
+
+    verifyNoInteractions(mockTaskScheduler);
+  }
+
+  // A helper function to create a DataFilegroup.
+  private static DataFileGroup createDataFileGroup(
+      String groupName,
+      String ownerPackage,
+      int versionNumber,
+      String[] fileId,
+      int[] byteSize,
+      String[] checksum,
+      String[] url,
+      DeviceNetworkPolicy deviceNetworkPolicy) {
+    if (fileId.length != byteSize.length
+        || fileId.length != checksum.length
+        || fileId.length != url.length) {
+      throw new IllegalArgumentException();
+    }
+
+    DataFileGroup.Builder dataFileGroupBuilder =
+        DataFileGroup.newBuilder()
+            .setGroupName(groupName)
+            .setOwnerPackage(ownerPackage)
+            .setFileGroupVersionNumber(versionNumber)
+            .setDownloadConditions(
+                DownloadConditions.newBuilder().setDeviceNetworkPolicy(deviceNetworkPolicy));
+
+    for (int i = 0; i < fileId.length; ++i) {
+      DataFile file =
+          DataFile.newBuilder()
+              .setFileId(fileId[i])
+              .setByteSize(byteSize[i])
+              .setChecksum(checksum[i])
+              .setUrlToDownload(url[i])
+              .build();
+      dataFileGroupBuilder.addFile(file);
+    }
+
+    return dataFileGroupBuilder.build();
+  }
+
+  private static DataFileGroupInternal createDataFileGroupInternal(
+      String groupName,
+      String ownerPackage,
+      int versionNumber,
+      String[] fileId,
+      int[] byteSize,
+      String[] checksum,
+      String[] url,
+      DeviceNetworkPolicy deviceNetworkPolicy)
+      throws Exception {
+    return ProtoConversionUtil.convert(
+        createDataFileGroup(
+            groupName,
+            ownerPackage,
+            versionNumber,
+            fileId,
+            byteSize,
+            checksum,
+            url,
+            deviceNetworkPolicy));
+  }
+
+  @Test
+  public void handleTask_maintenance() throws Exception {
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            /* fileGroupPopulatorList = */ ImmutableList.of(),
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            /* downloadMonitorOptional = */ Optional.absent(),
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+    when(mockMobileDataDownloadManager.maintenance()).thenReturn(Futures.immediateFuture(null));
+
+    mobileDataDownload.handleTask(TaskScheduler.MAINTENANCE_PERIODIC_TASK).get();
+    verify(mockMobileDataDownloadManager).maintenance();
+    verifyNoMoreInteractions(mockMobileDataDownloadManager);
+  }
+
+  @Test
+  public void handleTask_charging() throws Exception {
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            ImmutableList.of(mockFileGroupPopulator),
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            /* downloadMonitorOptional = */ Optional.absent(),
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    when(mockMobileDataDownloadManager.verifyAllPendingGroups(any()))
+        .thenReturn(Futures.immediateFuture(null));
+    when(mockFileGroupPopulator.refreshFileGroups(mobileDataDownload))
+        .thenReturn(Futures.immediateFuture(null));
+
+    mobileDataDownload.handleTask(TaskScheduler.CHARGING_PERIODIC_TASK).get();
+    verify(mockFileGroupPopulator).refreshFileGroups(mobileDataDownload);
+    verify(mockMobileDataDownloadManager).verifyAllPendingGroups(any());
+    verifyNoMoreInteractions(mockMobileDataDownloadManager);
+  }
+
+  @Test
+  public void handleTask_wifi_charging() throws Exception {
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            ImmutableList.of(mockFileGroupPopulator),
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            /* downloadMonitorOptional = */ Optional.absent(),
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    when(mockFileGroupPopulator.refreshFileGroups(mobileDataDownload))
+        .thenReturn(Futures.immediateFuture(null));
+    when(mockMobileDataDownloadManager.downloadAllPendingGroups(eq(true) /*wifi*/, any()))
+        .thenReturn(Futures.immediateFuture(null));
+
+    mobileDataDownload.handleTask(TaskScheduler.WIFI_CHARGING_PERIODIC_TASK).get();
+    verify(mockFileGroupPopulator, times(2)).refreshFileGroups(mobileDataDownload);
+    verify(mockMobileDataDownloadManager, times(2)).downloadAllPendingGroups(eq(true), any());
+    verifyNoMoreInteractions(mockMobileDataDownloadManager);
+  }
+
+  @Test
+  public void handleTask_cellular_charging() throws Exception {
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            ImmutableList.of(mockFileGroupPopulator),
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            /* downloadMonitorOptional = */ Optional.absent(),
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    when(mockFileGroupPopulator.refreshFileGroups(mobileDataDownload))
+        .thenReturn(Futures.immediateFuture(null));
+    when(mockMobileDataDownloadManager.downloadAllPendingGroups(eq(false) /*wifi*/, any()))
+        .thenReturn(Futures.immediateFuture(null));
+
+    mobileDataDownload.handleTask(TaskScheduler.CELLULAR_CHARGING_PERIODIC_TASK).get();
+    verify(mockFileGroupPopulator, times(2)).refreshFileGroups(mobileDataDownload);
+    verify(mockMobileDataDownloadManager, times(2)).downloadAllPendingGroups(eq(false), any());
+    verifyNoMoreInteractions(mockMobileDataDownloadManager);
+  }
+
+  @Test
+  public void handleTask_no_filegroup_populator() throws Exception {
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            /* fileGroupPopulatorList = */ ImmutableList.of(),
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            /* downloadMonitorOptional = */ Optional.absent(),
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    when(mockMobileDataDownloadManager.verifyAllPendingGroups(any()))
+        .thenReturn(Futures.immediateFuture(null));
+    when(mockMobileDataDownloadManager.downloadAllPendingGroups(anyBoolean() /*wifi*/, any()))
+        .thenReturn(Futures.immediateFuture(null));
+
+    mobileDataDownload.handleTask(TaskScheduler.CHARGING_PERIODIC_TASK).get();
+    verify(mockMobileDataDownloadManager).verifyAllPendingGroups(any());
+
+    mobileDataDownload.handleTask(TaskScheduler.CELLULAR_CHARGING_PERIODIC_TASK).get();
+    verify(mockMobileDataDownloadManager, times(2)).downloadAllPendingGroups(eq(false), any());
+
+    mobileDataDownload.handleTask(TaskScheduler.WIFI_CHARGING_PERIODIC_TASK).get();
+    verify(mockMobileDataDownloadManager, times(2)).downloadAllPendingGroups(eq(true), any());
+  }
+
+  @Test
+  public void handleTask_invalid_tag() throws Exception {
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            ImmutableList.of(mockFileGroupPopulator),
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            /* downloadMonitorOptional = */ Optional.absent(),
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    ExecutionException e =
+        assertThrows(
+            ExecutionException.class, () -> mobileDataDownload.handleTask("invalid-tag").get());
+    assertThat(e).hasCauseThat().isInstanceOf(IllegalArgumentException.class);
+  }
+
+  @Test
+  public void handleTask_onePopulatorFails_continuesToRunOthers() throws Exception {
+    FileGroupPopulator failingPopulator =
+        (MobileDataDownload unused) ->
+            Futures.immediateFailedFuture(new IllegalArgumentException("test"));
+
+    AtomicBoolean refreshedOneSucceedingPopulator = new AtomicBoolean(false);
+    FileGroupPopulator oneSucceedingPopulator =
+        (MobileDataDownload unused) -> {
+          refreshedOneSucceedingPopulator.set(true);
+          return Futures.immediateVoidFuture();
+        };
+
+    AtomicBoolean refreshedAnotherSucceedingPopulator = new AtomicBoolean(false);
+    FileGroupPopulator anotherSucceedingPopulator =
+        (MobileDataDownload unused) -> {
+          refreshedAnotherSucceedingPopulator.set(true);
+          return Futures.immediateVoidFuture();
+        };
+
+    // The populators will be executed in this order.
+    ImmutableList<FileGroupPopulator> populators =
+        ImmutableList.of(failingPopulator, oneSucceedingPopulator, anotherSucceedingPopulator);
+
+    MobileDataDownload mobileDataDownload =
+        new MobileDataDownloadImpl(
+            context,
+            mockEventLogger,
+            mockMobileDataDownloadManager,
+            EXECUTOR,
+            populators,
+            Optional.of(mockTaskScheduler),
+            fileStorage,
+            /* downloadMonitorOptional = */ Optional.absent(),
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            flags,
+            singleFileDownloader,
+            Optional.absent() /* customFileGroupValidator */);
+
+    when(mockMobileDataDownloadManager.verifyAllPendingGroups(any() /* validator */))
+        .thenReturn(Futures.immediateVoidFuture());
+    when(mockMobileDataDownloadManager.downloadAllPendingGroups(
+            anyBoolean() /*wifi*/, any() /* validator */))
+        .thenReturn(Futures.immediateVoidFuture());
+
+    ListenableFuture<Void> handleFuture =
+        mobileDataDownload.handleTask(TaskScheduler.WIFI_CHARGING_PERIODIC_TASK);
+    assertThat(handleFuture).whenDone().isSuccessful();
+
+    handleFuture.get();
+    assertThat(refreshedOneSucceedingPopulator.get()).isTrue();
+    assertThat(refreshedAnotherSucceedingPopulator.get()).isTrue();
+  }
+
+  @Test
+  public void reportUsage_basic() throws Exception {
+    DataFileGroupInternal dataFileGroup = createDefaultDataFileGroupInternal();
+
+    when(mockMobileDataDownloadManager.getFileGroup(any(GroupKey.class), eq(true)))
+        .thenReturn(Futures.immediateFuture(dataFileGroup));
+    when(mockMobileDataDownloadManager.getDataFileUri(dataFileGroup.getFile(0), dataFileGroup))
+        .thenReturn(Futures.immediateFuture(onDeviceUri1));
+
+    MobileDataDownload mobileDataDownload = createDefaultMobileDataDownload();
+
+    ClientFileGroup clientFileGroup =
+        mobileDataDownload
+            .getFileGroup(GetFileGroupRequest.newBuilder().setGroupName(FILE_GROUP_NAME_1).build())
+            .get();
+
+    UsageEvent usageEvent =
+        UsageEvent.builder()
+            .setEventCode(0)
+            .setAppVersion(123)
+            .setClientFileGroup(clientFileGroup)
+            .build();
+    mobileDataDownload.reportUsage(usageEvent).get();
+
+    verify(mockEventLogger).logMddUsageEvent(createFileGroupStats(clientFileGroup), null);
+  }
+
+  private static Void createFileGroupStats(ClientFileGroup clientFileGroup) {
+    return null;
+  }
+
+  private MobileDataDownload createDefaultMobileDataDownload() {
+    return new MobileDataDownloadImpl(
+        context,
+        mockEventLogger,
+        mockMobileDataDownloadManager,
+        EXECUTOR,
+        ImmutableList.of() /* fileGroupPopulatorList */,
+        Optional.of(mockTaskScheduler),
+        fileStorage,
+        Optional.absent() /* downloadMonitorOptional */,
+        Optional.of(this.getClass()), // don't need to use the real foreground download service.
+        flags,
+        singleFileDownloader,
+        Optional.absent() /* customFileGroupValidator */);
+  }
+
+  private DataFileGroupInternal createDefaultDataFileGroupInternal() throws Exception {
+    return createDataFileGroupInternal(
+        FILE_GROUP_NAME_1,
+        context.getPackageName(),
+        1 /* versionNumber */,
+        new String[] {FILE_ID_1},
+        new int[] {FILE_SIZE_1},
+        new String[] {FILE_CHECKSUM_1},
+        new String[] {FILE_URL_1},
+        DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI);
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/TestFileGroupPopulator.java b/javatests/com/google/android/libraries/mobiledatadownload/TestFileGroupPopulator.java
new file mode 100644
index 0000000..c089e95
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/TestFileGroupPopulator.java
@@ -0,0 +1,152 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+import android.content.Context;
+import android.text.TextUtils;
+import android.util.Log;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.mobiledatadownload.DownloadConfigProto.DataFile;
+import com.google.mobiledatadownload.DownloadConfigProto.DataFileGroup;
+import com.google.mobiledatadownload.DownloadConfigProto.DownloadConditions;
+import com.google.mobiledatadownload.DownloadConfigProto.DownloadConditions.DeviceNetworkPolicy;
+
+/** Test FileGroup Populator. */
+public class TestFileGroupPopulator implements FileGroupPopulator {
+
+  private static final String TAG = "MDD TestFileGroupPopulator";
+
+  static final String FILE_GROUP_NAME = "test-group";
+  static final String FILE_ID = "test-file-id";
+  static final int FILE_SIZE = 554;
+  static final String FILE_CHECKSUM = "a1cba9d87b1440f41ce9e7da38c43e1f6bd7d5df";
+  static final String FILE_URL = "https://www.gstatic.com/suggest-dev/odws1_empty.jar";
+
+  private final Context context;
+
+  public TestFileGroupPopulator(Context context) {
+    this.context = context;
+  }
+
+  @Override
+  public ListenableFuture<Void> refreshFileGroups(MobileDataDownload mobileDataDownload) {
+    Log.d(TAG, "Adding file groups " + FILE_GROUP_NAME);
+
+    DataFileGroup dataFileGroup =
+        createDataFileGroup(
+            FILE_GROUP_NAME,
+            context.getPackageName(),
+            new String[] {FILE_ID},
+            new int[] {FILE_SIZE},
+            new String[] {FILE_CHECKSUM},
+            new String[] {FILE_URL},
+            DeviceNetworkPolicy.DOWNLOAD_ON_ANY_NETWORK);
+
+    ListenableFuture<Boolean> addFileGroupFuture =
+        mobileDataDownload.addFileGroup(
+            AddFileGroupRequest.newBuilder().setDataFileGroup(dataFileGroup).build());
+    return Futures.transform(
+        addFileGroupFuture,
+        result -> {
+          if (result.booleanValue()) {
+            Log.d(TAG, "Added file groups " + dataFileGroup.getGroupName());
+          } else {
+            Log.d(TAG, "Failed to add file group " + dataFileGroup.getGroupName());
+          }
+          return null;
+        },
+        MoreExecutors.directExecutor());
+  }
+
+  // A helper function to create a DataFilegroup.
+  static DataFileGroup createDataFileGroup(
+      String groupName,
+      String ownerPackage,
+      String[] fileId,
+      int[] byteSize,
+      String[] checksum,
+      String[] url,
+      DeviceNetworkPolicy deviceNetworkPolicy) {
+    if (fileId.length != byteSize.length
+        || fileId.length != checksum.length
+        || fileId.length != url.length) {
+      throw new IllegalArgumentException();
+    }
+
+    DataFileGroup.Builder dataFileGroupBuilder =
+        DataFileGroup.newBuilder()
+            .setGroupName(groupName)
+            .setOwnerPackage(ownerPackage)
+            .setDownloadConditions(
+                DownloadConditions.newBuilder().setDeviceNetworkPolicy(deviceNetworkPolicy));
+
+    for (int i = 0; i < fileId.length; ++i) {
+      DataFile file =
+          DataFile.newBuilder()
+              .setFileId(fileId[i])
+              .setByteSize(byteSize[i])
+              .setChecksum(checksum[i])
+              .setUrlToDownload(url[i])
+              .build();
+      dataFileGroupBuilder.addFile(file);
+    }
+
+    return dataFileGroupBuilder.build();
+  }
+
+  // Allows to configure android-shared and non-android-shared files.
+  static DataFileGroup createDataFileGroup(
+      String groupName,
+      String ownerPackage,
+      String[] fileId,
+      int[] byteSize,
+      String[] checksum,
+      String[] androidSharingChecksum,
+      String[] url,
+      DeviceNetworkPolicy deviceNetworkPolicy) {
+    if (fileId.length != byteSize.length
+        || fileId.length != checksum.length
+        || fileId.length != url.length) {
+      throw new IllegalArgumentException();
+    }
+
+    DataFileGroup.Builder dataFileGroupBuilder =
+        DataFileGroup.newBuilder()
+            .setGroupName(groupName)
+            .setOwnerPackage(ownerPackage)
+            .setDownloadConditions(
+                DownloadConditions.newBuilder().setDeviceNetworkPolicy(deviceNetworkPolicy));
+
+    for (int i = 0; i < fileId.length; ++i) {
+      DataFile.Builder fileBuilder =
+          DataFile.newBuilder()
+              .setFileId(fileId[i])
+              .setByteSize(byteSize[i])
+              .setChecksum(checksum[i])
+              .setUrlToDownload(url[i]);
+      if (!TextUtils.isEmpty(androidSharingChecksum[i])) {
+        fileBuilder
+            .setAndroidSharingType(DataFile.AndroidSharingType.ANDROID_BLOB_WHEN_AVAILABLE)
+            .setAndroidSharingChecksumType(DataFile.AndroidSharingChecksumType.SHA2_256)
+            .setAndroidSharingChecksum(androidSharingChecksum[i]);
+      }
+      dataFileGroupBuilder.addFile(fileBuilder.build());
+    }
+    return dataFileGroupBuilder.build();
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/TwoStepPopulator.java b/javatests/com/google/android/libraries/mobiledatadownload/TwoStepPopulator.java
new file mode 100644
index 0000000..881c4a4
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/TwoStepPopulator.java
@@ -0,0 +1,116 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import android.content.Context;
+import android.net.Uri;
+import android.util.Log;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil;
+import com.google.common.base.Preconditions;
+import com.google.common.util.concurrent.FluentFuture;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.mobiledatadownload.ClientConfigProto.ClientFileGroup;
+import com.google.mobiledatadownload.DownloadConfigProto.DataFile.ChecksumType;
+import com.google.mobiledatadownload.DownloadConfigProto.DataFileGroup;
+import com.google.mobiledatadownload.DownloadConfigProto.DownloadConditions.DeviceNetworkPolicy;
+import java.io.IOException;
+
+/**
+ * A file group populator that demonstrate the 2-step download. The populator will read the content
+ * from first group and then add the second group to MDD.
+ */
+public class TwoStepPopulator implements FileGroupPopulator {
+  private static final String TAG = "TwoStepPopulator";
+
+  private final Context context;
+  private final SynchronousFileStorage fileStorage;
+
+  public TwoStepPopulator(Context context, SynchronousFileStorage fileStorage) {
+    this.context = context;
+    this.fileStorage = fileStorage;
+  }
+
+  @Override
+  public ListenableFuture<Void> refreshFileGroups(MobileDataDownload mobileDataDownload) {
+    ListenableFuture<ClientFileGroup> step1Future =
+        mobileDataDownload.getFileGroup(
+            GetFileGroupRequest.newBuilder().setGroupName("step1-file-group").build());
+
+    return FluentFuture.from(step1Future)
+        .transformAsync(
+            clientFileGroup -> {
+              if (clientFileGroup == null) {
+                return Futures.immediateFuture(null);
+              }
+
+              LogUtil.d("%s: Retrieved step1-file-group from MDD", TAG);
+
+              // Now read the url from the step1.txt
+              Uri fileUri = Uri.parse(clientFileGroup.getFile(0).getFileUri());
+              String step1Content = null;
+              try {
+                step1Content = new String(StreamUtils.readFileInBytes(fileStorage, fileUri), UTF_8);
+              } catch (IOException e) {
+                LogUtil.e(e, "Fail to read from step1.txt");
+              }
+
+              // Add a file group where the url is read from step1.txt
+              DataFileGroup step2FileGroup =
+                  MobileDataDownloadIntegrationTest.createDataFileGroup(
+                      "step2-file-group",
+                      context.getPackageName(),
+                      new String[] {"step2_id"},
+                      new int[] {13},
+                      new String[] {""},
+                      new ChecksumType[] {ChecksumType.NONE},
+                      new String[] {step1Content},
+                      DeviceNetworkPolicy.DOWNLOAD_ON_ANY_NETWORK);
+
+              ListenableFuture<Boolean> addFileGroupFuture =
+                  mobileDataDownload.addFileGroup(
+                      AddFileGroupRequest.newBuilder().setDataFileGroup(step2FileGroup).build());
+              Futures.addCallback(
+                  addFileGroupFuture,
+                  new FutureCallback<Boolean>() {
+                    @Override
+                    public void onSuccess(Boolean result) {
+                      Preconditions.checkState(result.booleanValue());
+                      if (result.booleanValue()) {
+                        Log.d(TAG, "Added file group " + step2FileGroup.getGroupName());
+                      } else {
+                        Log.d(TAG, "Failed to add file group " + step2FileGroup.getGroupName());
+                      }
+                    }
+
+                    @Override
+                    public void onFailure(Throwable t) {
+                      Log.e(TAG, "Failed to add file group", t);
+                    }
+                  },
+                  MoreExecutors.directExecutor());
+              return addFileGroupFuture;
+            },
+            MoreExecutors.directExecutor())
+        .transform(addFileGroupSucceeded -> null, MoreExecutors.directExecutor());
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/ZipFolderFileGroupPopulator.java b/javatests/com/google/android/libraries/mobiledatadownload/ZipFolderFileGroupPopulator.java
new file mode 100644
index 0000000..1fe9afd
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/ZipFolderFileGroupPopulator.java
@@ -0,0 +1,116 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload;
+
+import android.content.Context;
+import android.util.Log;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.mobiledatadownload.DownloadConfigProto.DataFile;
+import com.google.mobiledatadownload.DownloadConfigProto.DataFileGroup;
+import com.google.mobiledatadownload.DownloadConfigProto.DownloadConditions;
+import com.google.mobiledatadownload.DownloadConfigProto.DownloadConditions.DeviceNetworkPolicy;
+import com.google.mobiledatadownload.TransformProto.Transform;
+import com.google.mobiledatadownload.TransformProto.Transforms;
+import com.google.mobiledatadownload.TransformProto.ZipTransform;
+
+/** Test FileGroup Populator with zip file which contains 3 files and one sub-folder. */
+public class ZipFolderFileGroupPopulator implements FileGroupPopulator {
+
+  private static final String TAG = "MDD ZipFolderFileGroupPopulator";
+
+  static final String FILE_GROUP_NAME = "test-zip-group";
+  static final String FILE_ID = "test-zip-file-id";
+  static final int FILE_SIZE = 373;
+  private static final String FILE_CHECKSUM = "7024b6bcddf2b2897656e9353f7fc715df5ea986";
+  private static final String FILE_URL =
+      "https://www.gstatic.com/icing/idd/apitest/zip_test_folder.zip";
+
+  private final Context context;
+
+  public ZipFolderFileGroupPopulator(Context context) {
+    this.context = context;
+  }
+
+  @Override
+  public ListenableFuture<Void> refreshFileGroups(MobileDataDownload mobileDataDownload) {
+    DataFileGroup dataFileGroup =
+        createDataFileGroup(
+            FILE_GROUP_NAME,
+            context.getPackageName(),
+            new String[] {FILE_ID},
+            new int[] {FILE_SIZE},
+            new String[] {FILE_CHECKSUM},
+            new String[] {FILE_URL},
+            DeviceNetworkPolicy.DOWNLOAD_ON_ANY_NETWORK);
+
+    ListenableFuture<Boolean> addFileGroupFuture =
+        mobileDataDownload.addFileGroup(
+            AddFileGroupRequest.newBuilder().setDataFileGroup(dataFileGroup).build());
+    return Futures.transform(
+        addFileGroupFuture,
+        result -> {
+          if (result.booleanValue()) {
+            Log.d(TAG, "Added file groups " + dataFileGroup.getGroupName());
+          } else {
+            Log.d(TAG, "Failed to add file group " + dataFileGroup.getGroupName());
+          }
+          return null;
+        },
+        MoreExecutors.directExecutor());
+  }
+
+  public static DataFileGroup createDataFileGroup(
+      String groupName,
+      String ownerPackage,
+      String[] fileId,
+      int[] byteSize,
+      String[] checksum,
+      String[] url,
+      DeviceNetworkPolicy deviceNetworkPolicy) {
+    if (fileId.length != byteSize.length
+        || fileId.length != checksum.length
+        || fileId.length != url.length) {
+      throw new IllegalArgumentException();
+    }
+
+    DataFileGroup.Builder dataFileGroupBuilder =
+        DataFileGroup.newBuilder()
+            .setGroupName(groupName)
+            .setOwnerPackage(ownerPackage)
+            .setDownloadConditions(
+                DownloadConditions.newBuilder().setDeviceNetworkPolicy(deviceNetworkPolicy));
+
+    for (int i = 0; i < fileId.length; ++i) {
+      DataFile file =
+          DataFile.newBuilder()
+              .setFileId(fileId[i])
+              .setByteSize(byteSize[i])
+              .setDownloadedFileChecksum(checksum[i])
+              .setUrlToDownload(url[i])
+              .setDownloadTransforms(
+                  Transforms.newBuilder()
+                      .addTransform(
+                          Transform.newBuilder()
+                              .setZip(ZipTransform.newBuilder().setTarget("*").build())))
+              .build();
+      dataFileGroupBuilder.addFile(file);
+    }
+
+    return dataFileGroupBuilder.build();
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/account/AccountUtilTest.java b/javatests/com/google/android/libraries/mobiledatadownload/account/AccountUtilTest.java
new file mode 100644
index 0000000..d793514
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/account/AccountUtilTest.java
@@ -0,0 +1,112 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.account;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.accounts.Account;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+/** Unit tests for {@link AccountUtil}. */
+@RunWith(RobolectricTestRunner.class)
+public class AccountUtilTest {
+
+  @Test
+  public void createAccount() {
+    String name = "account-name";
+    String type = "account-type";
+
+    Account account = AccountUtil.create(name, type);
+
+    assertThat(account).isNotNull();
+    assertThat(account.name).isEqualTo(name);
+    assertThat(account.type).isEqualTo(type);
+  }
+
+  @Test
+  public void createAccount_emptyField() {
+    String name = "account-name";
+    String type = "account-type";
+
+    // Neither name nor type can be empty.
+    assertThat(AccountUtil.create("" /* name */, type)).isNull();
+    assertThat(AccountUtil.create(null /* name */, type)).isNull();
+    assertThat(AccountUtil.create(name, "" /* type */)).isNull();
+    assertThat(AccountUtil.create(name, null /* type */)).isNull();
+  }
+
+  @Test
+  public void createAccount_containDelimiter() {
+    String name = "account-name";
+    String type = "account-type";
+
+    // Neither name or type can contain account delimiter.
+    assertThat(AccountUtil.create("prefix:suffix" /* name */, type)).isNull();
+    assertThat(AccountUtil.create(name, "prefix:suffix" /* type */)).isNull();
+  }
+
+  @Test
+  public void createAccount_containSplitChar() {
+    String name = "account-name";
+    String type = "account-type";
+
+    // Neither name or type can contain split char.
+    assertThat(AccountUtil.create("prefix|suffix" /* name */, type)).isNull();
+    assertThat(AccountUtil.create(name, "prefix|suffix" /* type */)).isNull();
+  }
+
+  @Test
+  public void serializeAccount() {
+    String name = "account-name";
+    String type = "account-type";
+
+    Account account = new Account(name, type);
+    String serialized = AccountUtil.serialize(account);
+
+    assertThat(serialized).isEqualTo("account-type:account-name");
+  }
+
+  @Test
+  public void deserializeAccount() {
+    String accountStr = "foo:bar";
+    Account account = AccountUtil.deserialize(accountStr);
+
+    assertThat(account.type).isEqualTo("foo");
+    assertThat(account.name).isEqualTo("bar");
+  }
+
+  @Test
+  public void deserializeAccount_noDelimiter() {
+    // No account delimiter is found.
+    assertThat(AccountUtil.deserialize("type and name")).isNull();
+  }
+
+  @Test
+  public void deserializeAccount_multipleDelimiter() {
+    // Multiple account delimiters are found.
+    assertThat(AccountUtil.deserialize("type:name:foo")).isNull();
+    assertThat(AccountUtil.deserialize("type::name")).isNull();
+  }
+
+  @Test
+  public void deserializeAccount_containsSplitchar() {
+    // Neither name or type can contain split char.
+    assertThat(AccountUtil.deserialize("type:na|me")).isNull();
+    assertThat(AccountUtil.deserialize("ty|pe:name")).isNull();
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/account/BUILD b/javatests/com/google/android/libraries/mobiledatadownload/account/BUILD
new file mode 100644
index 0000000..48e23d9
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/account/BUILD
@@ -0,0 +1,32 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_local_test")
+
+package(
+    default_visibility = ["//:__subpackages__"],
+    licenses = ["notice"],
+)
+
+android_local_test(
+    name = "AccountUtilTest",
+    srcs = ["AccountUtilTest.java"],
+    manifest_values = {
+        "minSdkVersion": "16",
+        "targetSdkVersion": "27",
+    },
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/account:AccountUtil",
+        "@truth",
+    ],
+)
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/downloader/BUILD b/javatests/com/google/android/libraries/mobiledatadownload/downloader/BUILD
new file mode 100644
index 0000000..868a547
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/downloader/BUILD
@@ -0,0 +1,47 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("//javatests/com/google/android/libraries/mobiledatadownload:test_defs.bzl", "mdd_local_test")
+
+package(
+    default_visibility = ["//:__subpackages__"],
+    licenses = ["notice"],
+)
+
+mdd_local_test(
+    name = "MultiSchemeFileDownloaderTest",
+    srcs = ["MultiSchemeFileDownloaderTest.java"],
+    test_class = "com.google.android.libraries.mobiledatadownload.downloader.MultiSchemeFileDownloaderTest",
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/downloader:FileDownloader",
+        "@android_sdk_linux",
+        "@androidx_test",
+        "@com_google_guava_guava",
+        "@mockito",
+        "@truth",
+    ],
+)
+
+mdd_local_test(
+    name = "DownloadRequestTest",
+    srcs = ["DownloadRequestTest.java"],
+    test_class = "com.google.android.libraries.mobiledatadownload.downloader.DownloadRequestTest",
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload:FileSource",
+        "//java/com/google/android/libraries/mobiledatadownload/downloader:FileDownloader",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:MddConstants",
+        "@androidx_test",
+        "@com_google_protobuf//:protobuf_lite",
+        "@truth",
+    ],
+)
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/downloader/DownloadRequestTest.java b/javatests/com/google/android/libraries/mobiledatadownload/downloader/DownloadRequestTest.java
new file mode 100644
index 0000000..8115443
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/downloader/DownloadRequestTest.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.downloader;
+
+import static com.google.android.libraries.mobiledatadownload.internal.MddConstants.INLINE_FILE_URL_SCHEME;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.net.Uri;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.android.libraries.mobiledatadownload.FileSource;
+import com.google.protobuf.ByteString;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public final class DownloadRequestTest {
+
+  @Test
+  public void build_whenInlineFileUrl_whenInlineDownloadParamsNotProvided_throws() {
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            DownloadRequest.newBuilder()
+                .setUrlToDownload("inlinefile:sha1:test")
+                .setFileUri(
+                    Uri.parse(
+                        "android://com.google.android.libraries.mobiledatadownload.downloader/test"))
+                .build());
+  }
+
+  @Test
+  public void build_whenInlineFileUrl_whenInlineDownloadParamsProvided_builds() {
+    DownloadRequest request =
+        DownloadRequest.newBuilder()
+            .setUrlToDownload("inlinefile:sha1:test")
+            .setFileUri(
+                Uri.parse(
+                    "android://com.google.android.libraries.mobiledatadownload.downloader/test"))
+            .setInlineDownloadParamsOptional(
+                InlineDownloadParams.newBuilder()
+                    .setInlineFileContent(
+                        FileSource.ofByteString(ByteString.copyFromUtf8("TEST_CONTENT")))
+                    .build())
+            .build();
+
+    assertThat(request.inlineDownloadParamsOptional()).isPresent();
+    assertThat(request.urlToDownload()).startsWith(INLINE_FILE_URL_SCHEME);
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/downloader/MultiSchemeFileDownloaderTest.java b/javatests/com/google/android/libraries/mobiledatadownload/downloader/MultiSchemeFileDownloaderTest.java
new file mode 100644
index 0000000..832f4d9
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/downloader/MultiSchemeFileDownloaderTest.java
@@ -0,0 +1,176 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.downloader;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+import android.net.Uri;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.SettableFuture;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+@RunWith(AndroidJUnit4.class)
+public final class MultiSchemeFileDownloaderTest {
+
+  @Rule public final MockitoRule mocks = MockitoJUnit.rule();
+
+  @Mock FileDownloader mockStandardDownloader;
+  SettableFuture<Void> mockStandardDownloaderDownloadFuture = SettableFuture.create();
+  SettableFuture<CheckContentChangeResponse> mockStandardDownloaderIsContentChangedFuture =
+      SettableFuture.create();
+
+  @Mock FileDownloader mockPirDownloader;
+  SettableFuture<Void> mockPirDownloaderDownloadFuture = SettableFuture.create();
+  SettableFuture<CheckContentChangeResponse> mockPirDownloaderIsContentChangedFuture =
+      SettableFuture.create();
+
+  MultiSchemeFileDownloader downloader;
+
+  @Before
+  public void setUp() {
+
+    when(mockStandardDownloader.startDownloading(any()))
+        .thenReturn(mockStandardDownloaderDownloadFuture);
+    when(mockStandardDownloader.isContentChanged(any()))
+        .thenReturn(mockStandardDownloaderIsContentChangedFuture);
+    when(mockPirDownloader.startDownloading(any())).thenReturn(mockPirDownloaderDownloadFuture);
+    when(mockPirDownloader.isContentChanged(any()))
+        .thenReturn(mockPirDownloaderIsContentChangedFuture);
+
+    downloader =
+        MultiSchemeFileDownloader.builder()
+            .addScheme("http", mockStandardDownloader)
+            .addScheme("https", mockStandardDownloader)
+            .addScheme("pir", mockPirDownloader)
+            .build();
+  }
+
+  @Test
+  public void getScheme_worksForHttp() throws Exception {
+    assertThat(MultiSchemeFileDownloader.getScheme("http://www.google.com")).isEqualTo("http");
+  }
+
+  @Test
+  public void getScheme_worksForHttps() throws Exception {
+    assertThat(MultiSchemeFileDownloader.getScheme("https://www.google.com")).isEqualTo("https");
+  }
+
+  @Test
+  public void getScheme_worksForFile() throws Exception {
+    assertThat(MultiSchemeFileDownloader.getScheme("file:///localhost/bar")).isEqualTo("file");
+  }
+
+  @Test
+  public void getScheme_worksForData() throws Exception {
+    String url = "data:;charset=utf-8;base64,SGVsbG8gYW5kcm9pZCE=";
+    assertThat(MultiSchemeFileDownloader.getScheme(url)).isEqualTo("data");
+  }
+
+  @Test
+  public void getScheme_worksForPir() throws Exception {
+    String url = "pir://example.com:1234/database/version/3";
+    assertThat(MultiSchemeFileDownloader.getScheme(url)).isEqualTo("pir");
+  }
+
+  @Test
+  public void startDownloading_delegatesCorrectly_http() throws Exception {
+    DownloadRequest downloadRequest =
+        DownloadRequest.newBuilder()
+            .setUrlToDownload("http://www.google.com")
+            .setFileUri(Uri.parse("file://dev/null"))
+            .setDownloadConstraints(DownloadConstraints.NONE)
+            .build();
+    ListenableFuture<Void> future = downloader.startDownloading(downloadRequest);
+
+    verify(mockStandardDownloader).startDownloading(downloadRequest);
+    verifyNoMoreInteractions(mockStandardDownloader);
+    verifyNoMoreInteractions(mockPirDownloader);
+    assertThat(future.isDone()).isFalse();
+
+    // Make sure we actually got (a view of) the correct future.
+    mockStandardDownloaderDownloadFuture.set(null);
+    assertThat(future.isDone()).isTrue();
+  }
+
+  @Test
+  public void startDownloading_delegatesCorrectly_pir() throws Exception {
+    DownloadRequest downloadRequest =
+        DownloadRequest.newBuilder()
+            .setUrlToDownload("pir://example.com:1234/database/version/3")
+            .setFileUri(Uri.parse("file://dev/null"))
+            .setDownloadConstraints(DownloadConstraints.NONE)
+            .build();
+    ListenableFuture<Void> future = downloader.startDownloading(downloadRequest);
+
+    verify(mockPirDownloader).startDownloading(downloadRequest);
+    verifyNoMoreInteractions(mockStandardDownloader);
+    verifyNoMoreInteractions(mockPirDownloader);
+    assertThat(future.isDone()).isFalse();
+
+    // Make sure we actually got (a view of) the correct future.
+    mockPirDownloaderDownloadFuture.set(null);
+    assertThat(future.isDone()).isTrue();
+  }
+
+  @Test
+  public void isContentChanged_delegatesCorrectly_http() throws Exception {
+    CheckContentChangeRequest checkContentChangeRequest =
+        CheckContentChangeRequest.newBuilder().setUrl("http://www.google.com").build();
+    ListenableFuture<CheckContentChangeResponse> future =
+        downloader.isContentChanged(checkContentChangeRequest);
+
+    verify(mockStandardDownloader).isContentChanged(checkContentChangeRequest);
+    verifyNoMoreInteractions(mockStandardDownloader);
+    verifyNoMoreInteractions(mockPirDownloader);
+    assertThat(future.isDone()).isFalse();
+
+    // Make sure we actually got (a view of) the correct future.
+    mockStandardDownloaderIsContentChangedFuture.set(
+        CheckContentChangeResponse.newBuilder().setContentChanged(true).build());
+    assertThat(future.isDone()).isTrue();
+  }
+
+  @Test
+  public void isContentChanged_delegatesCorrectly_pir() throws Exception {
+    CheckContentChangeRequest checkContentChangeRequest =
+        CheckContentChangeRequest.newBuilder()
+            .setUrl("pir://example.com:1234/database/version/3")
+            .build();
+    ListenableFuture<CheckContentChangeResponse> future =
+        downloader.isContentChanged(checkContentChangeRequest);
+
+    verify(mockPirDownloader).isContentChanged(checkContentChangeRequest);
+    verifyNoMoreInteractions(mockStandardDownloader);
+    verifyNoMoreInteractions(mockPirDownloader);
+    assertThat(future.isDone()).isFalse();
+
+    // Make sure we actually got (a view of) the correct future.
+    mockPirDownloaderIsContentChangedFuture.set(
+        CheckContentChangeResponse.newBuilder().setContentChanged(true).build());
+    assertThat(future.isDone()).isTrue();
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/downloader/inline/BUILD b/javatests/com/google/android/libraries/mobiledatadownload/downloader/inline/BUILD
new file mode 100644
index 0000000..210f5ce
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/downloader/inline/BUILD
@@ -0,0 +1,39 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("//javatests/com/google/android/libraries/mobiledatadownload:test_defs.bzl", "mdd_local_test")
+
+package(
+    default_visibility = ["//:__subpackages__"],
+    licenses = ["notice"],
+)
+
+mdd_local_test(
+    name = "InlineFileDownloaderTest",
+    srcs = ["InlineFileDownloaderTest.java"],
+    test_class = "com.google.android.libraries.mobiledatadownload.downloader.inline.InlineFileDownloaderTest",
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload:DownloadException",
+        "//java/com/google/android/libraries/mobiledatadownload:FileSource",
+        "//java/com/google/android/libraries/mobiledatadownload/downloader:FileDownloader",
+        "//java/com/google/android/libraries/mobiledatadownload/downloader/inline:InlineFileDownloader",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:android",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing:fake_file_backend",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:stream",
+        "@androidx_test",
+        "@com_google_guava_guava",
+        "@com_google_protobuf//:protobuf_lite",
+        "@truth",
+    ],
+)
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/downloader/inline/InlineFileDownloaderTest.java b/javatests/com/google/android/libraries/mobiledatadownload/downloader/inline/InlineFileDownloaderTest.java
new file mode 100644
index 0000000..a7e392b
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/downloader/inline/InlineFileDownloaderTest.java
@@ -0,0 +1,143 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.downloader.inline;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.content.Context;
+import android.net.Uri;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.android.libraries.mobiledatadownload.DownloadException;
+import com.google.android.libraries.mobiledatadownload.DownloadException.DownloadResultCode;
+import com.google.android.libraries.mobiledatadownload.FileSource;
+import com.google.android.libraries.mobiledatadownload.downloader.DownloadConstraints;
+import com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest;
+import com.google.android.libraries.mobiledatadownload.downloader.InlineDownloadParams;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.AndroidFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.FakeFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.FakeFileBackend.OperationType;
+import com.google.android.libraries.mobiledatadownload.file.openers.ReadStreamOpener;
+import com.google.common.collect.ImmutableList;
+import com.google.protobuf.ByteString;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public final class InlineFileDownloaderTest {
+
+  private static final String FILE_NAME = "fileName";
+  private static final FileSource FILE_CONTENT =
+      FileSource.ofByteString(ByteString.copyFromUtf8("TEST_CONTENT"));
+  private static final Executor DOWNLOAD_EXECUTOR = Executors.newScheduledThreadPool(2);
+  private static final Context CONTEXT = ApplicationProvider.getApplicationContext();
+
+  private static final FakeFileBackend FAKE_FILE_BACKEND =
+      new FakeFileBackend(AndroidFileBackend.builder(CONTEXT).build());
+  private static final SynchronousFileStorage FILE_STORAGE =
+      new SynchronousFileStorage(
+          /* backends = */ ImmutableList.of(FAKE_FILE_BACKEND),
+          /* transforms = */ ImmutableList.of(),
+          /* monitors = */ ImmutableList.of());
+
+  private final Uri fileUri =
+      Uri.parse(
+          "android://com.google.android.libraries.mobiledatadownload.downloader.inline/files/datadownload/shared/public/"
+              + FILE_NAME);
+
+  private InlineFileDownloader inlineFileDownloader;
+
+  @Before
+  public void setUp() {
+    inlineFileDownloader = new InlineFileDownloader(FILE_STORAGE, DOWNLOAD_EXECUTOR);
+  }
+
+  @After
+  public void tearDown() {
+    FAKE_FILE_BACKEND.clearFailure(OperationType.ALL);
+  }
+
+  @Test
+  public void startDownloading_whenNonInlineFileSchemeGiven_fails() throws Exception {
+    DownloadRequest httpsDownloadRequest =
+        DownloadRequest.newBuilder()
+            .setUrlToDownload("https://url.to.download")
+            .setFileUri(fileUri)
+            .setDownloadConstraints(DownloadConstraints.NONE)
+            .build();
+
+    ExecutionException ex =
+        assertThrows(
+            ExecutionException.class,
+            () -> inlineFileDownloader.startDownloading(httpsDownloadRequest).get());
+
+    assertThat(ex).hasCauseThat().isInstanceOf(DownloadException.class);
+    DownloadException dex = (DownloadException) ex.getCause();
+    assertThat(dex.getDownloadResultCode())
+        .isEqualTo(DownloadResultCode.INVALID_INLINE_FILE_URL_SCHEME);
+  }
+
+  @Test
+  public void startDownloading_whenCopyFails_fails() throws Exception {
+    FAKE_FILE_BACKEND.setFailure(OperationType.WRITE_STREAM, new IOException("test exception"));
+
+    DownloadRequest downloadRequest =
+        DownloadRequest.newBuilder()
+            .setUrlToDownload("inlinefile:sha1:abc")
+            .setFileUri(fileUri)
+            .setInlineDownloadParamsOptional(
+                InlineDownloadParams.newBuilder().setInlineFileContent(FILE_CONTENT).build())
+            .build();
+
+    ExecutionException ex =
+        assertThrows(
+            ExecutionException.class,
+            () -> inlineFileDownloader.startDownloading(downloadRequest).get());
+
+    assertThat(ex).hasCauseThat().isInstanceOf(DownloadException.class);
+    DownloadException dex = (DownloadException) ex.getCause();
+    assertThat(dex.getDownloadResultCode()).isEqualTo(DownloadResultCode.INLINE_FILE_IO_ERROR);
+  }
+
+  @Test
+  public void startDownloading_whenCopyCompletes_isSuccess() throws Exception {
+    DownloadRequest downloadRequest =
+        DownloadRequest.newBuilder()
+            .setUrlToDownload("inlinefile:sha1:abc")
+            .setFileUri(fileUri)
+            .setInlineDownloadParamsOptional(
+                InlineDownloadParams.newBuilder().setInlineFileContent(FILE_CONTENT).build())
+            .build();
+
+    inlineFileDownloader.startDownloading(downloadRequest).get();
+
+    // Read file content back to check that it was copied from input
+    assertThat(FILE_STORAGE.exists(fileUri)).isTrue();
+    InputStream copiedContentStream = FILE_STORAGE.open(fileUri, ReadStreamOpener.create());
+    ByteString copiedContent = ByteString.readFrom(copiedContentStream);
+    copiedContentStream.close();
+    assertThat(copiedContent).isEqualTo(FILE_CONTENT.byteString());
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/downloader/offroad/AndroidManifest.xml b/javatests/com/google/android/libraries/mobiledatadownload/downloader/offroad/AndroidManifest.xml
new file mode 100644
index 0000000..294bbcc
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/downloader/offroad/AndroidManifest.xml
@@ -0,0 +1,34 @@
+<!--
+/*
+ * Copyright 2022 Google LLC
+ *
+ * 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.
+ */
+-->
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.google.android.libraries.mobiledatadownload.downloader.offroad" >
+
+  <uses-sdk android:minSdkVersion="16" />
+  <uses-permission android:name="android.permission.INTERNET" />
+  <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+  <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
+
+  <application>
+    <uses-library android:name="android.test.runner" />
+  </application>
+
+  <instrumentation
+      android:name="android.test.InstrumentationTestRunner"
+      android:targetPackage="com.google.android.libraries.mobiledatadownload.downloader.offroad" />
+</manifest>
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/downloader/offroad/BUILD b/javatests/com/google/android/libraries/mobiledatadownload/downloader/offroad/BUILD
new file mode 100644
index 0000000..7fbd330
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/downloader/offroad/BUILD
@@ -0,0 +1,63 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("//javatests/com/google/android/libraries/mobiledatadownload:test_defs.bzl", "mdd_local_test")
+
+package(
+    default_visibility = ["//:__subpackages__"],
+    licenses = ["notice"],
+)
+
+mdd_local_test(
+    name = "Offroad2FileDownloaderTest",
+    srcs = ["Offroad2FileDownloaderTest.java"],
+    data = [
+        "//javatests/com/google/android/libraries/mobiledatadownload/testdata:downloader_test_data_files",
+    ],
+    manifest = "AndroidManifest.xml",
+    test_class = "com.google.android.libraries.mobiledatadownload.downloader.offroad.Offroad2FileDownloaderTest",
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload:DownloadException",
+        "//java/com/google/android/libraries/mobiledatadownload/downloader:FileDownloader",
+        "//java/com/google/android/libraries/mobiledatadownload/downloader/offroad:ExceptionHandler",
+        "//java/com/google/android/libraries/mobiledatadownload/downloader/offroad:Offroad2FileDownloader",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:android",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/integration/downloader:downloader2",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:stream",
+        "//java/com/google/android/libraries/mobiledatadownload/tracing:concurrent",
+        "//javatests/com/google/android/libraries/mobiledatadownload/testing:TestHttpServer",
+        "@android_sdk_linux",
+        "@androidx_test",
+        "@com_google_guava_guava",
+        "@com_google_runfiles",
+        "@downloader",
+        "@truth",
+    ],
+)
+
+mdd_local_test(
+    name = "ExceptionHandlerTest",
+    srcs = ["ExceptionHandlerTest.java"],
+    test_class = "com.google.android.libraries.mobiledatadownload.downloader.offroad.ExceptionHandlerTest",
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload:DownloadException",
+        "//java/com/google/android/libraries/mobiledatadownload/downloader/offroad:ExceptionHandler",
+        "@android_sdk_linux",
+        "@com_google_guava_guava",
+        "@downloader",
+        "@truth",
+    ],
+)
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/downloader/offroad/ExceptionHandlerTest.java b/javatests/com/google/android/libraries/mobiledatadownload/downloader/offroad/ExceptionHandlerTest.java
new file mode 100644
index 0000000..ce6b155
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/downloader/offroad/ExceptionHandlerTest.java
@@ -0,0 +1,137 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.downloader.offroad;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.android.downloader.ErrorDetails;
+import com.google.android.downloader.RequestException;
+import com.google.android.libraries.mobiledatadownload.DownloadException;
+import com.google.android.libraries.mobiledatadownload.DownloadException.DownloadResultCode;
+import com.google.common.collect.ImmutableMap;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public final class ExceptionHandlerTest {
+
+  @Test
+  public void mapToDownloadException_withDefaultImpl_handlesHttpStatusErrors() throws Exception {
+    ErrorDetails errorDetails =
+        ErrorDetails.createFromHttpErrorResponse(
+            /* httpResponseCode = */ 404,
+            /* httpResponseHeaders = */ ImmutableMap.of(),
+            /* message = */ "404 response");
+    RequestException requestException = new RequestException(errorDetails);
+
+    ExceptionHandler handler = ExceptionHandler.withDefaultHandling();
+
+    DownloadException remappedException =
+        handler.mapToDownloadException("test exception", requestException);
+
+    assertThat(remappedException.getDownloadResultCode())
+        .isEqualTo(DownloadResultCode.ANDROID_DOWNLOADER_HTTP_ERROR);
+    assertThat(remappedException).hasMessageThat().isEqualTo("test exception");
+  }
+
+  @Test
+  public void
+      mapToDownloadException_withDefaultImpl_handlesHttpStatusErrorsWithDownloadExceptionUnwrapping()
+          throws Exception {
+    ErrorDetails errorDetails =
+        ErrorDetails.createFromHttpErrorResponse(
+            /* httpResponseCode = */ 404,
+            /* httpResponseHeaders = */ ImmutableMap.of(),
+            /* message = */ "404 response");
+    RequestException requestException = new RequestException(errorDetails);
+
+    com.google.android.downloader.DownloadException wrappedException =
+        new com.google.android.downloader.DownloadException(requestException);
+
+    ExceptionHandler handler = ExceptionHandler.withDefaultHandling();
+
+    DownloadException remappedException =
+        handler.mapToDownloadException("test exception", wrappedException);
+
+    assertThat(remappedException.getDownloadResultCode())
+        .isEqualTo(DownloadResultCode.ANDROID_DOWNLOADER_HTTP_ERROR);
+    assertThat(remappedException).hasMessageThat().isEqualTo("test exception");
+  }
+
+  @Test
+  public void mapToDownloadException_withDefaultImpl_handlesGeneralDownloadExceptionError()
+      throws Exception {
+    com.google.android.downloader.DownloadException generalException =
+        new com.google.android.downloader.DownloadException("general error");
+
+    ExceptionHandler handler = ExceptionHandler.withDefaultHandling();
+
+    DownloadException remappedException =
+        handler.mapToDownloadException("test exception", generalException);
+
+    assertThat(remappedException.getDownloadResultCode())
+        .isEqualTo(DownloadResultCode.ANDROID_DOWNLOADER2_ERROR);
+    assertThat(remappedException).hasMessageThat().isEqualTo("test exception");
+  }
+
+  @Test
+  public void mapToDownloadException_withDefaultImpl_returnsUnknownExceptionWhenCommonMappingFails()
+      throws Exception {
+    Exception testException = new Exception("test");
+
+    ExceptionHandler handler = ExceptionHandler.withDefaultHandling();
+
+    DownloadException remappedException =
+        handler.mapToDownloadException("test exception", testException);
+
+    assertThat(remappedException.getDownloadResultCode())
+        .isEqualTo(DownloadResultCode.UNKNOWN_ERROR);
+    assertThat(remappedException).hasMessageThat().isEqualTo("test exception");
+  }
+
+  @Test
+  public void
+      mapToDownloadException_withDefaultImpl_returnsUnknownExceptionWhenRequestExceptionMappingFails()
+          throws Exception {
+    RequestException requestException = new RequestException("generic request exception");
+
+    ExceptionHandler handler = ExceptionHandler.withDefaultHandling();
+
+    DownloadException remappedException =
+        handler.mapToDownloadException("test exception", requestException);
+
+    assertThat(remappedException.getDownloadResultCode())
+        .isEqualTo(DownloadResultCode.UNKNOWN_ERROR);
+    assertThat(remappedException).hasMessageThat().isEqualTo("test exception");
+  }
+
+  @Test
+  public void mapToDownloadException_withDefaultImpl_handlesMalformedExceptionChain()
+      throws Exception {
+    Exception cause1 = new Exception("test cause 1");
+    Exception cause2 = new Exception("test cause 2", cause1);
+    cause1.initCause(cause2);
+
+    ExceptionHandler handler = ExceptionHandler.withDefaultHandling();
+
+    DownloadException remappedException = handler.mapToDownloadException("test exception", cause1);
+
+    assertThat(remappedException.getDownloadResultCode())
+        .isEqualTo(DownloadResultCode.UNKNOWN_ERROR);
+    assertThat(remappedException).hasMessageThat().isEqualTo("test exception");
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/downloader/offroad/Offroad2FileDownloaderTest.java b/javatests/com/google/android/libraries/mobiledatadownload/downloader/offroad/Offroad2FileDownloaderTest.java
new file mode 100644
index 0000000..d9f439b
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/downloader/offroad/Offroad2FileDownloaderTest.java
@@ -0,0 +1,689 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * 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.
+ */
+// TODO
+package com.google.android.libraries.mobiledatadownload.downloader.offroad;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.util.concurrent.Futures.immediateFailedFuture;
+import static com.google.common.util.concurrent.Futures.immediateFuture;
+import static com.google.common.util.concurrent.Futures.immediateVoidFuture;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.junit.Assert.assertThrows;
+
+import android.content.Context;
+import android.net.Uri;
+import android.util.Pair;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.android.downloader.ConnectivityHandler;
+import com.google.android.downloader.DownloadConstraints;
+import com.google.android.downloader.DownloadConstraints.NetworkType;
+import com.google.android.downloader.DownloadMetadata;
+import com.google.android.downloader.Downloader;
+import com.google.android.downloader.FloggerDownloaderLogger;
+import com.google.android.downloader.OAuthTokenProvider;
+import com.google.android.downloader.PlatformUrlEngine;
+import com.google.android.downloader.testing.TestUrlEngine;
+import com.google.android.downloader.testing.TestUrlEngine.TestUrlRequest;
+import com.google.android.libraries.mobiledatadownload.DownloadException;
+import com.google.android.libraries.mobiledatadownload.DownloadException.DownloadResultCode;
+import com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.AndroidFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.backends.JavaFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.TemporaryUri;
+import com.google.android.libraries.mobiledatadownload.file.integration.downloader.DownloadMetadataStore;
+import com.google.android.libraries.mobiledatadownload.file.openers.ReadStreamOpener;
+import com.google.android.libraries.mobiledatadownload.file.openers.WriteStreamOpener;
+import com.google.android.libraries.mobiledatadownload.testing.TestHttpServer;
+import com.google.android.libraries.mobiledatadownload.tracing.PropagatedFutures;
+import com.google.common.base.Optional;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.common.io.ByteStreams;
+import com.google.common.net.HttpHeaders;
+import com.google.common.util.concurrent.AsyncFunction;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.devtools.build.runtime.RunfilesPaths;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+/**
+ * Unit tests for {@link
+ * com.google.android.libraries.mobiledatadownload.downloader.offroad.Offroad2FileDownloader}.
+ */
+@RunWith(RobolectricTestRunner.class)
+public class Offroad2FileDownloaderTest {
+
+  private static final int TRAFFIC_TAG = 1000;
+  private static final ScheduledExecutorService DOWNLOAD_EXECUTOR =
+      Executors.newScheduledThreadPool(2);
+  private static final ListeningExecutorService CONTROL_EXECUTOR =
+      MoreExecutors.listeningDecorator(Executors.newCachedThreadPool());
+
+  private static final long MAX_CONNECTION_WAIT_SECS = 10L;
+  private static final int MAX_PLATFORM_ENGINE_TIMEOUT_MILLIS = 1000;
+
+  /** Endpoint that can be registered to TestHttpServer to serve a file that can be downloaded. */
+  private static final String TEST_DATA_ENDPOINT = "/testfile";
+
+  /** Path to the underlying test data that is the source of what TestHttpServer will serve. */
+  private static final String TEST_DATA_PATH =
+      RunfilesPaths.resolve(
+              "third_party/java_src/android_libs/mobiledatadownload/javatests/com/google/android/libraries/mobiledatadownload/testdata/full_file.txt")
+          .toString();
+
+  private static final String PARTIAL_TEST_DATA_PATH =
+      RunfilesPaths.resolve(
+              "third_party/java_src/android_libs/mobiledatadownload/javatests/com/google/android/libraries/mobiledatadownload/testdata/partial_file.txt")
+          .toString();
+
+  private Context context;
+
+  private Uri.Builder testUrlPrefix;
+  private TestHttpServer testHttpServer;
+
+  private SynchronousFileStorage fileStorage;
+  private FakeConnectivityHandler fakeConnectivityHandler;
+  private FakeDownloadMetadataStore fakeDownloadMetadataStore;
+  private FakeOAuthTokenProvider fakeOAuthTokenProvider;
+  private FakeTrafficStatsTagger fakeTrafficStatsTagger;
+  private TestUrlEngine testUrlEngine;
+  private Downloader downloader;
+
+  private Offroad2FileDownloader fileDownloader;
+
+  @Rule public TemporaryUri tmpUri = new TemporaryUri();
+
+  @Before
+  public void setUp() throws Exception {
+    context = ApplicationProvider.getApplicationContext();
+    fileStorage =
+        new SynchronousFileStorage(
+            /* backends = */ ImmutableList.of(
+                AndroidFileBackend.builder(context).build(), new JavaFileBackend()),
+            /* transforms = */ ImmutableList.of(),
+            /* monitors = */ ImmutableList.of());
+
+    fakeDownloadMetadataStore = new FakeDownloadMetadataStore();
+
+    fakeTrafficStatsTagger = new FakeTrafficStatsTagger();
+
+    PlatformUrlEngine urlEngine =
+        new PlatformUrlEngine(
+            CONTROL_EXECUTOR,
+            MAX_PLATFORM_ENGINE_TIMEOUT_MILLIS,
+            MAX_PLATFORM_ENGINE_TIMEOUT_MILLIS,
+            fakeTrafficStatsTagger);
+
+    testUrlEngine = new TestUrlEngine(urlEngine);
+
+    fakeConnectivityHandler = new FakeConnectivityHandler();
+
+    downloader =
+        new Downloader.Builder()
+            .withIOExecutor(CONTROL_EXECUTOR)
+            .withConnectivityHandler(fakeConnectivityHandler)
+            .withMaxConcurrentDownloads(2)
+            .withLogger(new FloggerDownloaderLogger())
+            .addUrlEngine(ImmutableList.of("http", "https"), testUrlEngine)
+            .build();
+
+    fakeOAuthTokenProvider = new FakeOAuthTokenProvider();
+
+    fileDownloader =
+        new Offroad2FileDownloader(
+            downloader,
+            fileStorage,
+            DOWNLOAD_EXECUTOR,
+            fakeOAuthTokenProvider,
+            fakeDownloadMetadataStore,
+            ExceptionHandler.withDefaultHandling(),
+            Optional.absent());
+
+    testHttpServer = new TestHttpServer();
+    testUrlPrefix = testHttpServer.startServer();
+  }
+
+  @After
+  public void tearDown() {
+    testHttpServer.stopServer();
+    fakeConnectivityHandler.reset();
+    fakeDownloadMetadataStore.reset();
+    fakeOAuthTokenProvider.reset();
+    fakeTrafficStatsTagger.reset();
+  }
+
+  @Test
+  public void testStartDownloading_downloadConditionsNull_usesWifiOnly() throws Exception {
+    Uri fileUri = tmpUri.newUri();
+    String urlToDownload = testUrlPrefix.path(TEST_DATA_ENDPOINT).toString();
+    testHttpServer.registerTextFile(TEST_DATA_ENDPOINT, TEST_DATA_PATH);
+
+    // Setup custom handler to ensure expected constraints.
+    fakeConnectivityHandler.customHandler =
+        constraints -> {
+          assertThat(constraints.requireUnmeteredNetwork()).isTrue();
+          assertThat(constraints.requiredNetworkTypes())
+              .containsExactly(NetworkType.WIFI, NetworkType.ETHERNET, NetworkType.BLUETOOTH);
+
+          return immediateVoidFuture();
+        };
+
+    ListenableFuture<Void> downloadFuture =
+        fileDownloader.startDownloading(
+            DownloadRequest.newBuilder()
+                .setFileUri(fileUri)
+                .setUrlToDownload(urlToDownload)
+                .setDownloadConstraints(
+                    com.google.android.libraries.mobiledatadownload.downloader.DownloadConstraints
+                        .NONE)
+                .build());
+
+    downloadFuture.get(MAX_CONNECTION_WAIT_SECS, SECONDS);
+
+    assertThat(fakeConnectivityHandler.invokedCustomHandler).isTrue();
+
+    // Check DownloadMetadataStore calls
+    assertThat(fakeDownloadMetadataStore.upsertCalls).containsKey(fileUri);
+    assertThat(fakeDownloadMetadataStore.deleteCalls).contains(fileUri);
+    assertThat(fakeDownloadMetadataStore.read(fileUri).get(MAX_CONNECTION_WAIT_SECS, SECONDS))
+        .isAbsent();
+  }
+
+  @Test
+  public void testStartDownloading_wifi() throws Exception {
+    Uri fileUri = tmpUri.newUri();
+    String urlToDownload = testUrlPrefix.path(TEST_DATA_ENDPOINT).toString();
+    testHttpServer.registerTextFile(TEST_DATA_ENDPOINT, TEST_DATA_PATH);
+
+    // Setup custom handler to ensure expected constraints.
+    fakeConnectivityHandler.customHandler =
+        constraints -> {
+          assertThat(constraints.requireUnmeteredNetwork()).isTrue();
+          assertThat(constraints.requiredNetworkTypes())
+              .containsExactly(NetworkType.WIFI, NetworkType.ETHERNET, NetworkType.BLUETOOTH);
+
+          return immediateVoidFuture();
+        };
+
+    // Setup custom handler to add authorization token
+    fakeOAuthTokenProvider.customHandler = unused -> immediateFuture("TEST_TOKEN");
+
+    ListenableFuture<Void> downloadFuture =
+        fileDownloader.startDownloading(
+            DownloadRequest.newBuilder()
+                .setFileUri(fileUri)
+                .setUrlToDownload(urlToDownload)
+                .setDownloadConstraints(
+                    com.google.android.libraries.mobiledatadownload.downloader.DownloadConstraints
+                        .NETWORK_UNMETERED)
+                .setTrafficTag(TRAFFIC_TAG)
+                .build());
+
+    downloadFuture.get(MAX_CONNECTION_WAIT_SECS, SECONDS);
+
+    assertThat(testUrlEngine.storedRequests()).hasSize(1);
+    TestUrlRequest request = testUrlEngine.storedRequests().get(0);
+    assertThat(request.trafficTag()).isEqualTo(TRAFFIC_TAG);
+    assertThat(request.headers()).containsKey(HttpHeaders.AUTHORIZATION);
+    assertThat(request.headers())
+        .valuesForKey(HttpHeaders.AUTHORIZATION)
+        .contains("Bearer TEST_TOKEN");
+
+    assertThat(fakeConnectivityHandler.invokedCustomHandler).isTrue();
+    assertThat(fakeOAuthTokenProvider.invokedCustomHandler).isTrue();
+    assertThat(fakeTrafficStatsTagger.storedTrafficTags).contains(TRAFFIC_TAG);
+  }
+
+  @Test
+  public void testStartDownloading_wifi_notSettingTrafficTag() throws Exception {
+    Uri fileUri = tmpUri.newUri();
+    String urlToDownload = testUrlPrefix.path(TEST_DATA_ENDPOINT).toString();
+    testHttpServer.registerTextFile(TEST_DATA_ENDPOINT, TEST_DATA_PATH);
+
+    ListenableFuture<Void> downloadFuture =
+        fileDownloader.startDownloading(
+            DownloadRequest.newBuilder()
+                .setFileUri(fileUri)
+                .setUrlToDownload(urlToDownload)
+                .setDownloadConstraints(
+                    com.google.android.libraries.mobiledatadownload.downloader.DownloadConstraints
+                        .NETWORK_UNMETERED)
+                .build());
+
+    downloadFuture.get(MAX_CONNECTION_WAIT_SECS, SECONDS);
+
+    assertThat(testUrlEngine.storedRequests()).hasSize(1);
+    TestUrlRequest request = testUrlEngine.storedRequests().get(0);
+    assertThat(request.trafficTag()).isEqualTo(0);
+
+    assertThat(fakeTrafficStatsTagger.storedTrafficTags).doesNotContain(TRAFFIC_TAG);
+  }
+
+  @Test
+  public void testStartDownloading_extraHttpHeaders() throws Exception {
+    Uri fileUri = tmpUri.newUri();
+    String urlToDownload = testUrlPrefix.path(TEST_DATA_ENDPOINT).toString();
+    testHttpServer.registerTextFile(TEST_DATA_ENDPOINT, TEST_DATA_PATH);
+
+    ListenableFuture<Void> downloadFuture =
+        fileDownloader.startDownloading(
+            DownloadRequest.newBuilder()
+                .setFileUri(fileUri)
+                .setUrlToDownload(urlToDownload)
+                .setDownloadConstraints(
+                    com.google.android.libraries.mobiledatadownload.downloader.DownloadConstraints
+                        .NETWORK_UNMETERED)
+                .setTrafficTag(TRAFFIC_TAG)
+                .setExtraHttpHeaders(
+                    ImmutableList.of(
+                        Pair.create("user-agent", "mdd-downloader"),
+                        Pair.create("other-header", "header-value")))
+                .build());
+
+    downloadFuture.get(MAX_CONNECTION_WAIT_SECS, SECONDS);
+
+    assertThat(testUrlEngine.storedRequests()).hasSize(1);
+    TestUrlRequest request = testUrlEngine.storedRequests().get(0);
+    assertThat(request.headers().keySet()).containsExactly("user-agent", "other-header");
+    assertThat(request.headers()).valuesForKey("user-agent").contains("mdd-downloader");
+    assertThat(request.headers()).valuesForKey("other-header").contains("header-value");
+  }
+
+  @Test
+  public void testStartDownloading_cellular() throws Exception {
+    Uri fileUri = tmpUri.newUri();
+    String urlToDownload = testUrlPrefix.path(TEST_DATA_ENDPOINT).toString();
+    testHttpServer.registerTextFile(TEST_DATA_ENDPOINT, TEST_DATA_PATH);
+
+    // Setup custom handler to ensure expected constraints.
+    fakeConnectivityHandler.customHandler =
+        constraints -> {
+          assertThat(constraints).isEqualTo(DownloadConstraints.NETWORK_CONNECTED);
+
+          return immediateVoidFuture();
+        };
+
+    ListenableFuture<Void> downloadFuture =
+        fileDownloader.startDownloading(
+            DownloadRequest.newBuilder()
+                .setFileUri(fileUri)
+                .setUrlToDownload(urlToDownload)
+                .setDownloadConstraints(
+                    com.google.android.libraries.mobiledatadownload.downloader.DownloadConstraints
+                        .NETWORK_CONNECTED)
+                .build());
+
+    downloadFuture.get(MAX_CONNECTION_WAIT_SECS, SECONDS);
+
+    assertThat(fakeConnectivityHandler.invokedCustomHandler).isTrue();
+  }
+
+  @Test
+  public void testStartDownloading_failed() throws Exception {
+    Uri fileUri = tmpUri.newUri();
+
+    // Simulate failure due to bad url;
+    ListenableFuture<Void> downloadFuture =
+        fileDownloader.startDownloading(
+            DownloadRequest.newBuilder()
+                .setFileUri(fileUri)
+                .setUrlToDownload("https://BADURL")
+                .setDownloadConstraints(
+                    com.google.android.libraries.mobiledatadownload.downloader.DownloadConstraints
+                        .NETWORK_UNMETERED)
+                .build());
+
+    ExecutionException exception = assertThrows(ExecutionException.class, downloadFuture::get);
+    assertThat(exception).hasCauseThat().isInstanceOf(DownloadException.class);
+
+    DownloadException dex = (DownloadException) exception.getCause();
+    assertThat(dex.getDownloadResultCode()).isEqualTo(DownloadResultCode.UNKNOWN_ERROR);
+  }
+
+  @Test
+  public void testStartDownloading_whenPartialFile_whenMetadataNotPresent_getsFullFile()
+      throws Exception {
+    Uri fileUri = tmpUri.newUri();
+    String urlToDownload = testUrlPrefix.path(TEST_DATA_ENDPOINT).toString();
+    testHttpServer.registerTextFile(TEST_DATA_ENDPOINT, TEST_DATA_PATH);
+
+    // Write partial content to file but do _not_ write partial metadata.
+    try (InputStream inStream =
+            fileStorage.open(
+                Uri.parse("file://" + PARTIAL_TEST_DATA_PATH), ReadStreamOpener.create());
+        OutputStream outStream = fileStorage.open(fileUri, WriteStreamOpener.create())) {
+      ByteStreams.copy(inStream, outStream);
+    }
+
+    ListenableFuture<Void> downloadFuture =
+        fileDownloader.startDownloading(
+            DownloadRequest.newBuilder()
+                .setFileUri(fileUri)
+                .setUrlToDownload(urlToDownload)
+                .setDownloadConstraints(
+                    com.google.android.libraries.mobiledatadownload.downloader.DownloadConstraints
+                        .NONE)
+                .build());
+
+    downloadFuture.get(MAX_CONNECTION_WAIT_SECS, SECONDS);
+
+    // Check that full file is requested (no HTTP range headers)
+    assertThat(testUrlEngine.storedRequests().get(0).headers())
+        .doesNotContainKey(HttpHeaders.RANGE);
+    assertThat(testUrlEngine.storedRequests().get(0).headers())
+        .doesNotContainKey(HttpHeaders.IF_RANGE);
+
+    // Check DownloadMetadataStore calls
+    assertThat(fakeDownloadMetadataStore.readCalls).contains(fileUri);
+    assertThat(fakeDownloadMetadataStore.upsertCalls).containsKey(fileUri);
+    assertThat(fakeDownloadMetadataStore.deleteCalls).contains(fileUri);
+    assertThat(fakeDownloadMetadataStore.read(fileUri).get(MAX_CONNECTION_WAIT_SECS, SECONDS))
+        .isAbsent();
+  }
+
+  @Test
+  public void testStartDownloading_whenPartialFile_whenMetadataPresent_reusesPartialFile()
+      throws Exception {
+    Uri fileUri = tmpUri.newUri();
+    String urlToDownload = testUrlPrefix.path(TEST_DATA_ENDPOINT).toString();
+    testHttpServer.registerTextFile(TEST_DATA_ENDPOINT, TEST_DATA_PATH);
+
+    // Write partial content to file.
+    try (InputStream inStream =
+            fileStorage.open(
+                Uri.parse("file://" + PARTIAL_TEST_DATA_PATH), ReadStreamOpener.create());
+        OutputStream outStream = fileStorage.open(fileUri, WriteStreamOpener.create())) {
+      ByteStreams.copy(inStream, outStream);
+    }
+
+    // Write existing metadata to file.
+    fakeDownloadMetadataStore
+        .upsert(fileUri, DownloadMetadata.create("test", 0))
+        .get(MAX_CONNECTION_WAIT_SECS, SECONDS);
+
+    ListenableFuture<Void> downloadFuture =
+        fileDownloader.startDownloading(
+            DownloadRequest.newBuilder()
+                .setFileUri(fileUri)
+                .setUrlToDownload(urlToDownload)
+                .setDownloadConstraints(
+                    com.google.android.libraries.mobiledatadownload.downloader.DownloadConstraints
+                        .NONE)
+                .build());
+
+    downloadFuture.get(MAX_CONNECTION_WAIT_SECS, SECONDS);
+
+    // Check that full file is requested (no HTTP range headers)
+    assertThat(testUrlEngine.storedRequests().get(0).headers()).containsKey(HttpHeaders.RANGE);
+    assertThat(testUrlEngine.storedRequests().get(0).headers()).containsKey(HttpHeaders.IF_RANGE);
+
+    // Check DownloadMetadataStore calls
+    assertThat(fakeDownloadMetadataStore.readCalls).contains(fileUri);
+    assertThat(fakeDownloadMetadataStore.upsertCalls).containsKey(fileUri);
+    assertThat(fakeDownloadMetadataStore.deleteCalls).contains(fileUri);
+    assertThat(fakeDownloadMetadataStore.read(fileUri).get(MAX_CONNECTION_WAIT_SECS, SECONDS))
+        .isAbsent();
+  }
+
+  @Test
+  public void testCancelDownload_notFinishedFuture() throws Exception {
+    // Build a file uri so it's not created by TemporaryUri -- we can then make assertions on the
+    // existence of the file.
+    Uri fileUri = tmpUri.newUriBuilder().appendPath("unique").build();
+
+    String urlToDownload = testUrlPrefix.path(TEST_DATA_ENDPOINT).toString();
+    testHttpServer.registerTextFile(TEST_DATA_ENDPOINT, TEST_DATA_PATH);
+
+    // Block download using connectivity check
+    CountDownLatch blockingLatch = new CountDownLatch(1);
+    fakeConnectivityHandler.customHandler =
+        unused ->
+            PropagatedFutures.submitAsync(
+                () -> {
+                  blockingLatch.await();
+                  return immediateVoidFuture();
+                },
+                CONTROL_EXECUTOR);
+
+    ListenableFuture<Void> downloadFuture =
+        fileDownloader.startDownloading(
+            DownloadRequest.newBuilder()
+                .setFileUri(fileUri)
+                .setUrlToDownload(urlToDownload)
+                .setDownloadConstraints(
+                    com.google.android.libraries.mobiledatadownload.downloader.DownloadConstraints
+                        .NETWORK_UNMETERED)
+                .build());
+
+    assertThat(downloadFuture.isDone()).isFalse();
+    assertThat(fileStorage.exists(fileUri)).isFalse();
+
+    downloadFuture.cancel(true);
+
+    assertThat(downloadFuture.isCancelled()).isTrue();
+    assertThat(fileStorage.exists(fileUri)).isFalse();
+
+    // count down latch to clean up test.
+    blockingLatch.countDown();
+  }
+
+  @Test
+  public void testCancelDownload_onAlreadySucceededFuture() throws Exception {
+    // Build a file uri so it's not created by TemporaryUri -- we can then make assertions on the
+    // existence of the file.
+    Uri fileUri = tmpUri.getRootUriBuilder().appendPath("unique").build();
+    String urlToDownload = testUrlPrefix.path(TEST_DATA_ENDPOINT).toString();
+    testHttpServer.registerTextFile(TEST_DATA_ENDPOINT, TEST_DATA_PATH);
+
+    ListenableFuture<Void> downloadFuture =
+        fileDownloader.startDownloading(
+            DownloadRequest.newBuilder()
+                .setFileUri(fileUri)
+                .setUrlToDownload(urlToDownload)
+                .setDownloadConstraints(
+                    com.google.android.libraries.mobiledatadownload.downloader.DownloadConstraints
+                        .NETWORK_UNMETERED)
+                .build());
+
+    downloadFuture.get(MAX_CONNECTION_WAIT_SECS, SECONDS);
+
+    // Assert that on device file is created and remains even after cancel.
+    assertThat(fileStorage.exists(fileUri)).isTrue();
+
+    downloadFuture.cancel(true);
+
+    assertThat(fileStorage.exists(fileUri)).isTrue();
+  }
+
+  @Test
+  public void testCancelDownload_onAlreadyFailedFuture() throws Exception {
+    // Build a file uri so it's not created by TemporaryUri -- we can then make assertions on the
+    // existence of the file.
+    Uri fileUri = tmpUri.getRootUriBuilder().appendPath("unique").build();
+
+    // Simulate failure due to bad url;
+    ListenableFuture<Void> downloadFuture =
+        fileDownloader.startDownloading(
+            DownloadRequest.newBuilder()
+                .setFileUri(fileUri)
+                .setUrlToDownload("https://BADURL")
+                .setDownloadConstraints(
+                    com.google.android.libraries.mobiledatadownload.downloader.DownloadConstraints
+                        .NETWORK_UNMETERED)
+                .build());
+
+    Exception unused = assertThrows(ExecutionException.class, downloadFuture::get);
+
+    // Assert that on device file is not created and doesn't get created after cancel.
+    assertThat(fileStorage.exists(fileUri)).isFalse();
+
+    downloadFuture.cancel(true);
+
+    assertThat(fileStorage.exists(fileUri)).isFalse();
+  }
+
+  /** Custom {@link ConnectivityHandler} that allows custom logic to be used for each test. */
+  static final class FakeConnectivityHandler implements ConnectivityHandler {
+    private static final AsyncFunction<DownloadConstraints, Void> DEFAULT_HANDLER =
+        unused -> immediateVoidFuture();
+
+    private AsyncFunction<DownloadConstraints, Void> customHandler = DEFAULT_HANDLER;
+
+    private boolean invokedCustomHandler = false;
+
+    @Override
+    public ListenableFuture<Void> checkConnectivity(DownloadConstraints constraints) {
+      ListenableFuture<Void> returnFuture;
+      try {
+        returnFuture = customHandler.apply(constraints);
+      } catch (Exception e) {
+        returnFuture = immediateFailedFuture(e);
+      }
+
+      invokedCustomHandler = true;
+      return returnFuture;
+    }
+
+    public boolean invokedCustomHandler() {
+      return invokedCustomHandler;
+    }
+
+    /**
+     * Reset inner state to initial values.
+     *
+     * <p>This prevents failures caused by cross test pollution.
+     */
+    public void reset() {
+      customHandler = DEFAULT_HANDLER;
+      invokedCustomHandler = false;
+    }
+  }
+
+  /** Custom {@link OAuthTokenProvider} that allows custom logic for each test. */
+  static final class FakeOAuthTokenProvider implements OAuthTokenProvider {
+    private static final AsyncFunction<URI, String> DEFAULT_HANDLER =
+        unused -> immediateFuture(null);
+
+    private AsyncFunction<URI, String> customHandler = DEFAULT_HANDLER;
+
+    private boolean invokedCustomHandler = false;
+
+    @Override
+    public ListenableFuture<String> provideOAuthToken(URI uri) {
+      ListenableFuture<String> returnFuture;
+      try {
+        returnFuture = customHandler.apply(uri);
+      } catch (Exception e) {
+        returnFuture = immediateFailedFuture(e);
+      }
+
+      invokedCustomHandler = true;
+      return returnFuture;
+    }
+
+    /**
+     * Reset inner state to initial values.
+     *
+     * <p>This prevents failures caused by cross test pollution.
+     */
+    public void reset() {
+      customHandler = DEFAULT_HANDLER;
+      invokedCustomHandler = false;
+    }
+  }
+
+  private static final class FakeTrafficStatsTagger
+      implements PlatformUrlEngine.TrafficStatsTagger {
+    private final List<Integer> storedTrafficTags = new ArrayList<>();
+
+    @Override
+    public int getAndSetThreadStatsTag(int tag) {
+      int prevTag = storedTrafficTags.isEmpty() ? 0 : Iterables.getLast(storedTrafficTags);
+      storedTrafficTags.add(tag);
+      return prevTag;
+    }
+
+    @Override
+    public void restoreThreadStatsTag(int tag) {
+      storedTrafficTags.add(tag);
+    }
+
+    public void reset() {
+      storedTrafficTags.clear();
+    }
+  }
+
+  private static final class FakeDownloadMetadataStore implements DownloadMetadataStore {
+
+    // Backing storage structure for metadata.
+    private final Map<Uri, DownloadMetadata> storedMetadata = new HashMap<>();
+
+    // Tracking of what calls are made on this fake.
+    final List<Uri> readCalls = new ArrayList<>();
+    final Map<Uri, List<DownloadMetadata>> upsertCalls = new HashMap<>();
+    final List<Uri> deleteCalls = new ArrayList<>();
+
+    @Override
+    public ListenableFuture<Optional<DownloadMetadata>> read(Uri uri) {
+      readCalls.add(uri);
+
+      return immediateFuture(Optional.fromNullable(storedMetadata.get(uri)));
+    }
+
+    @Override
+    public ListenableFuture<Void> upsert(Uri uri, DownloadMetadata downloadMetadata) {
+      upsertCalls.putIfAbsent(uri, new ArrayList<>());
+      upsertCalls.get(uri).add(downloadMetadata);
+
+      storedMetadata.put(uri, downloadMetadata);
+      return immediateVoidFuture();
+    }
+
+    @Override
+    public ListenableFuture<Void> delete(Uri uri) {
+      deleteCalls.add(uri);
+
+      storedMetadata.remove(uri);
+      return immediateVoidFuture();
+    }
+
+    public void reset() {
+      storedMetadata.clear();
+
+      readCalls.clear();
+      upsertCalls.clear();
+      deleteCalls.clear();
+    }
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/AndroidManifest.xml b/javatests/com/google/android/libraries/mobiledatadownload/file/AndroidManifest.xml
new file mode 100644
index 0000000..208b6f8
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/AndroidManifest.xml
@@ -0,0 +1,30 @@
+<!--
+/*
+ * Copyright 2022 Google LLC
+ *
+ * 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.
+ */
+-->
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+  package="com.google.android.libraries.mobiledatadownload.file">
+    <uses-sdk
+            android:minSdkVersion="15"
+            android:targetSdkVersion="23"/>
+    <application>
+        <uses-library android:name="android.test.runner" />
+    </application>
+    <instrumentation
+        android:name="com.google.android.apps.common.testing.testrunner.Google3InstrumentationTestRunner"
+        android:targetPackage="com.google.android.libraries.mobiledatadownload.file" />
+</manifest>
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/BUILD b/javatests/com/google/android/libraries/mobiledatadownload/file/BUILD
new file mode 100644
index 0000000..d02d4f1
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/BUILD
@@ -0,0 +1,73 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_local_test")
+
+package(
+    default_visibility = ["//:__subpackages__"],
+    licenses = ["notice"],
+)
+
+# Export manifest file to avoid copy-pasting into every test directory
+exports_files(srcs = ["AndroidManifest.xml"])
+
+android_local_test(
+    name = "MonitorInputStreamTest",
+    size = "small",
+    srcs = ["MonitorInputStreamTest.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing:extras",
+        "//java/com/google/android/libraries/mobiledatadownload/file/spi",
+        "@mockito",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "MonitorOutputStreamTest",
+    size = "small",
+    srcs = ["MonitorOutputStreamTest.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing:extras",
+        "//java/com/google/android/libraries/mobiledatadownload/file/spi",
+        "@mockito",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "SynchronousFileStorageTest",
+    size = "small",
+    srcs = ["SynchronousFileStorageTest.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:android",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing:extras",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing:filestorage",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing:matchers",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:stream",
+        "//java/com/google/android/libraries/mobiledatadownload/file/spi",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:buffer",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:compress",
+        "@androidx_test",
+        "@com_google_guava_guava",
+        "@mockito",
+        "@truth",
+    ],
+)
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/MonitorInputStreamTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/MonitorInputStreamTest.java
new file mode 100644
index 0000000..3d0beb0
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/MonitorInputStreamTest.java
@@ -0,0 +1,115 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file;
+
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.makeArrayOfBytesContent;
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.BufferingMonitor;
+import com.google.android.libraries.mobiledatadownload.file.spi.Monitor;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Arrays;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+/** Test {@link com.google.android.libraries.mobiledatadownload.file.MonitorInputStream}. */
+@RunWith(RobolectricTestRunner.class)
+public class MonitorInputStreamTest {
+
+  private static final byte[] TEST_CONTENT = makeArrayOfBytesContent();
+
+  @Test
+  public void monitorRead_shouldSeeAllBytes() throws Exception {
+    BufferingMonitor buffer = new BufferingMonitor();
+    Uri uri = Uri.parse("foo:");
+    InputStream in = new ByteArrayInputStream(TEST_CONTENT);
+    MonitorInputStream stream = MonitorInputStream.newInstance(Arrays.asList(buffer), uri, in);
+
+    stream.read();
+    stream.read(new byte[5]);
+    stream.read();
+    stream.read(new byte[10], 5, 3);
+
+    assertThat(buffer.bytesRead()).isEqualTo(Arrays.copyOf(TEST_CONTENT, 10));
+  }
+
+  @Test
+  public void monitorRead_withNullInputMonitor_shouldReturnNull() throws Exception {
+    Monitor mockMonitor = mock(Monitor.class);
+    when(mockMonitor.monitorRead(any())).thenReturn(null);
+    Uri uri = Uri.parse("foo:");
+    InputStream in = new ByteArrayInputStream(TEST_CONTENT);
+
+    assertThat(MonitorInputStream.newInstance(Arrays.asList(mockMonitor), uri, in)).isNull();
+  }
+
+  @Test
+  public void monitorRead_shouldCallClose() throws Exception {
+    Monitor mockMonitor = mock(Monitor.class);
+    Monitor.InputMonitor mockInputMonitor = mock(Monitor.InputMonitor.class);
+    when(mockMonitor.monitorRead(any())).thenReturn(mockInputMonitor);
+    Uri uri = Uri.parse("foo:");
+    InputStream in = new ByteArrayInputStream(TEST_CONTENT);
+    try (MonitorInputStream stream =
+        MonitorInputStream.newInstance(Arrays.asList(mockMonitor), uri, in)) {
+      stream.read(new byte[TEST_CONTENT.length]);
+    }
+    verify(mockInputMonitor, times(1)).close();
+  }
+
+  @Test
+  public void monitorReadWithMonitorCloseException_shouldCallClose() throws Exception {
+    Monitor mockMonitor = mock(Monitor.class);
+    Monitor.InputMonitor mockInputMonitor = mock(Monitor.InputMonitor.class);
+    when(mockMonitor.monitorRead(any())).thenReturn(mockInputMonitor);
+    doThrow(new RuntimeException("Unchecked")).when(mockInputMonitor).close();
+    Uri uri = Uri.parse("foo:");
+    FakeInputStream fakeIn = new FakeInputStream();
+    try (MonitorInputStream stream =
+        MonitorInputStream.newInstance(Arrays.asList(mockMonitor), uri, fakeIn)) {
+      stream.read(new byte[TEST_CONTENT.length]);
+    }
+    verify(mockInputMonitor, times(1)).close();
+    assertThat(fakeIn.bytesRead).isEqualTo(TEST_CONTENT.length);
+    assertThat(fakeIn.closed).isTrue();
+  }
+
+  private static class FakeInputStream extends InputStream {
+    private boolean closed = true;
+    private int bytesRead = 0;
+
+    @Override
+    public int read() throws IOException {
+      bytesRead += 1;
+      return 0;
+    }
+
+    @Override
+    public void close() throws IOException {
+      closed = true;
+    }
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/MonitorOutputStreamTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/MonitorOutputStreamTest.java
new file mode 100644
index 0000000..3ce9641
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/MonitorOutputStreamTest.java
@@ -0,0 +1,147 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file;
+
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.makeArrayOfBytesContent;
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.BufferingMonitor;
+import com.google.android.libraries.mobiledatadownload.file.spi.Monitor;
+import com.google.thirdparty.robolectric.GoogleRobolectricTestRunner;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.Arrays;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Test {@link com.google.android.libraries.mobiledatadownload.file.MonitorOutputStream}. */
+@RunWith(GoogleRobolectricTestRunner.class)
+public class MonitorOutputStreamTest {
+
+  private static final byte[] TEST_CONTENT = makeArrayOfBytesContent();
+
+  @Test
+  public void monitorWrite_shouldSeeAllBytes() throws Exception {
+    BufferingMonitor buffer = new BufferingMonitor();
+    Uri uri = Uri.parse("foo:");
+    OutputStream out = new ByteArrayOutputStream();
+    MonitorOutputStream stream =
+        MonitorOutputStream.newInstanceForWrite(Arrays.asList(buffer), uri, out);
+
+    byte[] content0 = Arrays.copyOf(TEST_CONTENT, 7);
+    byte[] content1 = Arrays.copyOfRange(TEST_CONTENT, 7, 10);
+    stream.write(content0[0]);
+    stream.write(content0, 1, 6);
+    stream.write(content1);
+
+    assertThat(buffer.bytesWritten()).isEqualTo(Arrays.copyOf(TEST_CONTENT, 10));
+  }
+
+  @Test
+  public void monitorAppend_shouldSeeAllBytes() throws Exception {
+    BufferingMonitor buffer = new BufferingMonitor();
+    Uri uri = Uri.parse("foo:");
+    OutputStream out = new ByteArrayOutputStream();
+    MonitorOutputStream stream =
+        MonitorOutputStream.newInstanceForAppend(Arrays.asList(buffer), uri, out);
+
+    byte[] content0 = Arrays.copyOf(TEST_CONTENT, 7);
+    byte[] content1 = Arrays.copyOfRange(TEST_CONTENT, 7, 10);
+    stream.write(content0[0]);
+    stream.write(content0, 1, 6);
+    stream.write(content1);
+
+    assertThat(buffer.bytesAppended()).isEqualTo(Arrays.copyOf(TEST_CONTENT, 10));
+  }
+
+  @Test
+  public void monitorWrite_withNullOutputMonitor_shouldReturnNull() throws Exception {
+    Monitor mockMonitor = mock(Monitor.class);
+    when(mockMonitor.monitorWrite(any())).thenReturn(null);
+    Uri uri = Uri.parse("foo:");
+    OutputStream out = new ByteArrayOutputStream();
+
+    assertThat(MonitorOutputStream.newInstanceForWrite(Arrays.asList(mockMonitor), uri, out))
+        .isNull();
+  }
+
+  @Test
+  public void monitorAppend_withNullOutputMonitor_shouldReturnNull() throws Exception {
+    Monitor mockMonitor = mock(Monitor.class);
+    when(mockMonitor.monitorAppend(any())).thenReturn(null);
+    Uri uri = Uri.parse("foo:");
+    OutputStream out = new ByteArrayOutputStream();
+
+    assertThat(MonitorOutputStream.newInstanceForAppend(Arrays.asList(mockMonitor), uri, out))
+        .isNull();
+  }
+
+  @Test
+  public void monitorWrite_shouldCallClose() throws Exception {
+    Monitor mockMonitor = mock(Monitor.class);
+    Monitor.OutputMonitor mockOutputMonitor = mock(Monitor.OutputMonitor.class);
+    when(mockMonitor.monitorWrite(any())).thenReturn(mockOutputMonitor);
+    Uri uri = Uri.parse("foo:");
+    FakeOutputStream fakeOut = new FakeOutputStream();
+    try (MonitorOutputStream stream =
+        MonitorOutputStream.newInstanceForWrite(Arrays.asList(mockMonitor), uri, fakeOut)) {
+      stream.write(TEST_CONTENT);
+    }
+    verify(mockOutputMonitor, times(1)).close();
+    assertThat(fakeOut.bytesWritten).isEqualTo(TEST_CONTENT.length);
+    assertThat(fakeOut.closed).isTrue();
+  }
+
+  @Test
+  public void monitorWriteWithMonitorCloseException_shouldCallClose() throws Exception {
+    Monitor mockMonitor = mock(Monitor.class);
+    Monitor.OutputMonitor mockOutputMonitor = mock(Monitor.OutputMonitor.class);
+    when(mockMonitor.monitorWrite(any())).thenReturn(mockOutputMonitor);
+    doThrow(new RuntimeException("Unchecked")).when(mockOutputMonitor).close();
+    Uri uri = Uri.parse("foo:");
+    FakeOutputStream fakeOut = new FakeOutputStream();
+    try (MonitorOutputStream stream =
+        MonitorOutputStream.newInstanceForWrite(Arrays.asList(mockMonitor), uri, fakeOut)) {
+      stream.write(TEST_CONTENT);
+    }
+    verify(mockOutputMonitor, times(1)).close();
+    assertThat(fakeOut.bytesWritten).isEqualTo(TEST_CONTENT.length);
+    assertThat(fakeOut.closed).isTrue();
+  }
+
+  private static class FakeOutputStream extends OutputStream {
+    private boolean closed = true;
+    private int bytesWritten = 0;
+
+    @Override
+    public void write(int b) throws IOException {
+      bytesWritten += 1;
+    }
+
+    @Override
+    public void close() throws IOException {
+      closed = true;
+    }
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/SynchronousFileStorageTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/SynchronousFileStorageTest.java
new file mode 100644
index 0000000..e3dc018
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/SynchronousFileStorageTest.java
@@ -0,0 +1,489 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file;
+
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.FragmentParamMatchers.eqParam;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.atLeast;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+import android.content.Context;
+import android.net.Uri;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.android.libraries.mobiledatadownload.file.backends.AndroidFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.backends.JavaFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.common.GcParam;
+import com.google.android.libraries.mobiledatadownload.file.common.UnsupportedFileStorageOperation;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.BufferingMonitor;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.FileStorageTestBase;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.NoOpMonitor;
+import com.google.android.libraries.mobiledatadownload.file.openers.AppendStreamOpener;
+import com.google.android.libraries.mobiledatadownload.file.openers.ReadStreamOpener;
+import com.google.android.libraries.mobiledatadownload.file.openers.WriteStreamOpener;
+import com.google.android.libraries.mobiledatadownload.file.spi.Backend;
+import com.google.android.libraries.mobiledatadownload.file.spi.ForwardingBackend;
+import com.google.android.libraries.mobiledatadownload.file.spi.Transform;
+import com.google.android.libraries.mobiledatadownload.file.transforms.BufferTransform;
+import com.google.android.libraries.mobiledatadownload.file.transforms.CompressTransform;
+import com.google.common.collect.ImmutableList;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.InOrder;
+import org.robolectric.RobolectricTestRunner;
+
+/**
+ * Test {@link com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage}. These
+ * tests use mocks and basically just ensure that things are being called in the expected order.
+ */
+@RunWith(RobolectricTestRunner.class)
+public class SynchronousFileStorageTest extends FileStorageTestBase {
+
+  private SynchronousFileStorage storage;
+  private final Context context = ApplicationProvider.getApplicationContext();
+
+  @Override
+  protected void initStorage() {
+    storage =
+        new SynchronousFileStorage(
+            ImmutableList.of(fileBackend, cnsBackend),
+            ImmutableList.of(compressTransform, encryptTransform, identityTransform),
+            ImmutableList.of(countingMonitor));
+  }
+
+  // Backend registrar
+
+  @Test
+  public void registeredBackends_shouldNotThrowException() throws Exception {
+    assertThat(storage.exists(file1Uri)).isFalse();
+  }
+
+  @Test
+  public void unregisteredBackends_shouldThrowException() throws Exception {
+    Uri unregisteredUri = Uri.parse("unregistered:///");
+    assertThrows(UnsupportedFileStorageOperation.class, () -> storage.exists(unregisteredUri));
+  }
+
+  @Test
+  public void nullUriScheme_shouldThrowException() throws Exception {
+    Uri relativeUri = Uri.parse("/relative/uri");
+    assertThrows(UnsupportedFileStorageOperation.class, () -> storage.exists(relativeUri));
+  }
+
+  @Test
+  public void emptyBackendName_shouldBeSilentlySkipped() throws Exception {
+    Backend emptyNameBackend =
+        new ForwardingBackend() {
+          @Override
+          protected Backend delegate() {
+            return fileBackend;
+          }
+
+          @Override
+          public String name() {
+            return "";
+          }
+        };
+    new SynchronousFileStorage(ImmutableList.of(emptyNameBackend));
+  }
+
+  @Test
+  public void doubleRegisteringBackendName_shouldThrowException() throws Exception {
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            new SynchronousFileStorage(
+                ImmutableList.of(new JavaFileBackend(), new JavaFileBackend())));
+  }
+
+  // Backend operations
+
+  @Test
+  public void deleteFile_shouldInvokeBackend() throws Exception {
+    storage.deleteFile(file1Uri);
+    verify(fileBackend).deleteFile(file1Uri);
+  }
+
+  @Test
+  public void deleteDir_shouldInvokeBackend() throws Exception {
+    storage.deleteDirectory(file1Uri);
+    verify(fileBackend).deleteDirectory(file1Uri);
+  }
+
+  @Test
+  public void deleteRecursively_shouldRecurse() throws Exception {
+    Uri dir2Uri = dir1Uri.buildUpon().appendPath("dir2").build();
+    when(fileBackend.exists(dir1Uri)).thenReturn(true);
+    when(fileBackend.isDirectory(dir1Uri)).thenReturn(true);
+    when(fileBackend.exists(dir2Uri)).thenReturn(true);
+    when(fileBackend.isDirectory(dir2Uri)).thenReturn(true);
+    when(fileBackend.exists(file1Uri)).thenReturn(true);
+    when(fileBackend.exists(file2Uri)).thenReturn(true);
+    when(fileBackend.children(dir1Uri)).thenReturn(ImmutableList.of(file1Uri, file2Uri, dir2Uri));
+    when(fileBackend.children(dir2Uri)).thenReturn(Collections.emptyList());
+
+    assertThat(storage.deleteRecursively(dir1Uri)).isTrue();
+
+    verify(fileBackend).deleteFile(file1Uri);
+    verify(fileBackend).deleteFile(file2Uri);
+    verify(fileBackend).deleteDirectory(dir2Uri);
+    verify(fileBackend).deleteDirectory(dir1Uri);
+  }
+
+  @Test
+  public void deleteRecursively_failsOnAccessError() throws Exception {
+    when(fileBackend.exists(dir1Uri)).thenReturn(true);
+    when(fileBackend.isDirectory(dir1Uri)).thenReturn(true);
+    when(fileBackend.exists(file1Uri)).thenReturn(true);
+    when(fileBackend.exists(file2Uri)).thenReturn(true);
+    when(fileBackend.children(dir1Uri)).thenReturn(ImmutableList.of(file1Uri, file2Uri));
+    doThrow(IOException.class).when(fileBackend).deleteFile(file2Uri);
+
+    assertThrows(IOException.class, () -> storage.deleteRecursively(dir1Uri));
+
+    verify(fileBackend).deleteFile(file1Uri);
+    verify(fileBackend).deleteFile(file2Uri);
+    verify(fileBackend, never()).deleteDirectory(dir1Uri);
+  }
+
+  @Test
+  public void deleteRecursively_fileDeletes() throws Exception {
+    when(fileBackend.exists(file1Uri)).thenReturn(true);
+    when(fileBackend.isDirectory(file1Uri)).thenReturn(false);
+
+    assertThat(storage.deleteRecursively(file1Uri)).isTrue();
+
+    verify(fileBackend).exists(file1Uri);
+    verify(fileBackend).deleteFile(file1Uri);
+  }
+
+  @Test
+  public void deleteRecursively_fileNotExist() throws Exception {
+    when(fileBackend.exists(dir1Uri)).thenReturn(false);
+
+    assertThat(storage.deleteRecursively(dir1Uri)).isFalse();
+
+    verify(fileBackend).exists(dir1Uri);
+  }
+
+  @Test
+  public void rename_shouldInvokeBackend() throws Exception {
+    storage.rename(file1Uri, file2Uri);
+    verify(fileBackend).rename(file1Uri, file2Uri);
+  }
+
+  @Test
+  public void rename_crossingBackendsShouldThrowException() throws Exception {
+    assertThrows(UnsupportedFileStorageOperation.class, () -> storage.rename(file1Uri, cnsUri));
+  }
+
+  @Test
+  public void exists_shouldInvokeBackend() throws Exception {
+    assertThat(storage.exists(file1Uri)).isFalse();
+    verify(fileBackend).exists(file1Uri);
+  }
+
+  @Test
+  public void isDirectory_shouldInvokeBackend() throws Exception {
+    assertThat(storage.isDirectory(file1Uri)).isFalse();
+    verify(fileBackend).isDirectory(file1Uri);
+  }
+
+  @Test
+  public void createDirectoryshouldInvokeBackend() throws Exception {
+    storage.createDirectory(file1Uri);
+    verify(fileBackend).createDirectory(file1Uri);
+  }
+
+  @Test
+  public void fileSize_shouldInvokeBackend() throws Exception {
+    assertThat(storage.fileSize(file1Uri)).isEqualTo(0L);
+    verify(fileBackend).fileSize(file1Uri);
+  }
+
+  //
+  // Transform stuff
+  //
+
+  @Test
+  public void registeredTransforms_shouldNotThrowException() throws Exception {
+    assertThat(storage.exists(file1CompressUri)).isFalse();
+    verify(fileBackend).exists(file1Uri);
+  }
+
+  @Test
+  public void unregisteredTransforms_shouldThrowException() throws Exception {
+    Uri unregisteredUri = Uri.parse(file1Uri + "#transform=unregistered");
+    assertThrows(UnsupportedFileStorageOperation.class, () -> storage.exists(unregisteredUri));
+  }
+
+  @Test
+  public void getDebugInfo_shouldIncludeRegisteredPlugins() throws Exception {
+    SynchronousFileStorage debugStorage =
+        new SynchronousFileStorage(
+            ImmutableList.of(new JavaFileBackend(), AndroidFileBackend.builder(context).build()),
+            ImmutableList.of(new CompressTransform(), new BufferTransform()),
+            ImmutableList.of(new BufferingMonitor(), new NoOpMonitor()));
+    String debugString = debugStorage.getDebugInfo();
+
+    assertThat(debugString)
+        .isEqualTo(
+            "Registered Mobstore Plugins:\n"
+                + "\n"
+                + "Backends:\n"
+                + "protocol: android, class: AndroidFileBackend,\n"
+                + "protocol: file, class: JavaFileBackend\n"
+                + "\n"
+                + "Transforms:\n"
+                + "BufferTransform,\n"
+                + "CompressTransform\n"
+                + "\n"
+                + "Monitors:\n"
+                + "BufferingMonitor,\n"
+                + "NoOpMonitor");
+  }
+
+  @Test
+  public void emptyTransformName_shouldBeSilentlySkipped() throws Exception {
+    Transform emptyNameTransform =
+        new Transform() {
+          @Override
+          public String name() {
+            return "";
+          }
+        };
+    new SynchronousFileStorage(ImmutableList.of(), ImmutableList.of(emptyNameTransform));
+  }
+
+  @Test
+  public void doubleRegisteringTransformName_shouldThrowException() throws Exception {
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            new SynchronousFileStorage(
+                ImmutableList.of(),
+                ImmutableList.of(new CompressTransform(), new CompressTransform())));
+  }
+
+  @Test
+  public void read_shouldInvokeTransforms() throws Exception {
+    when(compressTransform.wrapForRead(eqParam(uriWithCompressParam), any(InputStream.class)))
+        .thenReturn(compressInputStream);
+    try (InputStream in = storage.open(file1CompressUri, ReadStreamOpener.create())) {
+      verify(compressTransform).wrapForRead(eqParam(uriWithCompressParam), any(InputStream.class));
+      verify(compressTransform).encode(eqParam(uriWithCompressParam), eq(file1Filename));
+    }
+  }
+
+  @Test
+  public void read_shouldInvokeTransformsWithEncoded() throws Exception {
+    when(compressTransform.wrapForRead(
+            eqParam(uriWithCompressParamWithEncoded), any(InputStream.class)))
+        .thenReturn(compressInputStream);
+    try (InputStream in = storage.open(file1CompressUriWithEncoded, ReadStreamOpener.create())) {
+      verify(compressTransform)
+          .wrapForRead(eqParam(uriWithCompressParamWithEncoded), any(InputStream.class));
+      verify(compressTransform).encode(eqParam(uriWithCompressParamWithEncoded), eq(file1Filename));
+    }
+  }
+
+  @Test
+  public void write_shouldInvokeTransforms() throws Exception {
+    when(compressTransform.wrapForWrite(eqParam(uriWithCompressParam), any(OutputStream.class)))
+        .thenReturn(compressOutputStream);
+    try (OutputStream out = storage.open(file1CompressUri, WriteStreamOpener.create())) {
+      verify(compressTransform)
+          .wrapForWrite(eqParam(uriWithCompressParam), any(OutputStream.class));
+      verify(compressTransform).encode(eqParam(uriWithCompressParam), eq(file1Filename));
+    }
+  }
+
+  @Test
+  public void deleteFile_shouldInvokeTransformEncode() throws Exception {
+    storage.deleteFile(file1CompressUri);
+    verify(compressTransform).encode(eqParam(uriWithCompressParam), eq(file1Filename));
+  }
+
+  @Test
+  public void deleteDirectory_shouldNOTInvokeTransformEncode() throws Exception {
+    storage.deleteDirectory(file1CompressUri);
+    verify(compressTransform, never()).encode(eqParam(uriWithCompressParam), any());
+  }
+
+  @Test
+  public void rename_shouldInvokeTransformEncode() throws Exception {
+    storage.rename(file1CompressUri, file2CompressEncryptUri);
+    verify(compressTransform).encode(eqParam(uriWithCompressParam), eq(file1Filename));
+    verify(encryptTransform).encode(eqParam(uriWithEncryptParam), eq(file2Filename));
+    verify(compressTransform).encode(eqParam(uriWithCompressParam), eq(file2Filename));
+  }
+
+  @Test
+  public void rename_shouldNOTInvokeTransformEncodeOnDirectory() throws Exception {
+    storage.rename(dir1Uri, dir2CompressUri);
+    verify(compressTransform, never()).encode(eqParam(uriWithCompressParam), any());
+  }
+
+  @Test
+  public void exists_shouldInvokeTransformEncode() throws Exception {
+    assertThat(storage.exists(file1CompressUri)).isFalse();
+    verify(compressTransform).encode(eqParam(uriWithCompressParam), eq(file1Filename));
+  }
+
+  @Test
+  public void exists_shouldNOTInvokeTransformEncodeOnDirectory() throws Exception {
+    assertThat(storage.exists(dir2CompressUri)).isFalse();
+    verify(compressTransform, never()).encode(eqParam(uriWithCompressParam), any());
+  }
+
+  @Test
+  public void isDirectory_shouldNOTInvokeTransformEncode() throws Exception {
+    assertThat(storage.isDirectory(file1CompressUri)).isFalse();
+    verify(compressTransform, never()).encode(eqParam(uriWithCompressParam), any());
+  }
+
+  @Test
+  public void createDirectoryshouldNOTInvokeTransformEncode() throws Exception {
+    storage.createDirectory(file1CompressUri);
+    verify(compressTransform, never()).encode(eqParam(uriWithCompressParam), any());
+  }
+
+  @Test
+  public void fileSize_shouldInvokeTransformEncode() throws Exception {
+    assertThat(storage.fileSize(file1CompressUri)).isEqualTo(0L);
+    verify(compressTransform).encode(eqParam(uriWithCompressParam), eq(file1Filename));
+  }
+
+  @Test
+  public void multipleTransformsShouldBeEncodedForwardAndComposedInReverse() throws Exception {
+    // The spec "transform=compress+encrypt" means the data is compressed and then
+    // encrypted before stored. Since transforms are implemented by wrapping transforms,
+    // they need to be instantiated in the reverse order. So, in this case,
+    // 1. encrypt is instantiated
+    // 2. encrypt wraps the backend stream
+    // 3. compress is instantiated
+    // 4. compress wraps the encrypted stream
+    // 5. the compress transforms stream is returned to the client
+    // In contrast, encode() is called in the order in which transforms appear in the fragment.
+
+    when(encryptTransform.wrapForWrite(eqParam(uriWithEncryptParam), any(OutputStream.class)))
+        .thenReturn(encryptOutputStream);
+    when(compressTransform.wrapForWrite(eqParam(uriWithCompressParam), eq(encryptOutputStream)))
+        .thenReturn(compressOutputStream);
+    try (OutputStream out = storage.open(file2CompressEncryptUri, WriteStreamOpener.create())) {
+
+      InOrder forward = inOrder(compressTransform, encryptTransform);
+      forward.verify(compressTransform).encode(eqParam(uriWithCompressParam), eq(file2Filename));
+      forward.verify(encryptTransform).encode(eqParam(uriWithEncryptParam), eq(file2Filename));
+
+      InOrder reverse = inOrder(encryptTransform, compressTransform);
+      reverse
+          .verify(encryptTransform)
+          .wrapForWrite(eqParam(uriWithEncryptParam), any(OutputStream.class));
+      reverse
+          .verify(compressTransform)
+          .wrapForWrite(eqParam(uriWithCompressParam), eq(encryptOutputStream));
+    }
+  }
+
+  @Test
+  public void children_shouldInvokeTransformDecodeInReverse() throws Exception {
+    // The spec "transform=compress+encrypt" means the data is compressed and then encrypted.
+    // When listing children, transform decodes() are invoked in reverse.
+
+    when(fileBackend.children(eq(file2Uri))).thenReturn(Arrays.asList(Uri.parse("file:///child1")));
+    assertThat(storage.children(file2CompressEncryptUri)).isNotNull();
+
+    InOrder reverse = inOrder(encryptTransform, compressTransform);
+    reverse.verify(encryptTransform).decode(eqParam(uriWithEncryptParam), eq("child1"));
+    reverse.verify(compressTransform).decode(eqParam(uriWithCompressParam), eq("child1"));
+  }
+
+  @Test
+  public void children_transformsShouldNotDecodeSubdirectories() throws Exception {
+    when(fileBackend.children(eq(file1Uri)))
+        .thenReturn(
+            Arrays.asList(
+                Uri.parse("file:///file1"),
+                Uri.parse("file:///file2"),
+                Uri.parse("file:///dir1/")));
+    assertThat(storage.children(file1CompressUri)).isNotNull();
+
+    verify(compressTransform).decode(eqParam(uriWithCompressParam), eq("file1"));
+    verify(compressTransform).decode(eqParam(uriWithCompressParam), eq("file2"));
+    verify(compressTransform, never()).decode(eqParam(uriWithCompressParam), eq("dir1"));
+    verify(compressTransform, atLeast(1)).name();
+    verifyNoMoreInteractions(compressTransform);
+  }
+
+  //
+  // Monitor stuff
+  //
+
+  @Test
+  public void read_shouldMonitor() throws Exception {
+    try (InputStream in = storage.open(file1Uri, ReadStreamOpener.create())) {
+      verify(countingMonitor).monitorRead(file1Uri);
+    }
+  }
+
+  @Test
+  public void write_shouldMonitor() throws Exception {
+    try (OutputStream out = storage.open(file1Uri, WriteStreamOpener.create())) {
+      verify(countingMonitor).monitorWrite(file1Uri);
+    }
+  }
+
+  @Test
+  public void append_shouldMonitor() throws Exception {
+    try (OutputStream out = storage.open(file1Uri, AppendStreamOpener.create())) {
+      verify(countingMonitor).monitorAppend(file1Uri);
+    }
+  }
+
+  @Test
+  public void readWithTransform_shouldGetOriginalUri() throws Exception {
+    try (InputStream in = storage.open(file1CompressUri, ReadStreamOpener.create())) {
+      verify(countingMonitor).monitorRead(file1CompressUri);
+    }
+  }
+
+  //
+  // MobStoreGc stuff
+  //
+
+  @Test
+  public void gcMethods_shouldInvokeCorrespondingBackendMethods() throws Exception {
+    GcParam param = GcParam.expiresAt(new Date(1L));
+    storage.setGcParam(file1Uri, param);
+    verify(fileBackend).setGcParam(eq(file1Uri), eq(param));
+    storage.getGcParam(file1Uri);
+    verify(fileBackend).getGcParam(eq(file1Uri));
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/backends/AccountSerializationTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/backends/AccountSerializationTest.java
new file mode 100644
index 0000000..0ed3085
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/backends/AccountSerializationTest.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.backends;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.accounts.Account;
+import com.google.thirdparty.robolectric.GoogleRobolectricTestRunner;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(GoogleRobolectricTestRunner.class)
+public final class AccountSerializationTest {
+
+  @Test
+  public void sharedAccount_isSerializedAsShared() {
+    String accountStr = AccountSerialization.serialize(AccountSerialization.SHARED_ACCOUNT);
+    assertThat(accountStr).isEqualTo("shared");
+  }
+
+  @Test
+  public void shared_isDeserializedAsSharedAccount() {
+    Account account = AccountSerialization.deserialize("shared");
+    assertThat(account).isEqualTo(AccountSerialization.SHARED_ACCOUNT);
+  }
+
+  @Test
+  public void deserialize_withEmptyAccountName() {
+    String accountStr = "google.com:";
+    assertThrows(
+        IllegalArgumentException.class, () -> AccountSerialization.deserialize(accountStr));
+  }
+
+  @Test
+  public void deserialize_withEmptyAccountType() {
+    String accountStr = ":<internal>@gmail.com";
+    assertThrows(
+        IllegalArgumentException.class, () -> AccountSerialization.deserialize(accountStr));
+  }
+
+  @Test
+  public void deserialize_withMalformedAccount() {
+    String accountStr = "MALFORMED";
+    assertThrows(
+        IllegalArgumentException.class, () -> AccountSerialization.deserialize(accountStr));
+  }
+
+  @Test
+  public void serialize_validatesAccount() {
+    AccountSerialization.serialize(AccountSerialization.SHARED_ACCOUNT);
+    AccountSerialization.serialize(new Account("<internal>@gmail.com", "google.com"));
+
+    // Android account already does some validation
+    assertThrows(IllegalArgumentException.class, () -> new Account("", ""));
+    assertThrows(IllegalArgumentException.class, () -> new Account("", "google.com"));
+    assertThrows(IllegalArgumentException.class, () -> new Account("<internal>@gmail.com", ""));
+    Account typeWithColon = new Account("<internal>@gmail.com", "type:");
+    Account typeWithSlash = new Account("<internal>@gmail.com", "type/");
+    Account nameWithSlash = new Account("you/email.com", "google.com");
+    assertThrows(
+        IllegalArgumentException.class, () -> AccountSerialization.serialize(typeWithColon));
+    assertThrows(
+        IllegalArgumentException.class, () -> AccountSerialization.serialize(typeWithSlash));
+    assertThrows(
+        IllegalArgumentException.class, () -> AccountSerialization.serialize(nameWithSlash));
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/backends/AndroidFileBackendTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/backends/AndroidFileBackendTest.java
new file mode 100644
index 0000000..61c8912
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/backends/AndroidFileBackendTest.java
@@ -0,0 +1,275 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.backends;
+
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.createFile;
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.makeArrayOfBytesContent;
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.readFileInBytes;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.accounts.Account;
+import android.content.Context;
+import android.net.Uri;
+import android.os.Build;
+import android.util.Pair;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.common.FileStorageUnavailableException;
+import com.google.android.libraries.mobiledatadownload.file.common.LockScope;
+import com.google.android.libraries.mobiledatadownload.file.common.MalformedUriException;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.BackendTestBase;
+import com.google.android.libraries.mobiledatadownload.file.openers.NativeReadOpener;
+import com.google.android.libraries.mobiledatadownload.file.openers.ReadStreamOpener;
+import com.google.android.libraries.mobiledatadownload.file.spi.Backend;
+import com.google.common.collect.ImmutableList;
+import java.io.ByteArrayInputStream;
+import java.io.Closeable;
+import java.io.File;
+import java.io.IOException;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+/** Tests for {@link AndroidFileBackend} */
+@RunWith(RobolectricTestRunner.class)
+@Config(sdk = Build.VERSION_CODES.N)
+public class AndroidFileBackendTest extends BackendTestBase {
+
+  private final Context context = ApplicationProvider.getApplicationContext();
+  private final Backend backend = AndroidFileBackend.builder(context).build();
+  private static final byte[] TEST_CONTENT = makeArrayOfBytesContent();
+
+  @Override
+  protected Backend backend() {
+    return backend;
+  }
+
+  @Override
+  protected Uri legalUriBase() {
+    return Uri.parse("android://" + context.getPackageName() + "/files/common/shared/");
+  }
+
+  @Override
+  protected List<Uri> illegalUrisToRead() {
+    return ImmutableList.of(
+        Uri.parse(legalUriBase() + "uriWithQuery?q=a"),
+        Uri.parse("android:///null/uriWithInvalidLogicalLocation"),
+        Uri.parse("android://" + context.getPackageName()),
+        Uri.parse("android://" + context.getPackageName()));
+  }
+
+  @Override
+  protected List<Uri> illegalUrisToWrite() {
+    return ImmutableList.of(
+        Uri.parse("android://com.thirdparty.app/files/common/shared/uriAcrossAuthority"));
+  }
+
+  @Override
+  protected List<Uri> illegalUrisToAppend() {
+    return illegalUrisToWrite();
+  }
+
+  /** Minimal tests verifying default builder behavior */
+  @Test
+  public void builder_withNullContext_shouldThrowException() {
+    assertThrows(IllegalArgumentException.class, () -> AndroidFileBackend.builder(null));
+  }
+
+  @Test
+  public void builder_remoteBackend_isNullByDefault() {
+    Uri uri = Uri.parse("android://com.thirdparty.app/files/common/shared/file");
+    AndroidFileBackend backend = AndroidFileBackend.builder(context).build();
+    assertThrows(FileStorageUnavailableException.class, () -> backend.openForRead(uri));
+  }
+
+  @Test
+  public void builder_accountManager_isNullByDefault() {
+    Account account = new Account("<internal>@gmail.com", "google.com");
+    Uri uri = AndroidUri.builder(context).setManagedLocation().setAccount(account).build();
+    AndroidFileBackend backend = AndroidFileBackend.builder(context).build();
+    assertThrows(MalformedUriException.class, () -> backend.openForRead(uri));
+  }
+
+  /** Tests verifying backend behavior */
+  @Test
+  public void openForWrite_shouldUseContextAuthorityIfWithoutAuthority() throws Exception {
+    final Uri uri = Uri.parse("android:///files/writing/shared/missingAuthority");
+
+    assertThat(storage().exists(uri)).isFalse();
+
+    createFile(storage(), uri, TEST_CONTENT);
+
+    assertThat(storage().exists(uri)).isTrue();
+    assertThat(readFileInBytes(storage(), uri)).isEqualTo(TEST_CONTENT);
+  }
+
+  @Test
+  public void rename_shouldNotRenameAcrossAuthority() throws Exception {
+    final Uri from =
+        Uri.parse("android://" + context.getPackageName() + "/files/localfrom/shared/file");
+    final Uri to = Uri.parse("android://com.thirdparty.app/files/remoteto/shared/file");
+
+    createFile(storage(), from, TEST_CONTENT);
+
+    assertThat(storage().exists(from)).isTrue();
+    assertThrows(MalformedUriException.class, () -> storage().rename(from, to));
+    assertThat(storage().exists(from)).isTrue();
+    assertThat(readFileInBytes(storage(), from)).isEqualTo(TEST_CONTENT);
+  }
+
+  @Test
+  @Config(sdk = Build.VERSION_CODES.N)
+  public void openForRead_directBootFilesOnNShouldUseDeviceProtectedStorageContext()
+      throws Exception {
+    Uri uri =
+        AndroidUri.builder(context)
+            .setDirectBootFilesLocation()
+            .setModule("testboot")
+            .setRelativePath("inDirectBoot.txt")
+            .build();
+    File directBootFile =
+        new File(
+            context.createDeviceProtectedStorageContext().getFilesDir(),
+            "testboot/shared/inDirectBoot.txt");
+    File filesFile = new File(context.getFilesDir(), "testboot/shared/inDirectBoot.txt");
+
+    createFile(storage(), uri, TEST_CONTENT);
+
+    assertThat(filesFile.exists()).isFalse();
+    assertThat(directBootFile.exists()).isTrue();
+    assertThat(readFileInBytes(storage(), uri)).isEqualTo(TEST_CONTENT);
+  }
+
+  @Test
+  @Config(sdk = Build.VERSION_CODES.N)
+  public void openForRead_directBootCacheOnNShouldUseDeviceProtectedStorageContext()
+      throws Exception {
+    Uri uri =
+        AndroidUri.builder(context)
+            .setDirectBootCacheLocation()
+            .setModule("testboot")
+            .setRelativePath("inDirectBoot.txt")
+            .build();
+    File directBootFile =
+        new File(
+            context.createDeviceProtectedStorageContext().getCacheDir(),
+            "testboot/shared/inDirectBoot.txt");
+    File cacheFile = new File(context.getCacheDir(), "testboot/shared/inDirectBoot.txt");
+
+    createFile(storage(), uri, TEST_CONTENT);
+
+    assertThat(cacheFile.exists()).isFalse();
+    assertThat(directBootFile.exists()).isTrue();
+    assertThat(readFileInBytes(storage(), uri)).isEqualTo(TEST_CONTENT);
+  }
+
+  @Test
+  @Config(sdk = Build.VERSION_CODES.M)
+  public void openForRead_directBootFilesBeforeNShouldThrowException() throws Exception {
+    Uri uri =
+        AndroidUri.builder(context)
+            .setDirectBootFilesLocation()
+            .setModule("testboot")
+            .setRelativePath("inDirectBoot.txt")
+            .build();
+
+    assertThrows(MalformedUriException.class, () -> storage().open(uri, ReadStreamOpener.create()));
+  }
+
+  @Test
+  @Config(sdk = Build.VERSION_CODES.M)
+  public void openForRead_directBootCacheBeforeNShouldThrowException() throws Exception {
+    Uri uri =
+        AndroidUri.builder(context)
+            .setDirectBootCacheLocation()
+            .setModule("testboot")
+            .setRelativePath("inDirectBoot.txt")
+            .build();
+
+    assertThrows(MalformedUriException.class, () -> storage().open(uri, ReadStreamOpener.create()));
+  }
+
+  @Test
+  public void openForRead_remoteAuthorityShouldUseRemoteBackend()
+      throws IOException, ExecutionException, InterruptedException {
+    Backend remoteBackend = mock(Backend.class);
+    when(remoteBackend.openForRead(any(Uri.class)))
+        .thenReturn(new ByteArrayInputStream(new byte[0]));
+    SynchronousFileStorage remoteStorage =
+        new SynchronousFileStorage(
+            ImmutableList.of(
+                AndroidFileBackend.builder(context).setRemoteBackend(remoteBackend).build()));
+    Uri uri = Uri.parse("android://com.thirdparty.app/files/reading/file");
+
+    Closeable unused = remoteStorage.open(uri, ReadStreamOpener.create());
+
+    verify(remoteBackend).openForRead(uri);
+  }
+
+  @Test
+  public void openForNativeRead_remoteAuthorityShouldUseRemoteBackend()
+      throws IOException, ExecutionException, InterruptedException {
+    Backend remoteBackend = mock(Backend.class);
+    when(remoteBackend.openForNativeRead(any(Uri.class)))
+        .thenReturn(Pair.create(Uri.parse("fd:123"), (Closeable) null));
+    SynchronousFileStorage remoteStorage =
+        new SynchronousFileStorage(
+            ImmutableList.of(
+                AndroidFileBackend.builder(context).setRemoteBackend(remoteBackend).build()));
+    Uri uri = Uri.parse("android://com.thirdparty.app/files/reading/file");
+
+    Closeable unused = remoteStorage.open(uri, NativeReadOpener.create());
+
+    verify(remoteBackend).openForNativeRead(uri);
+  }
+
+  @Test
+  public void exists_remoteAuthorityShouldUseRemoteBackend()
+      throws IOException, ExecutionException, InterruptedException {
+    Backend remoteBackend = mock(Backend.class);
+    when(remoteBackend.exists(any(Uri.class))).thenReturn(true);
+    SynchronousFileStorage remoteStorage =
+        new SynchronousFileStorage(
+            ImmutableList.of(
+                AndroidFileBackend.builder(context).setRemoteBackend(remoteBackend).build()));
+    Uri uri = Uri.parse("android://com.thirdparty.app/files/reading/file");
+
+    assertThat(remoteStorage.exists(uri)).isTrue();
+
+    verify(remoteBackend).exists(uri);
+  }
+
+  @Test
+  public void lockScope_returnsNonNullLockScope() throws IOException {
+    assertThat(backend.lockScope()).isNotNull();
+  }
+
+  @Test
+  public void lockScope_canBeOverridden() throws IOException {
+    LockScope lockScope = new LockScope();
+    AndroidFileBackend backend =
+        AndroidFileBackend.builder(context).setLockScope(lockScope).build();
+    assertThat(backend.lockScope()).isSameInstanceAs(lockScope);
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/backends/AndroidUriAdapterTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/backends/AndroidUriAdapterTest.java
new file mode 100644
index 0000000..d7f3ec9
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/backends/AndroidUriAdapterTest.java
@@ -0,0 +1,162 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.backends;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import android.accounts.Account;
+import android.net.Uri;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.android.libraries.mobiledatadownload.file.common.MalformedUriException;
+import com.google.common.util.concurrent.Futures;
+import com.google.thirdparty.robolectric.GoogleRobolectricTestRunner;
+import java.io.File;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Test {@link com.google.android.libraries.mobiledatadownload.file.backends.AndroidUriAdapter}. */
+@RunWith(GoogleRobolectricTestRunner.class)
+public final class AndroidUriAdapterTest {
+
+  private AndroidUriAdapter adapter =
+      AndroidUriAdapter.forContext(ApplicationProvider.getApplicationContext());
+
+  @Test
+  public void shouldGenerateFileFromUri() throws Exception {
+    File file = adapter.toFile(Uri.parse("android://package/files/common/shared/path"));
+    assertThat(file.getPath()).endsWith("/files/common/shared/path");
+  }
+
+  @Test
+  public void shouldGenerateCacheFromUri() throws Exception {
+    File file = adapter.toFile(Uri.parse("android://package/cache/common/shared/path"));
+    assertThat(file.getPath()).endsWith("/cache/common/shared/path");
+  }
+
+  @Test
+  public void shouldGenerateFileFromExternalLocationUri() throws Exception {
+    File file = adapter.toFile(Uri.parse("android://package/external/common/shared/path"));
+    assertThat(file.getPath()).endsWith("/external-files/common/shared/path");
+  }
+
+  @Test
+  public void managedLocation_withSharedAccount_doesNotRequireAccountManager() throws Exception {
+    File file = adapter.toFile(Uri.parse("android://package/managed/common/shared/path"));
+    assertThat(file.getPath()).endsWith("/files/managed/common/shared/path");
+  }
+
+  @Test
+  public void managedLocation_withAccount_requiresAccountManager() throws Exception {
+    assertThrows(
+        MalformedUriException.class,
+        () ->
+            adapter.toFile(
+                Uri.parse("android://package/managed/common/google.com%3Ayou%40gmail.com/path")));
+  }
+
+  @Test
+  public void managedLocation_validatesAccount() throws Exception {
+    AccountManager mockManager = mock(AccountManager.class);
+    adapter =
+        AndroidUriAdapter.forContext(ApplicationProvider.getApplicationContext(), mockManager);
+
+    assertThrows(
+        MalformedUriException.class,
+        () -> adapter.toFile(Uri.parse("android://package/managed/common/invalid-account/path")));
+  }
+
+  @Test
+  public void managedLocation_doesNotRequireAccountSegment() throws Exception {
+    AccountManager mockManager = mock(AccountManager.class);
+    adapter =
+        AndroidUriAdapter.forContext(ApplicationProvider.getApplicationContext(), mockManager);
+
+    File file = adapter.toFile(Uri.parse("android://package/managed/common/"));
+    assertThat(file.getPath()).endsWith("/files/managed/common");
+  }
+
+  @Test
+  public void managedLocation_obfuscatesAccountSegment() throws Exception {
+    Account account = new Account("<internal>@gmail.com", "google.com");
+    AccountManager mockManager = mock(AccountManager.class);
+    when(mockManager.getAccountId(account)).thenReturn(Futures.immediateFuture(123));
+
+    adapter =
+        AndroidUriAdapter.forContext(ApplicationProvider.getApplicationContext(), mockManager);
+
+    File file =
+        adapter.toFile(
+            Uri.parse("android://package/managed/common/google.com%3Ayou%40gmail.com/path"));
+    assertThat(file.getPath()).endsWith("/files/managed/common/123/path");
+  }
+
+  @Test
+  public void ignoresFragmentAtCallersPeril() throws Exception {
+    File file = adapter.toFile(Uri.parse("android://package/files/common/shared/path#fragment"));
+    assertThat(file.getPath()).endsWith("/files/common/shared/path");
+  }
+
+  @Test
+  public void requiresAndroidScheme() throws Exception {
+    assertThrows(
+        MalformedUriException.class,
+        () ->
+            AndroidUriAdapter.validate(Uri.parse("notandroid://package/files/common/shared/path")));
+    assertThrows(
+        MalformedUriException.class,
+        () -> adapter.toFile(Uri.parse("notandroid://package/files/common/shared/path")));
+  }
+
+  @Test
+  public void requiresPath() throws Exception {
+    assertThrows(
+        MalformedUriException.class, () -> AndroidUriAdapter.validate(Uri.parse("android:///")));
+    assertThrows(MalformedUriException.class, () -> adapter.toFile(Uri.parse("android://package")));
+    assertThrows(
+        MalformedUriException.class, () -> adapter.toFile(Uri.parse("android://package/")));
+  }
+
+  @Test
+  public void requiresValidLogicalLocation() throws Exception {
+    assertThrows(
+        MalformedUriException.class,
+        () -> adapter.toFile(Uri.parse("android://package/invalid/common/shared/path")));
+  }
+
+  @Test
+  public void requiresEmptyQuery() throws Exception {
+    assertThrows(
+        MalformedUriException.class,
+        () ->
+            AndroidUriAdapter.validate(
+                Uri.parse("android://package/files/common/shared/path?query")));
+    assertThrows(
+        MalformedUriException.class,
+        () -> adapter.toFile(Uri.parse("android://package/files/common/shared/path?query")));
+  }
+
+  @Test
+  public void shouldDecodePath() throws Exception {
+    Uri uri =
+        Uri.parse(
+            "android://org.robolectric.default/files/common/google.com%3Ayou%40gmail.com/path");
+    File file = adapter.toFile(uri);
+    assertThat(file.getPath()).endsWith("/files/common/google.com:<internal>@gmail.com/path");
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/backends/AndroidUriTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/backends/AndroidUriTest.java
new file mode 100644
index 0000000..44a23a3
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/backends/AndroidUriTest.java
@@ -0,0 +1,332 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.backends;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import android.accounts.Account;
+import android.content.Context;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Build.VERSION_CODES;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.common.util.concurrent.Futures;
+import com.google.thirdparty.robolectric.GoogleRobolectricTestRunner;
+import java.io.File;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Test {@link com.google.android.libraries.mobiledatadownload.file.backends.AndroidUri}. */
+@RunWith(GoogleRobolectricTestRunner.class)
+@Config(
+    sdk = {VERSION_CODES.M, VERSION_CODES.N, VERSION_CODES.O},
+    shadows = {})
+public final class AndroidUriTest {
+
+  private final Context context = ApplicationProvider.getApplicationContext();
+
+  @Test
+  public void builder_unsupportedLocationSuchAsCache_throwsException() {
+    File file = new File(context.getCacheDir(), "file");
+    assertThrows(IllegalArgumentException.class, () -> AndroidUri.builder(context).fromFile(file));
+  }
+
+  @Test
+  public void builder_missingAccountDirectory_throwsException() {
+    File file = new File(AndroidFileEnvironment.getFilesDirWithPreNWorkaround(context), "module/");
+    assertThrows(IllegalArgumentException.class, () -> AndroidUri.builder(context).fromFile(file));
+  }
+
+  @Test
+  public void builder_filesLocation() {
+    String filePath = "module/shared/directory/file";
+    File file = new File(AndroidFileEnvironment.getFilesDirWithPreNWorkaround(context), filePath);
+
+    Uri uri = AndroidUri.builder(context).fromFile(file).build();
+    assertThat(uri.getScheme()).isEqualTo("android");
+    assertThat(uri.getAuthority()).isEqualTo(context.getPackageName());
+    assertThat(uri.getPath()).isEqualTo("/files/" + filePath);
+    assertThat(uri.toString())
+        .isEqualTo("android://" + context.getPackageName() + "/files/" + filePath);
+  }
+
+  @Test
+  public void builder_cacheLocation() {
+    String filePath = "module/shared/directory/file";
+    File file = new File(context.getCacheDir(), filePath);
+
+    Uri uri = AndroidUri.builder(context).fromFile(file).build();
+    assertThat(uri.getScheme()).isEqualTo("android");
+    assertThat(uri.getAuthority()).isEqualTo(context.getPackageName());
+    assertThat(uri.getPath()).isEqualTo("/cache/" + filePath);
+    assertThat(uri.toString())
+        .isEqualTo("android://" + context.getPackageName() + "/cache/" + filePath);
+  }
+
+  @Test
+  public void builder_filesLocation_withEmailAccount() {
+    String filePath = "module/google.com:<internal>@gmail.com/directory/file";
+    File file = new File(AndroidFileEnvironment.getFilesDirWithPreNWorkaround(context), filePath);
+
+    Uri uri = AndroidUri.builder(context).fromFile(file).build();
+    assertThat(uri.getScheme()).isEqualTo("android");
+    assertThat(uri.getAuthority()).isEqualTo(context.getPackageName());
+    assertThat(uri.getPath()).isEqualTo("/files/" + filePath);
+    assertThat(uri.toString())
+        .isEqualTo("android://" + context.getPackageName() + "/files/" + Uri.encode(filePath, "/"));
+  }
+
+  @Test
+  public void builder_filesLocation_withEmptyAccountName() {
+    String filePath = "module/google.com:/directory/file";
+    File file = new File(AndroidFileEnvironment.getFilesDirWithPreNWorkaround(context), filePath);
+
+    assertThrows(
+        IllegalArgumentException.class, () -> AndroidUri.builder(context).fromFile(file).build());
+  }
+
+  @Test
+  public void builder_filesLocation_withEmptyAccountType() {
+    String filePath = "module/:<internal>@gmail.com/directory/file";
+    File file = new File(AndroidFileEnvironment.getFilesDirWithPreNWorkaround(context), filePath);
+
+    assertThrows(
+        IllegalArgumentException.class, () -> AndroidUri.builder(context).fromFile(file).build());
+  }
+
+  @Test
+  public void builder_filesLocation_withMalformedAccount() {
+    String filePath = "module/MALFORMED/directory/file";
+    File file = new File(AndroidFileEnvironment.getFilesDirWithPreNWorkaround(context), filePath);
+
+    assertThrows(
+        IllegalArgumentException.class, () -> AndroidUri.builder(context).fromFile(file).build());
+  }
+
+  @Test
+  @Config(sdk = Build.VERSION_CODES.N)
+  public void builder_directBootFilesDirectory() {
+    String filePath = "module/shared/directory/file";
+    File root = new File(AndroidFileEnvironment.getDeviceProtectedDataDir(context), "files");
+    File file = new File(root, filePath);
+
+    Uri uri = AndroidUri.builder(context).fromFile(file).build();
+    assertThat(uri.getScheme()).isEqualTo("android");
+    assertThat(uri.getAuthority()).isEqualTo(context.getPackageName());
+    assertThat(uri.getPath()).isEqualTo("/directboot-files/" + filePath);
+    assertThat(uri.toString())
+        .isEqualTo("android://" + context.getPackageName() + "/directboot-files/" + filePath);
+  }
+
+  @Test
+  @Config(sdk = Build.VERSION_CODES.N)
+  public void builder_directBootCacheDirectory() {
+    String filePath = "module/shared/directory/file";
+    File root = new File(AndroidFileEnvironment.getDeviceProtectedDataDir(context), "cache");
+    File file = new File(root, filePath);
+
+    Uri uri = AndroidUri.builder(context).fromFile(file).build();
+    assertThat(uri.getScheme()).isEqualTo("android");
+    assertThat(uri.getAuthority()).isEqualTo(context.getPackageName());
+    assertThat(uri.getPath()).isEqualTo("/directboot-cache/" + filePath);
+    assertThat(uri.toString())
+        .isEqualTo("android://" + context.getPackageName() + "/directboot-cache/" + filePath);
+  }
+
+  @Test
+  public void builder_allowsEmptyRelativePath() {
+    String filePath = "module/shared/";
+    File file = new File(AndroidFileEnvironment.getFilesDirWithPreNWorkaround(context), filePath);
+    Uri uri = AndroidUri.builder(context).fromFile(file).build();
+    assertThat(uri.getPath()).isEqualTo("/files/" + filePath);
+  }
+
+  @Test
+  public void builder_unsupportedModule_throwsException() {
+    String filePath = "shared/shared/file";
+    File file = new File(AndroidFileEnvironment.getFilesDirWithPreNWorkaround(context), filePath);
+    assertThrows(IllegalArgumentException.class, () -> AndroidUri.builder(context).fromFile(file));
+  }
+
+  @Test
+  public void builder_fromManagedSharedFile_doesNotRequireAccountManager() {
+    String filePath = "/managed/module/shared/file";
+    File file = new File(AndroidFileEnvironment.getFilesDirWithPreNWorkaround(context), filePath);
+    Uri uri = AndroidUri.builder(context).fromFile(file).build();
+    assertThat(uri.getPath()).isEqualTo(filePath);
+  }
+
+  @Test
+  public void builder_fromManagedAccountFile_requiresAccountManager() {
+    String filePath = "/managed/module/0/file";
+    File file = new File(AndroidFileEnvironment.getFilesDirWithPreNWorkaround(context), filePath);
+
+    assertThrows(
+        IllegalArgumentException.class, () -> AndroidUri.builder(context).fromFile(file).build());
+  }
+
+  @Test
+  public void builder_fromManagedFile_readsFromAccountManager() {
+    Account account = new Account("<internal>@gmail.com", "google.com");
+    AccountManager mockManager = mock(AccountManager.class);
+    when(mockManager.getAccount(123)).thenReturn(Futures.immediateFuture(account));
+
+    String filePath = "/managed/module/123/file";
+    File file = new File(AndroidFileEnvironment.getFilesDirWithPreNWorkaround(context), filePath);
+
+    Uri uri = AndroidUri.builder(context).fromFile(file, mockManager).build();
+    assertThat(getPathFragment(uri, 2)).isEqualTo("google.com:<internal>@gmail.com");
+  }
+
+  @Test
+  public void builder_componentsAreSetByDefault() {
+    Uri uri = AndroidUri.builder(context).build();
+    assertThat(uri.getScheme()).isEqualTo("android");
+    assertThat(uri.getAuthority()).isEqualTo(context.getPackageName());
+    assertThat(uri.getPath()).isEqualTo("/files/common/shared/");
+    assertThat(uri.toString())
+        .isEqualTo("android://" + context.getPackageName() + "/files/common/shared/");
+  }
+
+  @Test
+  public void builder_setLocation_expectedUsage() {
+    Uri uri = AndroidUri.builder(context).setInternalLocation().build();
+    assertThat(getPathFragment(uri, 0)).isEqualTo("files");
+  }
+
+  @Test
+  public void builder_setLocation_externalLocation() {
+    Uri uri = AndroidUri.builder(context).setExternalLocation().build();
+    assertThat(getPathFragment(uri, 0)).isEqualTo("external");
+  }
+
+  @Test
+  public void builder_setLocation_managed() {
+    Uri uri = AndroidUri.builder(context).setManagedLocation().build();
+    assertThat(getPathFragment(uri, 0)).isEqualTo("managed");
+  }
+
+  @Test
+  public void builder_setModule_expectedUsage() {
+    Uri uri = AndroidUri.builder(context).setModule("testmodule").build();
+    assertThat(getPathFragment(uri, 1)).isEqualTo("testmodule");
+  }
+
+  @Test
+  public void builder_setModule_isValidated() {
+    assertThrows(
+        IllegalArgumentException.class, () -> AndroidUri.builder(context).setModule("").build());
+  }
+
+  @Test
+  public void builder_setModule_doesNotCollideWithManagedLocation() {
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> AndroidUri.builder(context).setModule("managed").build());
+  }
+
+  @Test
+  public void builder_sharedAccount_isSerializedAsShared() {
+    Uri uri = AndroidUri.builder(context).setAccount(AndroidUri.SHARED_ACCOUNT).build();
+    assertThat(getPathFragment(uri, 2)).isEqualTo("shared");
+  }
+
+  @Test
+  public void builder_setAccount_isValidated() {
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> AndroidUri.builder(context).setAccount(new Account("", "")).build());
+  }
+
+  @Test
+  public void builder_setRelativePath_expectedUsage() {
+    Uri uri = AndroidUri.builder(context).setRelativePath("testfile").build();
+    assertThat(getPathFragment(uri, 3)).isEqualTo("testfile");
+  }
+
+  @Test
+  public void validateLocation_onlyAllowsPermittedLocations() {
+    AndroidUri.validateLocation("files");
+    AndroidUri.validateLocation("cache");
+    AndroidUri.validateLocation("external");
+    AndroidUri.validateLocation("directboot-files");
+    AndroidUri.validateLocation("directboot-cache");
+    AndroidUri.validateLocation("managed");
+    assertThrows(IllegalArgumentException.class, () -> AndroidUri.validateLocation(""));
+    assertThrows(IllegalArgumentException.class, () -> AndroidUri.validateLocation("other"));
+  }
+
+  @Test
+  public void validateModule_disallowsReservedModules() {
+    assertThrows(IllegalArgumentException.class, () -> AndroidUri.validateModule("reserved"));
+    assertThrows(IllegalArgumentException.class, () -> AndroidUri.validateModule("RESERVED"));
+  }
+
+  @Test
+  public void validateModule_allowsNonEmptyLowercaseLetters() {
+    AndroidUri.validateModule("a");
+    assertThrows(IllegalArgumentException.class, () -> AndroidUri.validateModule(""));
+    assertThrows(IllegalArgumentException.class, () -> AndroidUri.validateModule("A"));
+    assertThrows(IllegalArgumentException.class, () -> AndroidUri.validateModule("Aa"));
+
+    assertThrows(IllegalArgumentException.class, () -> AndroidUri.validateModule("mymodule0"));
+    assertThrows(IllegalArgumentException.class, () -> AndroidUri.validateModule("myModule"));
+  }
+
+  @Test
+  public void validateModule_allowsInterleavedUnderscores() {
+    AndroidUri.validateModule("mymodule");
+    AndroidUri.validateModule("my_module");
+    AndroidUri.validateModule("my_module_two");
+
+    assertThrows(IllegalArgumentException.class, () -> AndroidUri.validateModule("my module"));
+    assertThrows(IllegalArgumentException.class, () -> AndroidUri.validateModule("my-module"));
+
+    assertThrows(IllegalArgumentException.class, () -> AndroidUri.validateModule("mymodule_"));
+    assertThrows(IllegalArgumentException.class, () -> AndroidUri.validateModule("_mymodule"));
+    assertThrows(IllegalArgumentException.class, () -> AndroidUri.validateModule("my_module_"));
+    assertThrows(IllegalArgumentException.class, () -> AndroidUri.validateModule("_my_module"));
+    assertThrows(IllegalArgumentException.class, () -> AndroidUri.validateModule("my__module"));
+  }
+
+  @Test
+  public void validateRelativePath_isNoOp() {
+    AndroidUri.validateRelativePath("");
+    AndroidUri.validateRelativePath("myFile");
+    AndroidUri.validateRelativePath("myDir/myFile");
+    AndroidUri.validateRelativePath("myDir/../myFile");
+    AndroidUri.validateRelativePath("/myDir/myFile");
+  }
+
+  @Test
+  public void builder_setPackage_expectedUsage() {
+    Uri uri = AndroidUri.builder(context).setPackage("testpackage").build();
+    assertThat(uri.getAuthority()).isEqualTo("testpackage");
+  }
+
+  /**
+   * Utility method to get the i'th path fragment of {@code URI}. May throw exception if the URI is
+   * null, its path is null, or it does not have {@code index} path fragments.
+   */
+  private static String getPathFragment(Uri uri, int index) {
+    // A valid path begins with "/", so +1 is required to offset the first split element ("")
+    return uri.getPath().split("/")[index + 1];
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/backends/AssetFileBackendTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/backends/AssetFileBackendTest.java
new file mode 100644
index 0000000..c6e5224
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/backends/AssetFileBackendTest.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.backends;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.content.Context;
+import android.net.Uri;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.common.UnsupportedFileStorageOperation;
+import com.google.android.libraries.mobiledatadownload.file.openers.NativeReadOpener;
+import com.google.android.libraries.mobiledatadownload.file.openers.ReadStringOpener;
+import com.google.common.collect.ImmutableList;
+import java.io.FileNotFoundException;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public final class AssetFileBackendTest {
+  private final Context context = ApplicationProvider.getApplicationContext();
+
+  private SynchronousFileStorage storage;
+
+  @Before
+  public void setUp() {
+    AssetFileBackend backend = AssetFileBackend.builder(context).build();
+    storage = new SynchronousFileStorage(ImmutableList.of(backend));
+  }
+
+  @Test
+  public void openForRead_opensEmbeddedFiles() throws Exception {
+    Uri path = Uri.parse("asset:/AssetFileBackendTest.java");
+    String contents = storage.open(path, ReadStringOpener.create());
+    assertThat(contents).contains("AssetFileBackendTest");
+  }
+
+  @Test
+  public void fileSize_opensEmbeddedFiles() throws Exception {
+    Uri path = Uri.parse("asset:/AssetFileBackendTest.java");
+    Long size = storage.fileSize(path);
+    assertThat(size).isGreaterThan(0);
+  }
+
+  @Test
+  public void openForRead_handlesMissingAssets() throws Exception {
+    Uri path = Uri.parse("asset:/DOES_NOT_EXIST");
+    FileNotFoundException ex =
+        assertThrows(
+            FileNotFoundException.class, () -> storage.open(path, ReadStringOpener.create()));
+    assertThat(ex).hasMessageThat().isEqualTo("DOES_NOT_EXIST");
+  }
+
+  @Test
+  public void openForNativeRead_isUnsupported() throws Exception {
+    Uri path = Uri.parse("asset:/AssetFileBackendTest.java");
+    UnsupportedFileStorageOperation ex =
+        assertThrows(
+            UnsupportedFileStorageOperation.class,
+            () -> storage.open(path, NativeReadOpener.create()));
+    assertThat(ex).hasMessageThat().isEqualTo("Native read not supported (b/210546473)");
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/backends/BUILD b/javatests/com/google/android/libraries/mobiledatadownload/file/backends/BUILD
new file mode 100644
index 0000000..1bf30c4
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/backends/BUILD
@@ -0,0 +1,275 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_binary", "android_instrumentation_test", "android_local_test")
+load("//java/com/google/android/libraries/mobiledatadownload/file/common/testing:build_defs.bzl", "android_test_multi_api")
+
+package(
+    default_visibility = ["//:__subpackages__"],
+    licenses = ["notice"],
+)
+
+android_local_test(
+    name = "AccountSerializationTest",
+    size = "small",
+    srcs = ["AccountSerializationTest.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:account_serialization",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing:robolectric",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "AndroidFileBackendTest",
+    size = "small",
+    srcs = ["AndroidFileBackendTest.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:account_manager",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:android",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:native",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:stream",
+        "//java/com/google/android/libraries/mobiledatadownload/file/spi",
+        "@com_google_directboot",
+        "@com_google_guava_guava",
+        "@mockito",
+        "@truth",
+    ],
+)
+
+android_test_multi_api(
+    name = "AssetFileBackendTest",
+    size = "small",
+    srcs = ["AssetFileBackendTest.java"],
+    assets = [":test_assets"],
+    assets_dir = "assets",
+    manifest = "//javatests/com/google/android/libraries/mobiledatadownload/file:AndroidManifest.xml",
+    multidex = "legacy",
+    nocompress_extensions = ["java"],
+    target_apis = [
+        "23",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:asset",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:native",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:string",
+        "@androidx_test",
+        "@com_google_guava_guava",
+        "@junit",
+        "@truth",
+    ],
+)
+
+Fileset(
+    name = "test_assets",
+    out = "assets",
+    entries = [
+        FilesetEntry(files = [
+            "AssetFileBackendTest.java",
+        ]),
+    ],
+)
+
+android_local_test(
+    name = "AndroidUriAdapterTest",
+    size = "small",
+    srcs = ["AndroidUriAdapterTest.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:account_manager",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:android_adapter",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "@com_google_guava_guava",
+        "@mockito",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "AndroidUriTest",
+    size = "small",
+    srcs = ["AndroidUriTest.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:account_manager",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:android",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:android_file_environment",
+        "@androidx_test",
+        "@com_google_guava_guava",
+        "@mockito",
+        "@truth",
+    ],
+)
+
+android_binary(
+    name = "BlobSharingBackendTest_app",
+    testonly = 1,
+    srcs = ["BlobStoreBackendTest.java"],
+    manifest = "//javatests/com/google/android/libraries/mobiledatadownload/file:AndroidManifest.xml",
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:blob_uri",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:blobstore_backend",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file_descriptor",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/internal:charsets",
+        "@androidx_test",
+        "@com_google_android_testing//:testrunner",
+        "@com_google_guava_guava",
+        "@junit",
+        "@truth",
+        "@ub_uiautomator",
+    ],
+)
+
+android_instrumentation_test(
+    name = "BlobStoreBackendTest",
+    size = "small",
+    timeout = "long",
+    shard_count = 2,
+    target_device = "//tools/android/emulated_devices/generic_phone:google_30_x86",  # Blob Sharing available in R+
+    test_app = ":BlobSharingBackendTest_app",
+)
+
+android_local_test(
+    name = "BlobUriTest",
+    size = "small",
+    srcs = ["BlobUriTest.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:blob_uri",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "@com_google_guava_guava",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "ContentResolverBackendTest",
+    size = "small",
+    srcs = ["ContentResolverBackendTest.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:content_resolver",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing:robolectric",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:stream",
+        "@com_google_guava_guava",
+        "@truth",
+    ],
+)
+
+android_test_multi_api(
+    name = "FileDescriptorUriAndroidTest",
+    size = "large",
+    srcs = ["FileDescriptorUriAndroidTest.java"],
+    manifest = "//javatests/com/google/android/libraries/mobiledatadownload/file:AndroidManifest.xml",
+    target_apis = [
+        "23",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file_descriptor",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "@junit",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "FileUriAdapterTest",
+    size = "small",
+    srcs = ["FileUriAdapterTest.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file_adapter",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "FileUriTest",
+    size = "small",
+    srcs = ["FileUriTest.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "GenericUriAdapterTest",
+    size = "small",
+    srcs = ["GenericUriAdapterTest.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file_adapter",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:generic_adapter",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "MemoryBackendTest",
+    size = "small",
+    srcs = ["MemoryBackendTest.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:memory",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/spi",
+        "@com_google_guava_guava",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "MemoryUriTest",
+    size = "small",
+    srcs = ["MemoryUriTest.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:memory",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "JavaFileBackendTest",
+    size = "small",
+    srcs = ["JavaFileBackendTest.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/spi",
+        "@com_google_guava_guava",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "UriNormalizerTest",
+    size = "small",
+    srcs = ["UriNormalizerTest.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:uri_normalizer",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing:robolectric",
+        "@truth",
+    ],
+)
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/backends/BlobStoreBackendTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/backends/BlobStoreBackendTest.java
new file mode 100644
index 0000000..c172205
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/backends/BlobStoreBackendTest.java
@@ -0,0 +1,294 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.backends;
+
+import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;
+import static com.google.android.libraries.mobiledatadownload.file.common.internal.Charsets.UTF_8;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.app.blob.BlobStoreManager;
+import android.content.Context;
+import android.net.Uri;
+import android.os.ParcelFileDescriptor;
+import android.support.test.uiautomator.UiDevice;
+import android.util.Log;
+import android.util.Pair;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.android.libraries.mobiledatadownload.file.common.LimitExceededException;
+import com.google.common.io.BaseEncoding;
+import com.google.common.io.ByteStreams;
+import java.io.Closeable;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.security.MessageDigest;
+import java.util.Random;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class BlobStoreBackendTest {
+  public static final String TAG = "BlobStoreBackendTest";
+
+  private Context context;
+  private BlobStoreBackend backend;
+  private BlobStoreManager blobStoreManager;
+
+  @Before
+  public final void setUpStorage() {
+    context = ApplicationProvider.getApplicationContext();
+    backend = new BlobStoreBackend(context);
+    blobStoreManager = (BlobStoreManager) context.getSystemService(Context.BLOB_STORE_SERVICE);
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    // Commands to clean up the blob storage.
+    runShellCmd("cmd blob_store clear-all-sessions");
+    runShellCmd("cmd blob_store clear-all-blobs");
+    context.getFilesDir().delete();
+  }
+
+  @Test
+  public void nativeReadAfterWrite_succeeds() throws Exception {
+    byte[] content = "nativeReadAfterWrite_succeeds".getBytes(UTF_8);
+    String checksum = computeDigest(content);
+    Uri uri = BlobUri.builder(context).setBlobParameters(checksum).build();
+    int numOfLeases = blobStoreManager.getLeasedBlobs().size();
+
+    try (OutputStream out = backend.openForWrite(uri)) {
+      assertThat(out).isNotNull();
+      out.write(content);
+    }
+    assertThat(blobStoreManager.getLeasedBlobs()).hasSize(numOfLeases);
+
+    Uri uriForRead = BlobUri.builder(context).setBlobParameters(checksum).build();
+    Pair<Uri, Closeable> closeableUri = backend.openForNativeRead(uriForRead);
+    assertThat(closeableUri.second).isNotNull();
+    int nativeFd = FileDescriptorUri.getFd(closeableUri.first);
+    try (InputStream in =
+            new FileInputStream(ParcelFileDescriptor.fromFd(nativeFd).getFileDescriptor());
+        Closeable c = closeableUri.second) {
+      assertThat(ByteStreams.toByteArray(in)).isEqualTo(content);
+    }
+  }
+
+  @Test
+  public void readAfterWrite_succeeds() throws Exception {
+    byte[] content = "readAfterWrite_succeeds".getBytes(UTF_8);
+    String checksum = computeDigest(content);
+    Uri uri = BlobUri.builder(context).setBlobParameters(checksum).build();
+    int numOfLeases = blobStoreManager.getLeasedBlobs().size();
+
+    try (OutputStream out = backend.openForWrite(uri)) {
+      assertThat(out).isNotNull();
+      out.write(content);
+    }
+    assertThat(blobStoreManager.getLeasedBlobs()).hasSize(numOfLeases);
+
+    Uri uriForRead = BlobUri.builder(context).setBlobParameters(checksum).build();
+    try (InputStream in = backend.openForRead(uriForRead)) {
+      assertThat(in).isNotNull();
+      assertThat(ByteStreams.toByteArray(in)).isEqualTo(content);
+    }
+  }
+
+  @Test
+  public void exists() throws Exception {
+    byte[] content = "exists".getBytes(UTF_8);
+    String checksum = computeDigest(content);
+    Uri uri = BlobUri.builder(context).setBlobParameters(checksum).build();
+
+    assertThat(backend.exists(uri)).isFalse();
+
+    try (OutputStream out = backend.openForWrite(uri)) {
+      assertThat(out).isNotNull();
+      out.write(content);
+    }
+
+    assertThat(backend.exists(uri)).isTrue();
+  }
+
+  @Test
+  public void writeLease_succeeds() throws Exception {
+    byte[] content = "writeLease_succeeds".getBytes(UTF_8);
+    String checksum = computeDigest(content);
+    Uri blobUri = BlobUri.builder(context).setBlobParameters(checksum).build();
+    int numOfLeases = blobStoreManager.getLeasedBlobs().size();
+    try (OutputStream out = backend.openForWrite(blobUri)) {
+      assertThat(out).isNotNull();
+      out.write(content);
+    }
+
+    Uri leaseUri = BlobUri.builder(context).setLeaseParameters(checksum, 0).build();
+
+    OutputStream out = backend.openForWrite(leaseUri);
+    assertThat(out).isNull();
+
+    assertThat(blobStoreManager.getLeasedBlobs()).hasSize(numOfLeases + 1);
+  }
+
+  @Test
+  public void writeLeaseNonExistentFile_shouldThrow() throws Exception {
+    byte[] content = "writeLeaseNonExistentFile_shouldThrow".getBytes(UTF_8);
+    String checksum = computeDigest(content);
+
+    Uri uri = BlobUri.builder(context).setLeaseParameters(checksum, 0).build();
+
+    assertThrows(SecurityException.class, () -> backend.openForWrite(uri));
+  }
+
+  @Test
+  public void delete_succeeds() throws Exception {
+    byte[] content = "delete_succeeds".getBytes(UTF_8);
+    String checksum = computeDigest(content);
+    Uri blobUri = BlobUri.builder(context).setBlobParameters(checksum).build();
+    int numOfLeases = blobStoreManager.getLeasedBlobs().size();
+
+    try (OutputStream out = backend.openForWrite(blobUri)) {
+      assertThat(out).isNotNull();
+      out.write(content);
+    }
+
+    Uri leaseUri = BlobUri.builder(context).setLeaseParameters(checksum, 0).build();
+    try (OutputStream out = backend.openForWrite(leaseUri)) {
+      assertThat(out).isNull();
+    }
+    assertThat(blobStoreManager.getLeasedBlobs()).hasSize(numOfLeases + 1);
+
+    backend.deleteFile(leaseUri);
+
+    assertThat(blobStoreManager.getLeasedBlobs()).hasSize(numOfLeases);
+  }
+
+  @Test
+  public void releaseAllLeases_succeeds() throws Exception {
+    // Write and acquire lease on first file
+    byte[] content = "releaseAllLeases_succeeds_1".getBytes(UTF_8);
+    String checksum = computeDigest(content);
+    Uri blobUri = BlobUri.builder(context).setBlobParameters(checksum).build();
+    int numOfLeases = blobStoreManager.getLeasedBlobs().size();
+    try (OutputStream out = backend.openForWrite(blobUri)) {
+      assertThat(out).isNotNull();
+      out.write(content);
+    }
+    Uri leaseUri = BlobUri.builder(context).setLeaseParameters(checksum, 0).build();
+    try (OutputStream out = backend.openForWrite(leaseUri)) {
+      assertThat(out).isNull();
+    }
+    assertThat(blobStoreManager.getLeasedBlobs()).hasSize(numOfLeases + 1);
+
+    // Write and acquire lease on second file
+    content = "releaseAllLeases_succeeds_2".getBytes(UTF_8);
+    checksum = computeDigest(content);
+    blobUri = BlobUri.builder(context).setBlobParameters(checksum).build();
+    try (OutputStream out = backend.openForWrite(blobUri)) {
+      assertThat(out).isNotNull();
+      out.write(content);
+    }
+    leaseUri = BlobUri.builder(context).setLeaseParameters(checksum, 0).build();
+    try (OutputStream out = backend.openForWrite(leaseUri)) {
+      assertThat(out).isNull();
+    }
+    assertThat(blobStoreManager.getLeasedBlobs()).hasSize(numOfLeases + 2);
+
+    // Release all leases
+    Uri allLeases = BlobUri.builder(context).setAllLeasesParameters().build();
+
+    backend.deleteFile(allLeases);
+
+    assertThat(blobStoreManager.getLeasedBlobs()).isEmpty();
+  }
+
+  @Test
+  public void deleteNonExistentFile_shouldThrow() throws Exception {
+    BlobStoreBackend backend = new BlobStoreBackend(context);
+    byte[] content = "deleteNonExistentFile_shouldThrow".getBytes(UTF_8);
+    String checksum = computeDigest(content);
+
+    Uri uri = BlobUri.builder(context).setBlobParameters(checksum).build();
+
+    assertThrows(IOException.class, () -> backend.deleteFile(uri));
+  }
+
+  private static String computeDigest(byte[] byteContent) throws Exception {
+    MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
+    if (messageDigest == null) {
+      return "";
+    }
+    return BaseEncoding.base16().lowerCase().encode(messageDigest.digest(byteContent));
+  }
+
+  @Test
+  public void writeLeaseExceedsLimit_shouldThrow() throws Exception {
+    long initialRemainingQuota = blobStoreManager.getRemainingLeaseQuotaBytes();
+    int numOfLeases = blobStoreManager.getLeasedBlobs().size();
+    int numberOfFiles = 20;
+    int singleFileSize = (int) initialRemainingQuota / numberOfFiles;
+    long expectedRemainingQuota = initialRemainingQuota - singleFileSize * numberOfFiles;
+
+    // Create small files rather than one big file to avoid OutOfMemoryError
+    for (int i = 0; i < numberOfFiles; i++) {
+      byte[] content = generateRandomBytes(singleFileSize);
+      String checksum = computeDigest(content);
+      Uri blobUri = BlobUri.builder(context).setBlobParameters(checksum).build();
+
+      try (OutputStream out = backend.openForWrite(blobUri)) {
+        assertThat(out).isNotNull();
+        out.write(content);
+      }
+      Uri leaseUri = BlobUri.builder(context).setLeaseParameters(checksum, 0).build();
+      OutputStream out = backend.openForWrite(leaseUri);
+      assertThat(out).isNull();
+    }
+    assertThat(blobStoreManager.getLeasedBlobs()).hasSize(numOfLeases + numberOfFiles);
+    assertThat(blobStoreManager.getRemainingLeaseQuotaBytes()).isEqualTo(expectedRemainingQuota);
+
+    // Write one more file bigger than available quota. Acquiring the lease on it will throw
+    // LimitExceededException.
+    byte[] content = generateRandomBytes((int) expectedRemainingQuota + 1);
+    String checksum = computeDigest(content);
+    Uri blobUri = BlobUri.builder(context).setBlobParameters(checksum).build();
+    try (OutputStream out = backend.openForWrite(blobUri)) {
+      assertThat(out).isNotNull();
+      out.write(content);
+    }
+    Uri exceedingLeaseUri = BlobUri.builder(context).setLeaseParameters(checksum, 0).build();
+
+    assertThrows(LimitExceededException.class, () -> backend.openForWrite(exceedingLeaseUri));
+
+    assertThat(blobStoreManager.getLeasedBlobs()).hasSize(numOfLeases + numberOfFiles);
+    assertThat(blobStoreManager.getRemainingLeaseQuotaBytes()).isEqualTo(expectedRemainingQuota);
+  }
+
+  private static byte[] generateRandomBytes(int length) {
+    byte[] content = new byte[length];
+    new Random().nextBytes(content);
+    return content;
+  }
+
+  private static String runShellCmd(String cmd) throws IOException {
+    final UiDevice uiDevice = UiDevice.getInstance(getInstrumentation());
+    final String result = uiDevice.executeShellCommand(cmd).trim();
+    Log.i(TAG, "Output of '" + cmd + "': '" + result + "'");
+    return result;
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/backends/BlobUriTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/backends/BlobUriTest.java
new file mode 100644
index 0000000..497aecb
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/backends/BlobUriTest.java
@@ -0,0 +1,141 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.backends;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.content.Context;
+import android.net.Uri;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.android.libraries.mobiledatadownload.file.common.MalformedUriException;
+import com.google.common.io.BaseEncoding;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public final class BlobUriTest {
+
+  private final Context context = ApplicationProvider.getApplicationContext();
+
+  @Test
+  public void builder_setCorrectsParameters() throws Exception {
+    Uri blobUri = BlobUri.builder(context).setBlobParameters("1234").build();
+
+    assertThat(blobUri.getScheme()).isEqualTo("blobstore");
+    assertThat(blobUri.getAuthority()).isEqualTo(context.getPackageName());
+    assertThat(blobUri.getPath()).isEqualTo("/1234");
+    assertThat(blobUri.toString())
+        .isEqualTo(
+            "blobstore://com.google.android.libraries.mobiledatadownload.file.backends/1234");
+
+    Uri leaseUri = BlobUri.builder(context).setAllLeasesParameters().build();
+
+    assertThat(leaseUri.getScheme()).isEqualTo("blobstore");
+    assertThat(leaseUri.getAuthority()).isEqualTo(context.getPackageName());
+    assertThat(leaseUri.getPath()).isEqualTo("/*.lease");
+    assertThat(leaseUri.toString())
+        .isEqualTo(
+            "blobstore://com.google.android.libraries.mobiledatadownload.file.backends/*.lease");
+  }
+
+  @Test
+  public void builder_emptyChecksum_shouldThrow() throws Exception {
+    assertThrows(
+        MalformedUriException.class, () -> BlobUri.builder(context).setBlobParameters("").build());
+    assertThrows(
+        MalformedUriException.class,
+        () -> BlobUri.builder(context).setLeaseParameters("", 1).build());
+  }
+
+  @Test
+  public void validateUri_wrongNumberOfSegments_shouldThrow() throws Exception {
+    Uri invalidUri =
+        BlobUri.builder(context)
+            .setBlobParameters("1234")
+            .build()
+            .buildUpon()
+            .appendPath("newSegment")
+            .build();
+    assertThrows(MalformedUriException.class, () -> BlobUri.validateUri(invalidUri));
+  }
+
+  @Test
+  public void validateUri_allowOnlyPermittedChecksumExtensions() throws Exception {
+    Uri blobUri = BlobUri.builder(context).setBlobParameters("1234").build();
+    Uri leaseUri = BlobUri.builder(context).setLeaseParameters("1234", 1).build();
+    BlobUri.validateUri(blobUri);
+    BlobUri.validateUri(leaseUri);
+
+    Uri wrongExtensionUri = blobUri.buildUpon().path("1234.exts").build();
+    assertThrows(MalformedUriException.class, () -> BlobUri.validateUri(wrongExtensionUri));
+    Uri emptyChecksum = blobUri.buildUpon().path(".lease").build();
+    assertThrows(MalformedUriException.class, () -> BlobUri.validateUri(emptyChecksum));
+  }
+
+  @Test
+  public void validateUri_allowOnlyPermittedQueryParameters() throws Exception {
+    Uri emptyQueryUri = new Uri.Builder().path("1234").build();
+    BlobUri.validateUri(emptyQueryUri);
+    Uri queryWithExpiryDateUri =
+        new Uri.Builder().path("1234").appendQueryParameter("expiryDateSecs", "1").build();
+    BlobUri.validateUri(queryWithExpiryDateUri);
+
+    Uri queryTooLongUri =
+        new Uri.Builder()
+            .path("1234")
+            .appendQueryParameter("fileSize", "1")
+            .appendQueryParameter("expiryDate", "1")
+            .build();
+    assertThrows(MalformedUriException.class, () -> BlobUri.validateUri(queryTooLongUri));
+
+    Uri queryWithUnexpectedParameterUri =
+        new Uri.Builder().path("1234").appendQueryParameter("wrongParameter", "1").build();
+    assertThrows(
+        MalformedUriException.class, () -> BlobUri.validateUri(queryWithUnexpectedParameterUri));
+  }
+
+  @Test
+  public void isLeaseUri() throws Exception {
+    Uri leaseUri = BlobUri.builder(context).setLeaseParameters("1234", 1).build();
+    assertThat(BlobUri.isLeaseUri(leaseUri.getPath())).isTrue();
+
+    Uri nonLeaseUri = BlobUri.builder(context).setBlobParameters("1234").build();
+    assertThat(BlobUri.isLeaseUri(nonLeaseUri.getPath())).isFalse();
+    nonLeaseUri = new Uri.Builder().path("1234.exts").build();
+    assertThat(BlobUri.isLeaseUri(nonLeaseUri.getPath())).isFalse();
+  }
+
+  @Test
+  public void getExpiryDateSecs_shouldSucceed() throws Exception {
+    Uri leaseUri = BlobUri.builder(context).setLeaseParameters("1234", 1).build();
+    assertThat(BlobUri.getExpiryDateSecs(leaseUri)).isEqualTo(1);
+  }
+
+  @Test
+  public void getExpiryDateSecs_emptyQuery_shouldThrow() throws Exception {
+    Uri leaseUri = BlobUri.builder(context).setBlobParameters("1234").build();
+    assertThrows(MalformedUriException.class, () -> BlobUri.getExpiryDateSecs(leaseUri));
+  }
+
+  @Test
+  public void getChecksum() throws Exception {
+    Uri blobUri = BlobUri.builder(context).setBlobParameters("1234").build();
+    byte[] expectedBytes = BaseEncoding.base16().lowerCase().decode("1234");
+    assertThat(BlobUri.getChecksum(blobUri.getPath())).isEqualTo(expectedBytes);
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/backends/ContentResolverBackendTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/backends/ContentResolverBackendTest.java
new file mode 100644
index 0000000..0129b0e
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/backends/ContentResolverBackendTest.java
@@ -0,0 +1,125 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.backends;
+
+import static com.google.common.base.Charsets.UTF_8;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.net.Uri;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.common.MalformedUriException;
+import com.google.android.libraries.mobiledatadownload.file.common.UnsupportedFileStorageOperation;
+import com.google.android.libraries.mobiledatadownload.file.openers.AppendStreamOpener;
+import com.google.android.libraries.mobiledatadownload.file.openers.ReadStreamOpener;
+import com.google.android.libraries.mobiledatadownload.file.openers.WriteStreamOpener;
+import com.google.common.collect.ImmutableList;
+import com.google.common.io.ByteStreams;
+import com.google.thirdparty.robolectric.GoogleRobolectricTestRunner;
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Shadows;
+
+@RunWith(GoogleRobolectricTestRunner.class)
+public class ContentResolverBackendTest {
+
+  private final Context context = ApplicationProvider.getApplicationContext();
+  private SynchronousFileStorage storage;
+  private ContentResolver contentResolver;
+
+  @Before
+  public final void setUpStorage() {
+    ContentResolverBackend backend = ContentResolverBackend.builder(context).build();
+    storage = new SynchronousFileStorage(ImmutableList.of(backend));
+    contentResolver = context.getContentResolver();
+  }
+
+  @Test
+  public void openForRead_reads() throws Exception {
+    Uri uri = Uri.parse("content://test/openForRead_reads");
+    String expected = "expected content";
+    Shadows.shadowOf(contentResolver)
+        .registerInputStream(uri, new ByteArrayInputStream(expected.getBytes(UTF_8)));
+
+    try (InputStream in = storage.open(uri, ReadStreamOpener.create())) {
+      String actual = new String(ByteStreams.toByteArray(in), UTF_8);
+      assertThat(actual).isEqualTo(expected);
+    }
+  }
+
+  @Test
+  public void openForRead_missingFile() throws Exception {
+    Uri uri = Uri.parse("content://test/openForRead_missingFile");
+
+    try (InputStream in = storage.open(uri, ReadStreamOpener.create())) {
+      // The shadow is weird: it returns a stream even if not registered, but that stream throws
+      // when you try to use it.
+      assertThrows(UnsupportedOperationException.class, () -> in.read());
+    }
+  }
+
+  // TODO(b/110493197): Add test for native read. SynchronousFileStorage lacks
+  // registerFileDescriptor.
+
+  @Test
+  public void openForWrite_notImplemented() throws Exception {
+    Uri uri = Uri.parse("content://test/openForWrite_notImplemented");
+
+    assertThrows(
+        UnsupportedFileStorageOperation.class, () -> storage.open(uri, WriteStreamOpener.create()));
+  }
+
+  @Test
+  public void openForAppend_notImplemented() throws Exception {
+    Uri uri = Uri.parse("content://test/ok");
+
+    assertThrows(
+        UnsupportedFileStorageOperation.class,
+        () -> storage.open(uri, AppendStreamOpener.create()));
+  }
+
+  @Test
+  public void nonEmbedded_checksScheme() throws Exception {
+    ContentResolverBackend nonEmbedded = ContentResolverBackend.builder(context).build();
+    Uri uri = Uri.parse("WRONG://test/nonEmbedded_checksScheme");
+
+    assertThrows(MalformedUriException.class, () -> nonEmbedded.openForRead(uri));
+    assertThat(nonEmbedded.name()).isEqualTo("content");
+  }
+
+  @Test
+  public void embedded_rewritesScheme() throws Exception {
+    ContentResolverBackend embedded =
+        ContentResolverBackend.builder(context).setEmbedded(true).build();
+    Uri uri = Uri.parse("OTHERSCHEME://test/embedded_rewritesScheme");
+    Uri contentUri = uri.buildUpon().scheme("content").build();
+    String expected = "expected content";
+    Shadows.shadowOf(contentResolver)
+        .registerInputStream(contentUri, new ByteArrayInputStream(expected.getBytes(UTF_8)));
+
+    try (InputStream in = embedded.openForRead(uri)) {
+      String actual = new String(ByteStreams.toByteArray(in), UTF_8);
+      assertThat(actual).isEqualTo(expected);
+    }
+    assertThrows(IllegalStateException.class, () -> embedded.name());
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/backends/FileDescriptorUriAndroidTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/backends/FileDescriptorUriAndroidTest.java
new file mode 100644
index 0000000..d71282e
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/backends/FileDescriptorUriAndroidTest.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.backends;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.net.Uri;
+import android.os.ParcelFileDescriptor;
+import android.util.Pair;
+import com.google.android.libraries.mobiledatadownload.file.common.MalformedUriException;
+import java.io.Closeable;
+import java.io.File;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Test {@link com.google.android.libraries.mobiledatadownload.file.backends.FileDescriptorUri}. */
+@RunWith(JUnit4.class)
+public class FileDescriptorUriAndroidTest {
+
+  @Test
+  public void convenienceMethod_shouldGenerateUriFromPFD() throws Exception {
+    ParcelFileDescriptor stdin = ParcelFileDescriptor.adoptFd(0);
+    Pair<Uri, Closeable> result = FileDescriptorUri.fromParcelFileDescriptor(stdin);
+    assertThat(result.first.getScheme()).isEqualTo("fd");
+    assertThat(result.first.getSchemeSpecificPart()).isEqualTo("0");
+    assertThat(FileDescriptorUri.getFd(result.first)).isEqualTo(0);
+    assertThat(result.first.toString()).isEqualTo("fd:0");
+  }
+
+  @Test
+  public void close_shouldCloseFd() throws Exception {
+    ParcelFileDescriptor cwd =
+        ParcelFileDescriptor.open(new File("."), ParcelFileDescriptor.MODE_READ_ONLY);
+    Pair<Uri, Closeable> result = FileDescriptorUri.fromParcelFileDescriptor(cwd);
+
+    assertThat(result.first.getScheme()).isEqualTo("fd");
+    assertThat(FileDescriptorUri.getFd(result.first)).isGreaterThan(0);
+
+    File procFd = new File("/proc/self/fd/" + result.first.getSchemeSpecificPart());
+    assertThat(procFd.exists()).isTrue();
+    result.second.close();
+    assertThat(procFd.exists()).isFalse();
+  }
+
+  @Test
+  public void getFd_validatesUri() throws Exception {
+    assertThrows(MalformedUriException.class, () -> FileDescriptorUri.getFd(Uri.parse("file:5")));
+    assertThrows(MalformedUriException.class, () -> FileDescriptorUri.getFd(Uri.parse("fd:")));
+    assertThrows(MalformedUriException.class, () -> FileDescriptorUri.getFd(Uri.parse("fd:abc")));
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/backends/FileUriAdapterTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/backends/FileUriAdapterTest.java
new file mode 100644
index 0000000..d710339
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/backends/FileUriAdapterTest.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.backends;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.common.MalformedUriException;
+import com.google.thirdparty.robolectric.GoogleRobolectricTestRunner;
+import java.io.File;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Test {@link com.google.android.libraries.mobiledatadownload.file.backends.FileUriAdapter}. */
+@RunWith(GoogleRobolectricTestRunner.class)
+public class FileUriAdapterTest {
+  @Test
+  public void shouldGenerateFileFromUri() throws Exception {
+    File file = FileUriAdapter.instance().toFile(Uri.parse("file:///tmp/foo.txt"));
+    assertThat(file.getPath()).isEqualTo("/tmp/foo.txt");
+  }
+
+  @Test
+  public void ignoresFragmentAtCallersPeril() throws Exception {
+    File file = FileUriAdapter.instance().toFile(Uri.parse("file:///tmp/foo.txt#fragment"));
+    assertThat(file.getPath()).isEqualTo("/tmp/foo.txt");
+  }
+
+  @Test
+  public void requiresFileScheme() throws Exception {
+    assertThrows(
+        MalformedUriException.class,
+        () -> FileUriAdapter.instance().toFile(Uri.parse("xxx:///tmp/foo.txt")));
+  }
+
+  @Test
+  public void requiresEmptyQuery() throws Exception {
+    assertThrows(
+        MalformedUriException.class,
+        () -> FileUriAdapter.instance().toFile(Uri.parse("file:///tmp/foo.txt?query")));
+  }
+
+  @Test
+  public void requiresEmptyAuthority() throws Exception {
+    assertThrows(
+        MalformedUriException.class,
+        () -> FileUriAdapter.instance().toFile(Uri.parse("file://authority/tmp/foo.txt")));
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/backends/FileUriTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/backends/FileUriTest.java
new file mode 100644
index 0000000..389c8c6
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/backends/FileUriTest.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.backends;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.net.Uri;
+import com.google.thirdparty.robolectric.GoogleRobolectricTestRunner;
+import java.io.File;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Test {@link com.google.android.libraries.mobiledatadownload.file.backends.FileUri}. */
+@RunWith(GoogleRobolectricTestRunner.class)
+public class FileUriTest {
+
+  @Test
+  public void shouldGenerateUriFromFile() {
+    Uri uri = FileUri.builder().fromFile(new File("/tmp/foo.txt")).build();
+    assertThat(uri.getScheme()).isEqualTo("file");
+    assertThat(uri.getPath()).isEqualTo("/tmp/foo.txt");
+    assertThat(uri.toString()).isEqualTo("file:///tmp/foo.txt");
+  }
+
+  @Test
+  public void shouldGenerateUriFromStringPath() {
+    Uri uri = FileUri.builder().setPath("/tmp/foo.txt").build();
+    assertThat(uri.toString()).isEqualTo("file:///tmp/foo.txt");
+  }
+
+  @Test
+  public void builder_appendPath_addsPathSegment() {
+    Uri uri = FileUri.builder().setPath("tmp").appendPath("foo.txt").build();
+    assertThat(uri.toString()).isEqualTo("file:///tmp/foo.txt");
+  }
+
+  @Test
+  public void hasSafeDefault() {
+    Uri uri = FileUri.builder().build();
+    assertThat(uri.toString()).isEqualTo("file:///");
+  }
+
+  @Test
+  public void repeatedCalls_shouldRetainInfo() {
+    FileUri.Builder uriBuilder = FileUri.builder().setPath("/fake");
+    Uri fileUri = uriBuilder.build();
+    Uri fileUriWithTransform = fileUri.buildUpon().encodedFragment("transform=foo").build();
+    assertThat(fileUri.toString()).isEqualTo("file:///fake");
+    assertThat(fileUriWithTransform.toString()).isEqualTo("file:///fake#transform=foo");
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/backends/GenericUriAdapterTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/backends/GenericUriAdapterTest.java
new file mode 100644
index 0000000..58dcbf0
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/backends/GenericUriAdapterTest.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.backends;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.content.Context;
+import android.net.Uri;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.android.libraries.mobiledatadownload.file.common.MalformedUriException;
+import com.google.thirdparty.robolectric.GoogleRobolectricTestRunner;
+import java.io.File;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Test {@link com.google.android.libraries.mobiledatadownload.file.backends.GenericUriAdapter}. */
+@RunWith(GoogleRobolectricTestRunner.class)
+public class GenericUriAdapterTest {
+
+  private final Context context = ApplicationProvider.getApplicationContext();
+
+  @Test
+  public void shouldGenerateFileFromFileUri() throws Exception {
+    File file = GenericUriAdapter.forContext(context).toFile(Uri.parse("file:///tmp/foo.txt"));
+    assertThat(file.getPath()).isEqualTo("/tmp/foo.txt");
+  }
+
+  @Test
+  public void shouldGenerateFileFromAndroidUri() throws Exception {
+    File file =
+        GenericUriAdapter.forContext(context)
+            .toFile(Uri.parse("android://package/files/common/shared/path"));
+    assertThat(file.getPath()).endsWith("/files/common/shared/path");
+  }
+
+  @Test
+  public void shouldGenerateFileFromExternalLocationUri() throws Exception {
+    File file =
+        GenericUriAdapter.forContext(context)
+            .toFile(Uri.parse("android://package/external/common/shared/path"));
+    assertThat(file.getPath()).endsWith("/external-files/common/shared/path");
+  }
+
+  @Test
+  public void shouldCheckScheme() throws Exception {
+    assertThrows(
+        MalformedUriException.class,
+        () -> FileUriAdapter.instance().toFile(Uri.parse("xxx:///tmp/foo.txt")));
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/backends/JavaFileBackendTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/backends/JavaFileBackendTest.java
new file mode 100644
index 0000000..857373a
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/backends/JavaFileBackendTest.java
@@ -0,0 +1,122 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.backends;
+
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.makeArrayOfBytesContent;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.common.FileChannelConvertible;
+import com.google.android.libraries.mobiledatadownload.file.common.LockScope;
+import com.google.android.libraries.mobiledatadownload.file.common.Sizable;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.BackendTestBase;
+import com.google.android.libraries.mobiledatadownload.file.spi.Backend;
+import com.google.common.collect.ImmutableList;
+import com.google.thirdparty.robolectric.GoogleRobolectricTestRunner;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.List;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+
+@RunWith(GoogleRobolectricTestRunner.class)
+public final class JavaFileBackendTest extends BackendTestBase {
+
+  private Backend backend = new JavaFileBackend();
+
+  @Rule public TemporaryFolder tmpFolder = new TemporaryFolder();
+
+  @Test
+  public void openForRead_shouldImplementFileChannelConvertible() throws IOException {
+    backend.openForWrite(uriForTestMethod()).close();
+    try (InputStream inputStream = backend.openForRead(uriForTestMethod())) {
+      assertThat(inputStream).isInstanceOf(FileChannelConvertible.class);
+      assertThat(((FileChannelConvertible) inputStream).toFileChannel().position()).isEqualTo(0);
+    }
+  }
+
+  @Test
+  public void openForWrite_shouldImplementFileChannelConvertible() throws IOException {
+    try (OutputStream outputStream = backend.openForWrite(uriForTestMethod())) {
+      assertThat(outputStream).isInstanceOf(FileChannelConvertible.class);
+      assertThat(((FileChannelConvertible) outputStream).toFileChannel().position()).isEqualTo(0);
+    }
+  }
+
+  @Test
+  public void openForAppend_shouldImplementFileChannelConvertible() throws IOException {
+    try (OutputStream outputStream = backend.openForAppend(uriForTestMethod())) {
+      assertThat(outputStream).isInstanceOf(FileChannelConvertible.class);
+      assertThat(((FileChannelConvertible) outputStream).toFileChannel().position()).isEqualTo(0);
+    }
+  }
+
+  @Test
+  public void openForRead_shouldImplementSizable() throws IOException {
+    byte[] content = makeArrayOfBytesContent();
+    try (OutputStream out = backend.openForWrite(uriForTestMethod())) {
+      out.write(content);
+    }
+
+    try (InputStream inputStream = backend.openForRead(uriForTestMethod())) {
+      assertThat(inputStream).isInstanceOf(Sizable.class);
+      assertThat(((Sizable) inputStream).size()).isEqualTo(content.length);
+    }
+  }
+
+  @Test
+  public void lockScope_returnsNonNullLockScope() throws IOException {
+    assertThat(backend.lockScope()).isNotNull();
+  }
+
+  @Test
+  public void lockScope_canBeOverridden() throws IOException {
+    LockScope lockScope = new LockScope();
+    backend = new JavaFileBackend(lockScope);
+    assertThat(backend.lockScope()).isSameInstanceAs(lockScope);
+  }
+
+  @Override
+  protected Backend backend() {
+    return backend;
+  }
+
+  @Override
+  protected Uri legalUriBase() {
+    return FileUri.fromFile(tmpFolder.getRoot());
+  }
+
+  @Override
+  protected List<Uri> illegalUrisToRead() {
+    String root = legalUriBase().getPath();
+    return ImmutableList.of(
+        Uri.parse("file://<internal>@authority:123/" + root + "/uriWithAuthority"),
+        Uri.parse("file:///" + root + "/uriWithQuery?q=a"));
+  }
+
+  @Override
+  protected List<Uri> illegalUrisToWrite() {
+    return ImmutableList.of();
+  }
+
+  @Override
+  protected List<Uri> illegalUrisToAppend() {
+    return illegalUrisToWrite();
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/backends/MemoryBackendTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/backends/MemoryBackendTest.java
new file mode 100644
index 0000000..f939633
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/backends/MemoryBackendTest.java
@@ -0,0 +1,73 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.backends;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.BackendTestBase;
+import com.google.android.libraries.mobiledatadownload.file.spi.Backend;
+import com.google.common.collect.ImmutableList;
+import com.google.thirdparty.robolectric.GoogleRobolectricTestRunner;
+import java.util.List;
+import org.junit.runner.RunWith;
+
+@RunWith(GoogleRobolectricTestRunner.class)
+public final class MemoryBackendTest extends BackendTestBase {
+
+  private final Backend backend = new MemoryBackend();
+
+  @Override
+  protected Backend backend() {
+    return backend;
+  }
+
+  @Override
+  protected Uri legalUriBase() {
+    return Uri.parse("memory:");
+  }
+
+  @Override
+  protected List<Uri> illegalUrisToRead() {
+    return ImmutableList.of(
+        Uri.parse("memory:"),
+        Uri.parse("memory://<internal>@authority:123/uriWithAuthority"),
+        Uri.parse("memory:///uriWithQuery?q=a"));
+  }
+
+  @Override
+  protected List<Uri> illegalUrisToWrite() {
+    return illegalUrisToRead();
+  }
+
+  @Override
+  protected boolean supportsAppend() {
+    return false;
+  }
+
+  @Override
+  protected boolean supportsDirectories() {
+    return false;
+  }
+
+  @Override
+  protected boolean supportsFileConvertible() {
+    return false;
+  }
+
+  @Override
+  protected boolean supportsToFile() {
+    return false;
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/backends/MemoryUriTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/backends/MemoryUriTest.java
new file mode 100644
index 0000000..5b86606
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/backends/MemoryUriTest.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.backends;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.common.MalformedUriException;
+import com.google.thirdparty.robolectric.GoogleRobolectricTestRunner;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(GoogleRobolectricTestRunner.class)
+public class MemoryUriTest {
+
+  @Test
+  public void builder_withoutKey_throwsException() throws Exception {
+    assertThrows(MalformedUriException.class, () -> MemoryUri.builder().build());
+  }
+
+  @Test
+  public void builder_setKey_setsSchemeSpecificPart() throws Exception {
+    Uri uri = MemoryUri.builder().setKey("foo").build();
+    assertThat(uri.getSchemeSpecificPart()).isEqualTo("foo");
+    assertThat(uri.toString()).isEqualTo("memory:foo");
+  }
+
+  @Test
+  public void builder_setKey_requiresNonEmptyValue() throws Exception {
+    assertThrows(MalformedUriException.class, () -> MemoryUri.builder().setKey("").build());
+  }
+
+  @Test
+  public void builder_buildsOpaqueUri() throws Exception {
+    Uri uri = MemoryUri.builder().setKey("foo?withQuestionMark").build();
+    assertThat(uri.getSchemeSpecificPart()).isEqualTo("foo?withQuestionMark");
+    assertThat(uri.toString()).isEqualTo("memory:foo%3FwithQuestionMark");
+
+    uri = MemoryUri.builder().setKey("foo/withForwardSlash").build();
+    assertThat(uri.getSchemeSpecificPart()).isEqualTo("foo/withForwardSlash");
+    assertThat(uri.toString()).isEqualTo("memory:foo%2FwithForwardSlash");
+
+    uri = MemoryUri.builder().setKey("foo#withPound").build();
+    assertThat(uri.getSchemeSpecificPart()).isEqualTo("foo#withPound");
+    assertThat(uri.toString()).isEqualTo("memory:foo%23withPound");
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/backends/UriNormalizerTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/backends/UriNormalizerTest.java
new file mode 100644
index 0000000..8d56b19
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/backends/UriNormalizerTest.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.backends;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.net.Uri;
+import com.google.thirdparty.robolectric.GoogleRobolectricTestRunner;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Test {@link com.google.android.libraries.mobiledatadownload.file.backends.UriNormalizer}. */
+@RunWith(GoogleRobolectricTestRunner.class)
+public final class UriNormalizerTest {
+
+  @Test
+  public void normalizeUriWithDotDot() {
+    Uri uri = Uri.parse("android://package/files/common/../shared/path/..");
+
+    assertThat(UriNormalizer.normalizeUri(uri).toString())
+        .isEqualTo("android://package/files/shared");
+  }
+
+  @Test
+  public void normalizeUriWithDot() {
+    Uri uri = Uri.parse("android://package/files/common/./shared/path/././.");
+
+    assertThat(UriNormalizer.normalizeUri(uri).toString())
+        .isEqualTo("android://package/files/common/shared/path");
+  }
+
+  @Test
+  public void normalizeUriWithSlashSlash() {
+    Uri uri = Uri.parse("android://package/files/common//shared/path//");
+
+    assertThat(UriNormalizer.normalizeUri(uri).toString())
+        .isEqualTo("android://package/files/common/shared/path");
+  }
+
+  @Test
+  public void normalizeUriWithUppercaseScheme() {
+    Uri uri = Uri.parse("ANDROID://package/files/common/shared/path");
+
+    assertThat(UriNormalizer.normalizeUri(uri).toString())
+        .isEqualTo("android://package/files/common/shared/path");
+  }
+
+  @Test
+  public void normalizeUriReachesEndOfPath() {
+    Uri uri = Uri.parse("android://package/files/common/shared/path/../../../../../..");
+
+    assertThat(UriNormalizer.normalizeUri(uri).toString()).isEqualTo("android://package/");
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/behaviors/BUILD b/javatests/com/google/android/libraries/mobiledatadownload/file/behaviors/BUILD
new file mode 100644
index 0000000..5fbb304
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/behaviors/BUILD
@@ -0,0 +1,73 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_binary", "android_instrumentation_test", "android_library", "android_local_test")
+
+package(
+    default_visibility = ["//:__subpackages__"],
+    licenses = ["notice"],
+)
+
+android_local_test(
+    name = "UriComputingBehaviorTest",
+    srcs = [
+        "UriComputingBehaviorTest.java",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/behaviors:compute_uri",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing:extras",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:stream",
+        "//java/com/google/android/libraries/mobiledatadownload/file/spi",
+        "@com_google_guava_guava",
+        "@mockito",
+        "@truth",
+    ],
+)
+
+android_library(
+    name = "SyncingBehaviorAndroidTest_lib",
+    testonly = 1,
+    srcs = [
+        "SyncingBehaviorAndroidTest.java",
+    ],
+    manifest = "//javatests/com/google/android/libraries/mobiledatadownload/file:AndroidManifest.xml",
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/behaviors:syncing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:stream",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:buffer",
+        "@com_google_android_testing//:testrunner",
+        "@com_google_guava_guava",
+        "@junit",
+        "@truth",
+    ],
+)
+
+android_binary(
+    name = "SyncingBehaviorAndroidTest_bin",
+    testonly = 1,
+    manifest = "//javatests/com/google/android/libraries/mobiledatadownload/file:AndroidManifest.xml",
+    deps = [":SyncingBehaviorAndroidTest_lib"],
+)
+
+android_instrumentation_test(
+    name = "SyncingBehaviorAndroidTest",
+    timeout = "moderate",
+    testonly = 1,
+    target_device = "//tools/android/emulated_devices/generic_phone:google_23_x86",
+    test_app = ":SyncingBehaviorAndroidTest_bin",
+)
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/behaviors/SyncingBehaviorAndroidTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/behaviors/SyncingBehaviorAndroidTest.java
new file mode 100644
index 0000000..d2a5e8b
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/behaviors/SyncingBehaviorAndroidTest.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.behaviors;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.JavaFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.TemporaryUri;
+import com.google.android.libraries.mobiledatadownload.file.openers.ReadStreamOpener;
+import com.google.android.libraries.mobiledatadownload.file.openers.WriteStreamOpener;
+import com.google.android.libraries.mobiledatadownload.file.transforms.BufferTransform;
+import com.google.common.collect.ImmutableList;
+import java.io.InputStream;
+import java.io.OutputStream;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class SyncingBehaviorAndroidTest {
+  private SynchronousFileStorage storage;
+
+  @Rule public TemporaryUri tmpUri = new TemporaryUri();
+
+  @Before
+  public void setUpStorage() throws Exception {
+    storage =
+        new SynchronousFileStorage(
+            ImmutableList.of(new JavaFileBackend()), ImmutableList.of(new BufferTransform()));
+  }
+
+  @Test
+  public void syncing_sync() throws Exception {
+    Uri uri1 =
+        tmpUri
+            .newUriBuilder()
+            .build()
+            .buildUpon()
+            .encodedFragment("transform=buffer(size=8192)")
+            .build();
+    SyncingBehavior syncing = new SyncingBehavior();
+    try (OutputStream out = storage.open(uri1, WriteStreamOpener.create().withBehaviors(syncing))) {
+      out.write(42);
+      try (InputStream in = storage.open(uri1, ReadStreamOpener.create())) {
+        assertThat(in.read()).isEqualTo(-1);
+      }
+      syncing.sync();
+      try (InputStream in = storage.open(uri1, ReadStreamOpener.create())) {
+        assertThat(in.read()).isEqualTo(42);
+      }
+    }
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/behaviors/UriComputingBehaviorTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/behaviors/UriComputingBehaviorTest.java
new file mode 100644
index 0000000..e696b08
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/behaviors/UriComputingBehaviorTest.java
@@ -0,0 +1,130 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.behaviors;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.FileUri;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.DummyTransforms;
+import com.google.android.libraries.mobiledatadownload.file.openers.ReadStreamOpener;
+import com.google.android.libraries.mobiledatadownload.file.openers.WriteStreamOpener;
+import com.google.android.libraries.mobiledatadownload.file.spi.Backend;
+import com.google.common.collect.ImmutableList;
+import com.google.thirdparty.robolectric.GoogleRobolectricTestRunner;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.concurrent.Future;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+@RunWith(GoogleRobolectricTestRunner.class)
+public final class UriComputingBehaviorTest {
+
+  private static final InputStream EMPTY_INPUT_STREAM = new ByteArrayInputStream(new byte[0]);
+  private SynchronousFileStorage storage;
+
+  @Rule public final MockitoRule mocks = MockitoJUnit.rule();
+
+  @Mock protected Backend fileBackend;
+
+  @Before
+  public void initStorage() throws Exception {
+    when(fileBackend.name()).thenReturn("file");
+    storage =
+        new SynchronousFileStorage(
+            ImmutableList.of(fileBackend),
+            ImmutableList.of(DummyTransforms.CAP_FILENAME_TRANSFORM));
+  }
+
+  @Test
+  public void getComputedUri_read_shouldRetainUnencodedUriAndFragment() throws Exception {
+    Uri fileUriWithTransform =
+        FileUri.builder()
+            .setPath("/fake")
+            .build()
+            .buildUpon()
+            .encodedFragment("transform=cap")
+            .build();
+    when(fileBackend.openForRead(any())).thenReturn(EMPTY_INPUT_STREAM);
+    Future<Uri> uriFuture;
+    UriComputingBehavior computeUri = new UriComputingBehavior(fileUriWithTransform);
+    try (InputStream in =
+        storage.open(fileUriWithTransform, ReadStreamOpener.create().withBehaviors(computeUri))) {
+      uriFuture = computeUri.uriFuture();
+    }
+    assertThat(uriFuture.get()).isEqualTo(fileUriWithTransform);
+  }
+
+  @Test
+  public void getComputedUri_read_withNoTransform_returnsSameUri() throws Exception {
+    Uri fileUriWithoutTransform = FileUri.builder().setPath("/fake").build();
+    when(fileBackend.openForRead(any())).thenReturn(EMPTY_INPUT_STREAM);
+    Future<Uri> uriFuture;
+    UriComputingBehavior computeUri = new UriComputingBehavior(fileUriWithoutTransform);
+    try (InputStream in =
+        storage.open(
+            fileUriWithoutTransform, ReadStreamOpener.create().withBehaviors(computeUri))) {
+      uriFuture = computeUri.uriFuture();
+    }
+    assertThat(uriFuture.get()).isEqualTo(fileUriWithoutTransform);
+  }
+
+  @Test
+  public void getComputedUri_write_shouldRetainUnencodedUriAndFragment() throws Exception {
+    OutputStream outputStream = new ByteArrayOutputStream();
+    Uri fileUriWithTransform =
+        FileUri.builder()
+            .setPath("/fake")
+            .build()
+            .buildUpon()
+            .encodedFragment("transform=cap")
+            .build();
+    when(fileBackend.openForWrite(any())).thenReturn(outputStream);
+    Future<Uri> uriFuture;
+    UriComputingBehavior computeUri = new UriComputingBehavior(fileUriWithTransform);
+    try (OutputStream out =
+        storage.open(fileUriWithTransform, WriteStreamOpener.create().withBehaviors(computeUri))) {
+      uriFuture = computeUri.uriFuture();
+    }
+    assertThat(uriFuture.get()).isEqualTo(fileUriWithTransform);
+  }
+
+  @Test
+  public void getComputedUri_write_withNoTransform_returnsSameUri() throws Exception {
+    OutputStream outputStream = new ByteArrayOutputStream();
+    Uri fileUriWithoutTransform = FileUri.builder().setPath("/fake").build();
+    when(fileBackend.openForWrite(any())).thenReturn(outputStream);
+    Future<Uri> uriFuture;
+    UriComputingBehavior computeUri = new UriComputingBehavior(fileUriWithoutTransform);
+    try (OutputStream out =
+        storage.open(
+            fileUriWithoutTransform, WriteStreamOpener.create().withBehaviors(computeUri))) {
+      uriFuture = computeUri.uriFuture();
+    }
+    assertThat(uriFuture.get()).isEqualTo(fileUriWithoutTransform);
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/common/BUILD b/javatests/com/google/android/libraries/mobiledatadownload/file/common/BUILD
new file mode 100644
index 0000000..948a1ff
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/common/BUILD
@@ -0,0 +1,49 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_local_test")
+
+package(
+    default_visibility = ["//:__subpackages__"],
+    licenses = ["notice"],
+)
+
+android_local_test(
+    name = "FragmentTest",
+    size = "small",
+    srcs = ["FragmentTest.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/common:fragment",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "GcParamTest",
+    size = "small",
+    srcs = ["GcParamTest.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "LockScopeTest",
+    size = "small",
+    srcs = ["LockScopeTest.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "@truth",
+    ],
+)
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/common/FragmentTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/common/FragmentTest.java
new file mode 100644
index 0000000..b9fd298
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/common/FragmentTest.java
@@ -0,0 +1,321 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.net.Uri;
+import com.google.thirdparty.robolectric.GoogleRobolectricTestRunner;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(GoogleRobolectricTestRunner.class)
+public final class FragmentTest {
+
+  @Test
+  public void builder_empty() throws Exception {
+    Fragment.Builder fragment = Fragment.builder();
+    assertThat(fragment.build().toString()).isEmpty();
+  }
+
+  @Test
+  public void parse_empty() throws Exception {
+    Fragment fragment = Fragment.parse("");
+    assertThat(fragment.toString()).isEmpty();
+  }
+
+  @Test
+  public void parse_simpleParam() throws Exception {
+    Fragment fragment = Fragment.parse("simple=true");
+    assertThat(fragment.toString()).isEqualTo("simple=true");
+    assertThat(fragment.params().get(0).key()).isEqualTo("simple");
+  }
+
+  @Test
+  public void builder() throws Exception {
+    Fragment fragment =
+        Fragment.builder()
+            .addParam(
+                Fragment.Param.builder("paramKey")
+                    .addValue(
+                        Fragment.ParamValue.builder("paramValue")
+                            .addSubParam("subparamKey", "subparamValue")))
+            .build();
+
+    assertThat(fragment.toString()).isEqualTo("paramKey=paramValue(subparamKey=subparamValue)");
+  }
+
+  @Test
+  public void parse() throws Exception {
+    Fragment fragment = Fragment.parse("paramKey=paramValue(subparamKey=subparamValue)");
+
+    assertThat(fragment.toString()).isEqualTo("paramKey=paramValue(subparamKey=subparamValue)");
+
+    Fragment.Param param = fragment.params().get(0);
+    assertThat(param.key()).isEqualTo("paramKey");
+    Fragment.ParamValue value = param.values().get(0);
+    assertThat(value.name()).isEqualTo("paramValue");
+    Fragment.SubParam subparam = value.subParams().get(0);
+    assertThat(subparam.key()).isEqualTo("subparamKey");
+    assertThat(subparam.value()).isEqualTo("subparamValue");
+  }
+
+  @Test
+  public void parse_multipleParamsAndsubParams() throws Exception {
+    Fragment fragment = Fragment.parse("k1=v1&k2=v2(sk1=sv1)&k3=v3(sk1=sv1,sk2=sv2,sk3)");
+
+    assertThat(fragment.toString()).isEqualTo("k1=v1&k2=v2(sk1=sv1)&k3=v3(sk1=sv1,sk2=sv2,sk3)");
+
+    Fragment.Param p1 = fragment.params().get(0);
+    assertThat(p1.key()).isEqualTo("k1");
+    Fragment.ParamValue v1 = p1.values().get(0);
+    assertThat(v1.name()).isEqualTo("v1");
+    assertThat(v1.subParams().size()).isEqualTo(0);
+
+    Fragment.Param p2 = fragment.params().get(1);
+    assertThat(p2.key()).isEqualTo("k2");
+    Fragment.ParamValue v2 = p2.values().get(0);
+    assertThat(v2.name()).isEqualTo("v2");
+    assertThat(v2.subParams().size()).isEqualTo(1);
+    Fragment.SubParam p2s = v2.subParams().get(0);
+    assertThat(p2s.key()).isEqualTo("sk1");
+    assertThat(p2s.value()).isEqualTo("sv1");
+
+    Fragment.Param p3 = fragment.params().get(2);
+    assertThat(p3.key()).isEqualTo("k3");
+    Fragment.ParamValue v3 = p3.values().get(0);
+    assertThat(v3.name()).isEqualTo("v3");
+    assertThat(v3.subParams().size()).isEqualTo(3);
+    Fragment.SubParam p3s1 = v3.subParams().get(0);
+    assertThat(p3s1.key()).isEqualTo("sk1");
+    assertThat(p3s1.hasValue()).isTrue();
+    assertThat(p3s1.value()).isEqualTo("sv1");
+    Fragment.SubParam p3s2 = v3.subParams().get(1);
+    assertThat(p3s2.key()).isEqualTo("sk2");
+    assertThat(p3s2.hasValue()).isTrue();
+    assertThat(p3s2.value()).isEqualTo("sv2");
+    Fragment.SubParam p3s3 = v3.subParams().get(2);
+    assertThat(p3s3.key()).isEqualTo("sk3");
+    assertThat(p3s3.hasValue()).isFalse();
+    assertThat(p3s3.value()).isNull();
+  }
+
+  @Test
+  public void builder_multipleValues() throws Exception {
+    Fragment fragment =
+        Fragment.builder()
+            .addParam("unset")
+            .addParam(Fragment.Param.builder("k1").addValue("v1"))
+            .addParam(
+                Fragment.Param.builder("k2")
+                    .addValue("v2")
+                    .addValue("v2a")
+                    .addValue(Fragment.ParamValue.builder("v2b").addSubParam("sk1", "sv1")))
+            .build();
+
+    assertThat(fragment.toString()).isEqualTo("k1=v1&k2=v2+v2a+v2b(sk1=sv1)");
+  }
+
+  @Test
+  public void builder_nestedMutation() throws Exception {
+    Fragment.Builder fragment = Fragment.parse("k0=v0a&k1=v1&k2=v2+v2a+v2b(sk1=sv1)").toBuilder();
+
+    fragment.findParam("k0").addValue("v0b");
+    fragment.findParam("k1").findValue("v1").addSubParam("sk1", "sv1");
+    fragment.findParam("k2").findValue("v2b").addSubParam("sk2", "sv2");
+
+    assertThat(fragment.build().toString())
+        .isEqualTo("k0=v0a+v0b&k1=v1(sk1=sv1)&k2=v2+v2a+v2b(sk1=sv1,sk2=sv2)");
+  }
+
+  @Test
+  public void parse_multipleValues() throws Exception {
+    Fragment fragment = Fragment.parse("k0=v0&k1=v1&k2=v2+v2a+v2b(sk1=sv1)");
+
+    Fragment.Param p0 = fragment.params().get(0);
+    assertThat(p0.key()).isEqualTo("k0");
+    Fragment.ParamValue v0 = p0.values().get(0);
+    assertThat(v0.name()).isEqualTo("v0");
+    assertThat(v0.subParams().isEmpty()).isTrue();
+
+    Fragment.Param p1 = fragment.params().get(1);
+    assertThat(p1.key()).isEqualTo("k1");
+    Fragment.ParamValue v1 = p1.values().get(0);
+    assertThat(v1.name()).isEqualTo("v1");
+    assertThat(v1.subParams().isEmpty()).isTrue();
+
+    Fragment.Param p2 = fragment.params().get(2);
+    assertThat(p2.key()).isEqualTo("k2");
+    Fragment.ParamValue v2 = p2.values().get(0);
+    assertThat(v2.name()).isEqualTo("v2");
+    assertThat(v2.subParams().isEmpty()).isTrue();
+
+    Fragment.ParamValue v2a = p2.values().get(1);
+    assertThat(v2a.name()).isEqualTo("v2a");
+    assertThat(v2a.subParams().isEmpty()).isTrue();
+
+    Fragment.ParamValue v2b = p2.values().get(2);
+    assertThat(v2b.name()).isEqualTo("v2b");
+    assertThat(v2b.subParams().size()).isEqualTo(1);
+    Fragment.SubParam v2bs1 = v2b.subParams().get(0);
+    assertThat(v2bs1.key()).isEqualTo("sk1");
+    assertThat(v2bs1.value()).isEqualTo("sv1");
+  }
+
+  @Test
+  public void parse_duplicateParams() throws Exception {
+    Fragment fragment = Fragment.parse("k=1&k=2");
+    assertThat(fragment.params().get(0).values().get(0).name()).isEqualTo("2");
+  }
+
+  @Test
+  public void parse_duplicateParamValues() throws Exception {
+    Fragment fragment = Fragment.parse("k=1+1(x=y)");
+    assertThat(fragment.params().get(0).values().get(0).findSubParamValue("x")).isEqualTo("y");
+  }
+
+  @Test
+  public void parse_duplicatesubParams() throws Exception {
+    Fragment fragment = Fragment.parse("k=1(x=y,x=z)");
+    assertThat(fragment.params().get(0).values().get(0).findSubParamValue("x")).isEqualTo("z");
+  }
+
+  @Test
+  public void parse_duplicateUnsetSubParams() throws Exception {
+    Fragment fragment = Fragment.parse("k=1(x=y,x)");
+    assertThat(fragment.params().get(0).values().get(0).findSubParam("x")).isNotNull();
+    assertThat(fragment.params().get(0).values().get(0).findSubParam("x").hasValue()).isFalse();
+    assertThat(fragment.params().get(0).values().get(0).findSubParamValue("x")).isNull();
+  }
+
+  @Test
+  public void parse_unsetSubParam() throws Exception {
+    Fragment fragment = Fragment.parse("p=v(sp1)");
+    assertThat(fragment.findParam("p").findValue("v").findSubParam("sp1")).isNotNull();
+    assertThat(fragment.findParam("p").findValue("v").findSubParam("sp1").hasValue()).isFalse();
+    assertThat(fragment.findParam("p").findValue("v").findSubParamValue("sp1")).isNull();
+  }
+
+  @Test
+  public void roundTrip() throws Exception {
+    Fragment fragment = Fragment.parse("a=b&c=d(e=f,g=h,i=j)+e+f");
+
+    assertThat(fragment.toString()).isEqualTo("a=b&c=d(e=f,g=h,i=j)+e+f");
+  }
+
+  @Test
+  public void parse_illegal() throws Exception {
+    assertThrows(IllegalArgumentException.class, () -> Fragment.parse("x"));
+    assertThrows(IllegalArgumentException.class, () -> Fragment.parse("x="));
+
+    assertThrows(IllegalArgumentException.class, () -> Fragment.parse("="));
+    assertThrows(IllegalArgumentException.class, () -> Fragment.parse("=="));
+    assertThrows(IllegalArgumentException.class, () -> Fragment.parse("=x"));
+
+    assertThrows(IllegalArgumentException.class, () -> Fragment.parse("x=y("));
+    assertThrows(IllegalArgumentException.class, () -> Fragment.parse("x=y)"));
+    assertThrows(IllegalArgumentException.class, () -> Fragment.parse("x=y()"));
+
+    assertThrows(IllegalArgumentException.class, () -> Fragment.parse("x=y(=)"));
+    assertThrows(IllegalArgumentException.class, () -> Fragment.parse("x=y(==)"));
+    assertThrows(IllegalArgumentException.class, () -> Fragment.parse("x=y(=z)"));
+  }
+
+  @Test
+  public void parse_weirdButNotIllegal() throws Exception {
+    // Unencoded chars gets encoded
+    assertThat(Fragment.parse(" =y").toString()).isEqualTo("+=y");
+    assertThat(Fragment.parse(" x=y ").toString()).isEqualTo("+x=y+");
+    assertThat(Fragment.parse("()=y").toString()).isEqualTo("%28%29=y");
+    assertThat(Fragment.parse("x==y").toString()).isEqualTo("x=%3Dy");
+    assertThat(Fragment.parse("x=y(z==)").toString()).isEqualTo("x=y(z=%3D)");
+
+    assertThat(Fragment.parse("x=y(z=)").toString()).isEqualTo("x=y(z)");
+    assertThat(Fragment.parse("+=y").toString()).isEqualTo("+=y");
+    assertThat(Fragment.parse("").toString()).isEmpty();
+    assertThat(Fragment.parse((String) null).toString()).isEmpty();
+  }
+
+  @Test
+  public void build_escapingInvalidCharacters() throws Exception {
+    Fragment fragment =
+        Fragment.builder()
+            .addParam(
+                Fragment.Param.builder("m&m")
+                    .addValue(Fragment.ParamValue.builder("2+2").addSubParam("k=", "(v)")))
+            .build();
+    assertThat(fragment.toString()).isEqualTo("m%26m=2%2B2(k%3D=%28v%29)");
+
+    Fragment roundtrip = Fragment.parse(fragment.toString());
+    Fragment.Param mnm = roundtrip.params().get(0);
+    assertThat(mnm.key()).isEqualTo("m&m");
+    Fragment.ParamValue twoptwo = mnm.values().get(0);
+    assertThat(twoptwo.name()).isEqualTo("2+2");
+    Fragment.SubParam pqp = twoptwo.subParams().get(0);
+    assertThat(pqp.key()).isEqualTo("k=");
+    assertThat(pqp.value()).isEqualTo("(v)");
+  }
+
+  @Test
+  public void toBuilder_shouldMakeDefensiveCopy() throws Exception {
+    Fragment fragment = Fragment.parse("a=b(c=d)");
+    Fragment.Builder fragmentBuilder = fragment.toBuilder();
+    assertThat(fragment.toString()).isEqualTo("a=b(c=d)");
+    assertThat(fragmentBuilder.build().toString()).isEqualTo("a=b(c=d)");
+
+    fragmentBuilder.addParam(Fragment.Param.builder("X").addValue("XX"));
+    fragmentBuilder.findParam("a").addValue("Y");
+    fragmentBuilder.findParam("a").findValue("b").addSubParam("Z", "ZZ");
+
+    assertThat(fragment.toString()).isEqualTo("a=b(c=d)");
+    assertThat(fragmentBuilder.build().toString()).isEqualTo("a=b(c=d,Z=ZZ)+Y&X=XX");
+  }
+
+  @Test
+  public void uri_withValidCharacters() throws Exception {
+    String encodedFragmentString = "a=b+c(d=e)";
+    Uri uri = Uri.parse("a://b/c").buildUpon().encodedFragment(encodedFragmentString).build();
+    assertThat(uri.toString()).isEqualTo("a://b/c#a=b+c(d=e)");
+    assertThat(uri.getEncodedFragment()).isEqualTo(encodedFragmentString);
+    Fragment roundtrip = Fragment.parse(uri);
+    Fragment.Param a = roundtrip.params().get(0);
+    assertThat(a.key()).isEqualTo("a");
+    Fragment.ParamValue b = a.values().get(0);
+    assertThat(b.name()).isEqualTo("b");
+    Fragment.ParamValue c = a.values().get(1);
+    assertThat(c.name()).isEqualTo("c");
+    Fragment.SubParam de = c.subParams().get(0);
+    assertThat(de.key()).isEqualTo("d");
+    assertThat(de.value()).isEqualTo("e");
+  }
+
+  @Test
+  public void uri_withInvalidCharacters() throws Exception {
+    String encodedFragmentString = "m%26m=2%2B2(k%3D=%28v%29)";
+    Uri uri = Uri.parse("a://b/c").buildUpon().encodedFragment(encodedFragmentString).build();
+    assertThat(uri.toString()).isEqualTo("a://b/c#m%26m=2%2B2(k%3D=%28v%29)");
+    assertThat(uri.getEncodedFragment()).isEqualTo(encodedFragmentString);
+    Fragment roundtrip = Fragment.parse(uri);
+    Fragment.Param mnm = roundtrip.params().get(0);
+    assertThat(mnm.key()).isEqualTo("m&m");
+    Fragment.ParamValue twoptwo = mnm.values().get(0);
+    assertThat(twoptwo.name()).isEqualTo("2+2");
+    Fragment.SubParam pqp = twoptwo.subParams().get(0);
+    assertThat(pqp.key()).isEqualTo("k=");
+    assertThat(pqp.value()).isEqualTo("(v)");
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/common/GcParamTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/common/GcParamTest.java
new file mode 100644
index 0000000..f78ead3
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/common/GcParamTest.java
@@ -0,0 +1,96 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import com.google.thirdparty.robolectric.GoogleRobolectricTestRunner;
+import java.util.Date;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(GoogleRobolectricTestRunner.class)
+public class GcParamTest {
+
+  @Test
+  public void none_shouldHaveNoneTagAndNoExpiration() {
+    GcParam none = GcParam.none();
+    assertThat(none).isNotNull();
+    assertThat(none.isExpiresAt()).isFalse();
+    assertThrows(IllegalStateException.class, () -> none.getExpiration());
+    assertThat(none.isNone()).isTrue();
+  }
+
+  @Test
+  public void expiresAt_shouldHaveExpiresAtTagAndExpiration() {
+
+    Date date1 = new Date(1L);
+    Date date2 = new Date(2L);
+    GcParam expiresAt1 = GcParam.expiresAt(date1);
+    GcParam expiresAt2 = GcParam.expiresAt(date2);
+
+    assertThat(expiresAt1).isNotNull();
+    assertThat(expiresAt2).isNotNull();
+    assertThat(expiresAt1.isNone()).isFalse();
+    assertThat(expiresAt2.isNone()).isFalse();
+    assertThat(expiresAt1.isExpiresAt()).isTrue();
+    assertThat(expiresAt2.isExpiresAt()).isTrue();
+    assertThat(expiresAt1.getExpiration()).isEqualTo(date1);
+    assertThat(expiresAt2.getExpiration()).isEqualTo(date2);
+  }
+
+  @Test
+  public void equals_shouldCompareDataMembers() {
+
+    GcParam expiresAt1 = GcParam.expiresAt(new Date(1L));
+    GcParam expiresAt2 = GcParam.expiresAt(new Date(2L));
+    GcParam expiresAt1b = GcParam.expiresAt(new Date(1L));
+    GcParam none1 = GcParam.none();
+    GcParam none2 = GcParam.none();
+
+    assertThat(expiresAt1).isNotEqualTo(null);
+    assertThat(expiresAt2).isNotEqualTo(null);
+    assertThat(expiresAt1b).isNotEqualTo(null);
+    assertThat(none1).isNotEqualTo(null);
+    assertThat(none2).isNotEqualTo(null);
+
+    assertThat(expiresAt1).isNotEqualTo(expiresAt2);
+    assertThat(expiresAt1).isEqualTo(expiresAt1b);
+    assertThat(expiresAt1).isNotEqualTo(none1);
+    assertThat(expiresAt1).isNotEqualTo(none2);
+
+    assertThat(expiresAt2).isNotEqualTo(expiresAt1);
+    assertThat(expiresAt2).isNotEqualTo(expiresAt1b);
+    assertThat(expiresAt2).isNotEqualTo(none1);
+    assertThat(expiresAt2).isNotEqualTo(none2);
+
+    assertThat(expiresAt1b).isEqualTo(expiresAt1);
+    assertThat(expiresAt1b).isNotEqualTo(expiresAt2);
+    assertThat(expiresAt1b).isNotEqualTo(none1);
+    assertThat(expiresAt1b).isNotEqualTo(none2);
+
+    assertThat(none1).isNotEqualTo(expiresAt1);
+    assertThat(none1).isNotEqualTo(expiresAt2);
+    assertThat(none1).isNotEqualTo(expiresAt1b);
+    assertThat(none1).isEqualTo(none2);
+
+    assertThat(none2).isNotEqualTo(expiresAt1);
+    assertThat(none2).isNotEqualTo(expiresAt2);
+    assertThat(none2).isNotEqualTo(expiresAt1b);
+    assertThat(none2).isEqualTo(none1);
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/common/LockScopeTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/common/LockScopeTest.java
new file mode 100644
index 0000000..67fe88d
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/common/LockScopeTest.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.net.Uri;
+import com.google.thirdparty.robolectric.GoogleRobolectricTestRunner;
+import java.io.IOException;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.Semaphore;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(GoogleRobolectricTestRunner.class)
+public class LockScopeTest {
+
+  @Test
+  public void createWithSharedThreadLocks_sharesThreadLocksAcrossInstances() throws IOException {
+    ConcurrentMap<String, Semaphore> lockMap = new ConcurrentHashMap<>();
+    LockScope lockScope = LockScope.createWithExistingThreadLocks(lockMap);
+    LockScope otherLockScope = LockScope.createWithExistingThreadLocks(lockMap);
+    Uri uri = Uri.parse("file:///dummy");
+
+    try (Lock lock = lockScope.threadLock(uri)) {
+      assertThat(otherLockScope.tryThreadLock(uri)).isNull();
+    }
+
+    assertThat(otherLockScope.tryThreadLock(uri)).isNotNull();
+  }
+
+  @Test
+  public void createWithFailingThreadLocks_willFailToAcquireThreadLocks() throws IOException {
+    LockScope lockScope = LockScope.createWithFailingThreadLocks();
+    Uri uri = Uri.parse("file:///dummy");
+
+    assertThrows(UnsupportedFileStorageOperation.class, () -> lockScope.threadLock(uri));
+    assertThat(lockScope.tryThreadLock(uri)).isNull();
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/common/internal/BUILD b/javatests/com/google/android/libraries/mobiledatadownload/file/common/internal/BUILD
new file mode 100644
index 0000000..396d6a6
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/common/internal/BUILD
@@ -0,0 +1,64 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_local_test")
+
+package(
+    default_visibility = ["//:__subpackages__"],
+    licenses = ["notice"],
+)
+
+android_local_test(
+    name = "BackendInputStreamTest",
+    size = "small",
+    srcs = ["BackendInputStreamTest.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/internal:backend_stream",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "BackendOutputStreamTest",
+    size = "small",
+    srcs = ["BackendOutputStreamTest.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/internal:backend_stream",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "LazyByteArrayInputStreamTest",
+    size = "small",
+    srcs = ["LazyByteArrayInputStreamTest.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/internal:lazy_stream",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "LiteTransformFragmentsTest",
+    size = "small",
+    srcs = ["LiteTransformFragmentsTest.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/common:fragment",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/internal:lite_transform_fragments",
+        "@com_google_guava_guava",
+        "@truth",
+    ],
+)
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/common/internal/BackendInputStreamTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/common/internal/BackendInputStreamTest.java
new file mode 100644
index 0000000..c5bd3fe
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/common/internal/BackendInputStreamTest.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common.internal;
+
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.makeArrayOfBytesContent;
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.writeFileToSink;
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.thirdparty.robolectric.GoogleRobolectricTestRunner;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+
+/** Basic sanity tests for BackendInputStream; more complex behaviors are tested elsewhere. */
+@RunWith(GoogleRobolectricTestRunner.class)
+public final class BackendInputStreamTest {
+
+  @Rule public TemporaryFolder tmpFolder = new TemporaryFolder();
+
+  @Test
+  public void toFile_returnsInputFile() throws IOException {
+    File file = tmpFolder.newFile();
+    try (BackendInputStream stream = BackendInputStream.create(file)) {
+      assertThat(stream.toFile()).isSameInstanceAs(file);
+    }
+  }
+
+  @Test
+  public void toFileChannel_returnsInputFileChannel() throws IOException {
+    File file = tmpFolder.newFile();
+    writeFileToSink(new FileOutputStream(file), makeArrayOfBytesContent(5));
+    try (BackendInputStream stream = BackendInputStream.create(file)) {
+      assertThat(stream.toFileChannel().size()).isEqualTo(5);
+    }
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/common/internal/BackendOutputStreamTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/common/internal/BackendOutputStreamTest.java
new file mode 100644
index 0000000..06d6110
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/common/internal/BackendOutputStreamTest.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common.internal;
+
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.makeArrayOfBytesContent;
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.writeFileToSink;
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.thirdparty.robolectric.GoogleRobolectricTestRunner;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+
+/** Basic sanity tests for BackendOutputStream; more complex behaviors are tested elsewhere. */
+@RunWith(GoogleRobolectricTestRunner.class)
+public final class BackendOutputStreamTest {
+
+  @Rule public TemporaryFolder tmpFolder = new TemporaryFolder();
+
+  @Test
+  public void toFile_returnsInputFile() throws IOException {
+    File file = tmpFolder.newFile();
+    try (BackendOutputStream stream = BackendOutputStream.createForWrite(file)) {
+      assertThat(stream.toFile()).isSameInstanceAs(file);
+    }
+  }
+
+  @Test
+  public void toFileChannel_returnsInputFileChannel() throws IOException {
+    File file = tmpFolder.newFile();
+    writeFileToSink(new FileOutputStream(file), makeArrayOfBytesContent(5));
+    try (BackendOutputStream stream = BackendOutputStream.createForAppend(file)) {
+      assertThat(stream.toFileChannel().size()).isEqualTo(5);
+    }
+  }
+
+  @Test
+  public void sync_flushesStream() throws IOException {
+    File file = tmpFolder.newFile();
+    try (BackendOutputStream stream = BackendOutputStream.createForWrite(file)) {
+      // NOTE: testing sync() behavior requires an android_emulator_test and is covered by
+      // SyncingBehaviorAndroidTest. In the interest of quick tests, just ensure this doesn't fail.
+      stream.sync();
+    }
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/common/internal/LazyByteArrayInputStreamTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/common/internal/LazyByteArrayInputStreamTest.java
new file mode 100644
index 0000000..08777ec
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/common/internal/LazyByteArrayInputStreamTest.java
@@ -0,0 +1,154 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common.internal;
+
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.readFileFromSource;
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.Assert.assertThrows;
+
+import com.google.thirdparty.robolectric.GoogleRobolectricTestRunner;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.concurrent.Callable;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(GoogleRobolectricTestRunner.class)
+public final class LazyByteArrayInputStreamTest {
+
+  private static final String CONTENT = "The five boxing wizards jump quickly";
+  private static final Callable<byte[]> CONTENT_CALLABLE = () -> CONTENT.getBytes(UTF_8);
+
+  @Test
+  public void callableIsCalledLazilyExactlyOnce() throws IOException {
+    SpyCallable<byte[]> spy = new SpyCallable<byte[]>(CONTENT_CALLABLE);
+    InputStream stream = new LazyByteArrayInputStream(spy);
+
+    assertThat(spy.callCount()).isEqualTo(0);
+    stream.read();
+    stream.read();
+    assertThat(spy.callCount()).isEqualTo(1);
+  }
+
+  @Test
+  public void exceptionFromCallableIsWrappedAndPropagated() throws IOException {
+    Callable<byte[]> genericThrower =
+        () -> {
+          throw new Exception("generic");
+        };
+    InputStream genericStream = new LazyByteArrayInputStream(genericThrower);
+    IOException genericThrown = assertThrows(IOException.class, () -> genericStream.read());
+    assertThat(genericThrown).hasCauseThat().hasMessageThat().isEqualTo("generic");
+
+    Callable<byte[]> ioCallable =
+        () -> {
+          throw new IOException("io");
+        };
+    InputStream ioStream = new LazyByteArrayInputStream(ioCallable);
+    IOException ioThrown = assertThrows(IOException.class, () -> ioStream.read());
+    assertThat(ioThrown).hasMessageThat().isEqualTo("io");
+  }
+
+  @Test
+  public void available_onStreamThatHasNotBeenRead_returnsZero() throws IOException {
+    SpyCallable<byte[]> spy = new SpyCallable<byte[]>(CONTENT_CALLABLE);
+    InputStream stream = new LazyByteArrayInputStream(spy);
+
+    assertThat(stream.available()).isEqualTo(0);
+    assertThat(spy.callCount()).isEqualTo(0);
+  }
+
+  @Test
+  public void available_returnsRemainingCallableBytes() throws IOException {
+    InputStream stream = new LazyByteArrayInputStream(CONTENT_CALLABLE);
+
+    stream.skip(3);
+    assertThat(stream.available()).isEqualTo(CONTENT.length() - 3);
+  }
+
+  @Test
+  public void close_callsCallable() throws IOException {
+    SpyCallable<byte[]> spy = new SpyCallable<byte[]>(CONTENT_CALLABLE);
+    InputStream stream = new LazyByteArrayInputStream(spy);
+
+    stream.close();
+    assertThat(spy.callCount()).isEqualTo(1);
+  }
+
+  @Test
+  public void read_returnsCallableBytes() throws IOException {
+    InputStream stream = new LazyByteArrayInputStream(CONTENT_CALLABLE);
+    assertThat(readFileFromSource(stream)).isEqualTo(CONTENT);
+  }
+
+  @Test
+  public void skip_skipsCallableBytes() throws IOException {
+    InputStream stream = new LazyByteArrayInputStream(CONTENT_CALLABLE);
+    stream.skip(5);
+    assertThat(readFileFromSource(stream)).isEqualTo(CONTENT.substring(5));
+  }
+
+  @Test
+  public void markSupported_returnsTrue() throws IOException {
+    InputStream stream = new LazyByteArrayInputStream(CONTENT_CALLABLE);
+    assertThat(stream.markSupported()).isTrue();
+  }
+
+  @Test
+  public void reset_fromFirstByte() throws IOException {
+    // Read whole string, reset to default mark position = 0, read same thing
+    InputStream stream = new LazyByteArrayInputStream(CONTENT_CALLABLE);
+
+    readFileFromSource(stream);
+    stream.reset();
+    assertThat(readFileFromSource(stream)).isEqualTo(CONTENT);
+  }
+
+  @Test
+  public void markAndReset_fromMidwayByte() throws IOException {
+    // Skip 10 and mark, read remainder, reset to marked 10 position, read same thing
+    InputStream stream = new LazyByteArrayInputStream(CONTENT_CALLABLE);
+
+    stream.skip(10);
+    stream.mark(0); // arbitrary readlimit
+
+    readFileFromSource(stream);
+    stream.reset();
+    assertThat(readFileFromSource(stream)).isEqualTo(CONTENT.substring(10));
+  }
+
+  /** A substitute for Mockito.spy(Callable<T>) since Mockito can't wrap anonymous classes. */
+  private static final class SpyCallable<T> implements Callable<T> {
+    private final Callable<T> delegate;
+    private int callCount;
+
+    SpyCallable(Callable<T> delegate) {
+      this.delegate = delegate;
+      this.callCount = 0;
+    }
+
+    @Override
+    public T call() throws Exception {
+      callCount++;
+      return delegate.call();
+    }
+
+    int callCount() {
+      return callCount;
+    }
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/common/internal/LiteTransformFragmentsTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/common/internal/LiteTransformFragmentsTest.java
new file mode 100644
index 0000000..364cb3b
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/common/internal/LiteTransformFragmentsTest.java
@@ -0,0 +1,152 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common.internal;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.common.Fragment;
+import com.google.common.collect.ImmutableList;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public final class LiteTransformFragmentsTest {
+
+  @Test
+  public void parseAbsentTransformFragment_yieldsEmpty() throws Exception {
+    Uri uri = Uri.parse("scheme:path");
+    assertThat(LiteTransformFragments.parseTransformNames(uri)).isEmpty();
+  }
+
+  @Test
+  public void parseEmptyTransformFragment_yieldsEmpty() throws Exception {
+    Uri uri = Uri.parse("scheme:path#");
+    assertThat(LiteTransformFragments.parseTransformNames(uri)).isEmpty();
+  }
+
+  @Test
+  public void parseNonTransformFragment_yieldsEmpty() throws Exception {
+    Uri uri = Uri.parse("scheme:path#nontransform");
+    assertThat(LiteTransformFragments.parseTransformNames(uri)).isEmpty();
+  }
+
+  @Test
+  public void parseSimpleTransformFragment_yieldsSpec() throws Exception {
+    Uri uri = Uri.parse("scheme:path#transform=simple");
+    assertThat(LiteTransformFragments.parseTransformNames(uri)).containsExactly("simple");
+  }
+
+  @Test
+  public void parseMixedTransformFragment_yieldsSpec() throws Exception {
+    Uri uri = Uri.parse("scheme:path#transform=M1X_d");
+    assertThat(LiteTransformFragments.parseTransformNames(uri)).containsExactly("M1X_d");
+  }
+
+  @Test
+  public void parseEncodedTransformFragment_yieldsInvalidSpec() throws Exception {
+    // Trailing "%3D" is ignored.
+    Uri uri = Uri.parse("scheme:path#transform=INVALID%3D");
+    assertThat(LiteTransformFragments.parseTransformNames(uri)).containsExactly("INVALID");
+  }
+
+  @Test
+  public void parseTransformBeforeOtherFragment_yieldsSpec() throws Exception {
+    Uri uri = Uri.parse("scheme:path#transform=beforeother&other");
+    assertThat(LiteTransformFragments.parseTransformNames(uri)).containsExactly("beforeother");
+  }
+
+  @Test
+  public void parseTransformAfterOtherFragment_yieldsEmpty() throws Exception {
+    Uri uri = Uri.parse("scheme:path#nontransform&transform=afterother");
+    assertThat(LiteTransformFragments.parseTransformNames(uri)).isEmpty();
+  }
+
+  @Test
+  public void parseMultipleTransformFragments_yieldsAllSpecs() throws Exception {
+    Uri uri = Uri.parse("scheme:path#transform=first+second+third");
+    assertThat(LiteTransformFragments.parseTransformNames(uri))
+        .containsExactly("first", "second", "third");
+  }
+
+  @Test
+  public void parseTransformFragmentWithSubparams_yieldsJustName() throws Exception {
+    Uri uri = Uri.parse("scheme:path#transform=withparams(foo=bar)");
+    assertThat(LiteTransformFragments.parseTransformNames(uri)).containsExactly("withparams");
+  }
+
+  @Test
+  public void parseMultipleTransformFragmentsWithSubparams_yieldsAllNames() throws Exception {
+    Uri uri = Uri.parse("scheme:path#transform=first(foo=bar)+second(yada=yada,x=y)+third(xxx)");
+    assertThat(LiteTransformFragments.parseTransformNames(uri))
+        .containsExactly("first", "second", "third");
+  }
+
+  @Test
+  public void parseTransformFragmentWithEncodedSubparams_yieldsJustName() throws Exception {
+    Uri uri = Uri.parse("scheme:path#transform=withencoded(k%3D=%28v%29)");
+    assertThat(LiteTransformFragments.parseTransformNames(uri)).containsExactly("withencoded");
+  }
+
+  @Test
+  public void joinEmpty_yieldNil() throws Exception {
+    String encodedFragment = LiteTransformFragments.joinTransformSpecs(ImmutableList.of());
+    assertThat(encodedFragment).isNull();
+
+    // NOTE: Android Uri treats null as removing the fragment.
+    Uri uri = Uri.parse("scheme:path#REMOVED").buildUpon().encodedFragment(encodedFragment).build();
+    assertThat(uri.toString()).isEqualTo("scheme:path");
+  }
+
+  @Test
+  public void joinSimple_yieldFragment() throws Exception {
+    assertThat(LiteTransformFragments.joinTransformSpecs(ImmutableList.of("simple")))
+        .isEqualTo("transform=simple");
+  }
+
+  @Test
+  public void joinMultiple_yieldFragment() throws Exception {
+    assertThat(LiteTransformFragments.joinTransformSpecs(ImmutableList.of("first", "second")))
+        .isEqualTo("transform=first+second");
+  }
+
+  @Test
+  public void joinMultipleWithParams_yieldEncodedFragment() throws Exception {
+    String fragment =
+        LiteTransformFragments.joinTransformSpecs(
+            ImmutableList.of("first(foo=bar)", "second(k%3D=%28v%29)"));
+    assertThat(fragment).isEqualTo("transform=first(foo=bar)+second(k%3D=%28v%29)");
+    Uri uri = Uri.parse("scheme:path").buildUpon().encodedFragment(fragment).build();
+
+    // Run it through the full fragment parser to ensure output is valid.
+    Fragment fullFragment = Fragment.parse(uri);
+    Fragment.Param transform = fullFragment.params().get(0);
+    assertThat(transform.key()).isEqualTo("transform");
+
+    Fragment.ParamValue first = transform.values().get(0);
+    assertThat(first.name()).isEqualTo("first");
+    Fragment.SubParam foo = first.subParams().get(0);
+    assertThat(foo.key()).isEqualTo("foo");
+    assertThat(foo.value()).isEqualTo("bar");
+
+    Fragment.ParamValue second = transform.values().get(1);
+    assertThat(second.name()).isEqualTo("second");
+    Fragment.SubParam kequal = second.subParams().get(0);
+    assertThat(kequal.key()).isEqualTo("k=");
+    assertThat(kequal.value()).isEqualTo("(v)");
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/common/testing/BUILD b/javatests/com/google/android/libraries/mobiledatadownload/file/common/testing/BUILD
new file mode 100644
index 0000000..1861c30
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/common/testing/BUILD
@@ -0,0 +1,32 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_local_test")
+
+package(
+    default_visibility = ["//:__subpackages__"],
+    licenses = ["notice"],
+)
+
+android_local_test(
+    name = "FakeFileBackendTest",
+    size = "small",
+    srcs = ["FakeFileBackendTest.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing:extras",
+        "//java/com/google/android/libraries/mobiledatadownload/file/spi",
+        "@com_google_guava_guava",
+    ],
+)
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/common/testing/FakeFileBackendTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/common/testing/FakeFileBackendTest.java
new file mode 100644
index 0000000..bc1f90a
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/common/testing/FakeFileBackendTest.java
@@ -0,0 +1,156 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.common.testing;
+
+import static org.junit.Assert.assertThrows;
+
+import android.net.Uri;
+import android.util.Pair;
+import com.google.android.libraries.mobiledatadownload.file.common.GcParam;
+import com.google.android.libraries.mobiledatadownload.file.common.UnsupportedFileStorageOperation;
+import com.google.android.libraries.mobiledatadownload.file.spi.Backend;
+import com.google.common.collect.ImmutableList;
+import java.io.Closeable;
+import java.io.IOException;
+import java.util.List;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public final class FakeFileBackendTest extends BackendTestBase {
+  private final FakeFileBackend backend = new FakeFileBackend();
+
+  @Rule public TemporaryUri tmpUri = new TemporaryUri();
+
+  @Override
+  protected Backend backend() {
+    return backend;
+  }
+
+  @Override
+  protected Uri legalUriBase() throws IOException {
+    return tmpUri.newDirectoryUri();
+  }
+
+  @Override
+  protected List<Uri> illegalUrisToRead() {
+    return ImmutableList.of();
+  }
+
+  @Override
+  protected List<Uri> illegalUrisToWrite() {
+    return ImmutableList.of();
+  }
+
+  @Override
+  protected List<Uri> illegalUrisToAppend() {
+    return ImmutableList.of();
+  }
+
+  @Test
+  public void throwsExceptions_forRead() throws Exception {
+    Uri uri = tmpUri.newUri();
+    backend.setFailure(FakeFileBackend.OperationType.READ, new FakeIOException("test"));
+    assertThrows(FakeIOException.class, () -> backend.openForRead(uri));
+    assertThrows(FakeIOException.class, () -> backend.openForNativeRead(uri));
+
+    backend.clearFailure(FakeFileBackend.OperationType.READ);
+    try (Closeable resource = backend.openForRead(uri)) {}
+    Pair<Uri, Closeable> nativeOpen = backend.openForNativeRead(uri);
+    try (Closeable resource = nativeOpen.second) {}
+  }
+
+  @Test
+  public void throwsExceptions_forWrite() throws Exception {
+    Uri uri = tmpUri.newUri();
+    backend.setFailure(FakeFileBackend.OperationType.WRITE, new FakeIOException("test"));
+    assertThrows(FakeIOException.class, () -> backend.openForWrite(uri));
+    assertThrows(FakeIOException.class, () -> backend.openForAppend(uri));
+
+    backend.clearFailure(FakeFileBackend.OperationType.WRITE);
+    try (Closeable resource = backend.openForWrite(uri)) {}
+    try (Closeable resource = backend.openForAppend(uri)) {}
+  }
+
+  @Test
+  public void throwsExceptions_forQuery() throws Exception {
+    Uri uri = tmpUri.newUri();
+    Uri dir = tmpUri.newDirectoryUri();
+
+    backend.setFailure(FakeFileBackend.OperationType.QUERY, new FakeIOException("test"));
+    assertThrows(FakeIOException.class, () -> backend.exists(uri));
+    assertThrows(FakeIOException.class, () -> backend.isDirectory(uri));
+    assertThrows(FakeIOException.class, () -> backend.fileSize(uri));
+    assertThrows(FakeIOException.class, () -> backend.children(dir));
+    assertThrows(FakeIOException.class, () -> backend.getGcParam(uri));
+    assertThrows(FakeIOException.class, () -> backend.toFile(uri));
+
+    backend.clearFailure(FakeFileBackend.OperationType.QUERY);
+    backend.exists(uri);
+    backend.isDirectory(uri);
+    backend.fileSize(uri);
+    backend.children(dir);
+    assertThrows(UnsupportedFileStorageOperation.class, () -> backend.getGcParam(uri));
+    backend.toFile(uri);
+  }
+
+  @Test
+  public void throwsExceptions_forManage() throws Exception {
+    Uri uri = tmpUri.newUri();
+    Uri uri2 = tmpUri.newUri();
+    Uri dir = tmpUri.newDirectoryUri();
+
+    backend.setFailure(FakeFileBackend.OperationType.MANAGE, new FakeIOException("test"));
+    assertThrows(FakeIOException.class, () -> backend.deleteFile(uri));
+    assertThrows(FakeIOException.class, () -> backend.deleteDirectory(dir));
+    assertThrows(FakeIOException.class, () -> backend.rename(uri, uri2));
+    assertThrows(FakeIOException.class, () -> backend.createDirectory(dir));
+    assertThrows(FakeIOException.class, () -> backend.setGcParam(uri, GcParam.none()));
+
+    backend.clearFailure(FakeFileBackend.OperationType.MANAGE);
+    assertThrows(
+        UnsupportedFileStorageOperation.class, () -> backend.setGcParam(uri, GcParam.none()));
+    backend.rename(uri, uri2);
+    backend.deleteFile(uri2);
+    backend.deleteDirectory(dir);
+    backend.createDirectory(dir);
+  }
+
+  @Test
+  public void throwsExceptions_forAll() throws Exception {
+    Uri uri = tmpUri.newUri();
+
+    backend.setFailure(FakeFileBackend.OperationType.ALL, new FakeIOException("test"));
+    assertThrows(FakeIOException.class, () -> backend.openForRead(uri)); // READ
+    assertThrows(FakeIOException.class, () -> backend.openForWrite(uri)); // WRITE
+    assertThrows(FakeIOException.class, () -> backend.exists(uri)); // QUERY
+    assertThrows(FakeIOException.class, () -> backend.deleteFile(uri)); // MANAGE
+
+    backend.clearFailure(FakeFileBackend.OperationType.ALL);
+    try (Closeable resource = backend.openForRead(uri)) {}
+    try (Closeable resource = backend.openForWrite(uri)) {}
+    backend.exists(uri);
+    backend.deleteFile(uri);
+  }
+
+  private static class FakeIOException extends IOException {
+    FakeIOException(String message) {
+      super(message);
+    }
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/integration/downloader/BUILD b/javatests/com/google/android/libraries/mobiledatadownload/file/integration/downloader/BUILD
new file mode 100644
index 0000000..f054997
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/integration/downloader/BUILD
@@ -0,0 +1,37 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_local_test")
+
+package(
+    default_visibility = ["//:__subpackages__"],
+    licenses = ["notice"],
+)
+
+android_local_test(
+    name = "DownloadDestinationOpenerTest",
+    size = "small",
+    srcs = ["DownloadDestinationOpenerTest.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/integration/downloader:downloader2",
+        "//java/com/google/android/libraries/mobiledatadownload/file/integration/downloader:downloader2_sp",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:bytes",
+        "@androidx_test",
+        "@com_google_guava_guava",
+        "@downloader",
+        "@truth",
+    ],
+)
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/integration/downloader/DownloadDestinationOpenerTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/integration/downloader/DownloadDestinationOpenerTest.java
new file mode 100644
index 0000000..5688d55
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/integration/downloader/DownloadDestinationOpenerTest.java
@@ -0,0 +1,233 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.integration.downloader;
+
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.makeArrayOfBytesContent;
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import android.content.SharedPreferences;
+import android.net.Uri;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.android.downloader.DownloadDestination;
+import com.google.android.downloader.DownloadMetadata;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.JavaFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.TemporaryUri;
+import com.google.android.libraries.mobiledatadownload.file.openers.ReadByteArrayOpener;
+import com.google.android.libraries.mobiledatadownload.file.openers.WriteByteArrayOpener;
+import com.google.common.collect.ImmutableList;
+import com.google.common.primitives.Bytes;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+import java.nio.ByteBuffer;
+import java.nio.channels.WritableByteChannel;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeoutException;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.ParameterizedRobolectricTestRunner;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameters;
+
+@RunWith(ParameterizedRobolectricTestRunner.class)
+public final class DownloadDestinationOpenerTest {
+  private static final long TIMEOUT = 3L;
+
+  private static final byte[] CONTENT = makeArrayOfBytesContent();
+
+  private static final ListeningExecutorService EXECUTOR_SERVICE =
+      MoreExecutors.listeningDecorator(Executors.newCachedThreadPool());
+
+  private SynchronousFileStorage storage;
+
+  private final Implementation implUnderTest;
+  private SharedPreferences downloadMetadataSp;
+
+  @Rule public TemporaryUri tmpUri = new TemporaryUri();
+
+  /* Run the same test suite on multiple implementations of the same interface. */
+  private enum Implementation {
+    SHARED_PREFERENCES
+  }
+
+  @Parameters(name = "implementation={0}")
+  public static ImmutableList<Object[]> data() {
+    return ImmutableList.of(new Object[] {Implementation.SHARED_PREFERENCES});
+  }
+
+  public DownloadDestinationOpenerTest(Implementation impl) {
+    this.implUnderTest = impl;
+  }
+
+  @Before
+  public void setUp() throws Exception {
+    storage = new SynchronousFileStorage(ImmutableList.of(new JavaFileBackend()));
+    downloadMetadataSp =
+        ApplicationProvider.getApplicationContext().getSharedPreferences("prefs", 0);
+  }
+
+  @Test
+  public void opener_withNoExistingData_createsNewMetadata() throws Exception {
+    Uri fileUri = tmpUri.newUri();
+    DownloadMetadata emptyMetadata = DownloadMetadata.create("", 0);
+
+    // Create destination.
+    DownloadMetadataStore store = createMetadataStore();
+    DownloadDestinationOpener opener = DownloadDestinationOpener.create(store);
+    DownloadDestination destination = storage.open(fileUri, opener);
+
+    // Asset that destination has initial, empty values.
+    assertThat(destination.numExistingBytes()).isEqualTo(0);
+    assertThat(destination.readMetadata()).isEqualTo(emptyMetadata);
+  }
+
+  @Test
+  public void opener_withNoExistingData_writes() throws Exception {
+    Uri fileUri = tmpUri.newUri();
+
+    // Set up data and metadata to write.
+    ByteBuffer buffer = ByteBuffer.allocate(CONTENT.length);
+    buffer.put(CONTENT);
+    buffer.position(0);
+
+    DownloadMetadata metadataToWrite = DownloadMetadata.create("test", 10);
+
+    // Create destination and write data/metadata.
+    DownloadMetadataStore store = createMetadataStore();
+    DownloadDestinationOpener opener = DownloadDestinationOpener.create(store);
+    DownloadDestination destination = storage.open(fileUri, opener);
+
+    try (WritableByteChannel writeChannel = destination.openByteChannel(0, metadataToWrite)) {
+      writeChannel.write(buffer);
+    }
+
+    // Read back data to ensure the write completed properly.
+    ReadByteArrayOpener readOpener = ReadByteArrayOpener.create();
+    byte[] readContent = storage.open(fileUri, readOpener);
+
+    assertThat(readContent).hasLength(CONTENT.length);
+    assertThat(readContent).isEqualTo(CONTENT);
+
+    // Assert that destination now reflects the latest state.
+    assertThat(destination.numExistingBytes()).isEqualTo(CONTENT.length);
+    assertThat(destination.readMetadata()).isEqualTo(metadataToWrite);
+  }
+
+  @Test
+  public void opener_withExistingData_usesExistingMetadata() throws Exception {
+    Uri fileUri = tmpUri.newUri();
+
+    DownloadMetadata expectedMetadata = DownloadMetadata.create("test", 10);
+
+    // Set up file with existing data and existing metadata in store.
+    Void unused = storage.open(fileUri, WriteByteArrayOpener.create(CONTENT));
+
+    writeToMetadataStore(fileUri.toString(), expectedMetadata);
+
+    // Create destination.
+    DownloadMetadataStore store = createMetadataStore();
+    DownloadDestinationOpener opener = DownloadDestinationOpener.create(store);
+    DownloadDestination destination = storage.open(fileUri, opener);
+
+    // Assert that destination now reflects the latest state.
+    assertThat(destination.numExistingBytes()).isEqualTo(CONTENT.length);
+    assertThat(destination.readMetadata()).isEqualTo(expectedMetadata);
+  }
+
+  @Test
+  public void opener_withExistingData_writes() throws Exception {
+    Uri fileUri = tmpUri.newUri();
+    byte[] newContent = makeArrayOfBytesContent();
+    byte[] expectedContent = Bytes.concat(CONTENT, newContent);
+
+    // Set up file with existing data and existing metadata in store
+    Void unused = storage.open(fileUri, WriteByteArrayOpener.create(CONTENT));
+
+    writeToMetadataStore(fileUri.toString(), DownloadMetadata.create("initial", 5L));
+
+    // Set up data/metadata to write.
+    ByteBuffer buffer = ByteBuffer.allocate(newContent.length);
+    buffer.put(newContent);
+    buffer.position(0);
+
+    DownloadMetadata metadataToWrite = DownloadMetadata.create("test", 10);
+
+    // Create destination and write data/metadata.
+    DownloadMetadataStore store = createMetadataStore();
+    DownloadDestinationOpener opener = DownloadDestinationOpener.create(store);
+    DownloadDestination destination = storage.open(fileUri, opener);
+
+    try (WritableByteChannel writeChannel =
+        destination.openByteChannel(destination.numExistingBytes(), metadataToWrite)) {
+      writeChannel.write(buffer);
+    }
+
+    // Read back data to ensure the write completed properly.
+    byte[] readContent = storage.open(fileUri, ReadByteArrayOpener.create());
+
+    assertThat(readContent).hasLength(expectedContent.length);
+    assertThat(readContent).isEqualTo(expectedContent);
+
+    // Assert that destination now reflects the latest state.
+    assertThat(destination.numExistingBytes()).isEqualTo(expectedContent.length);
+    assertThat(destination.readMetadata()).isEqualTo(metadataToWrite);
+  }
+
+  @Test
+  public void opener_clearsMetadataAndData() throws Exception {
+    Uri fileUri = tmpUri.newUri();
+    DownloadMetadata emptyMetadata = DownloadMetadata.create("", 0);
+
+    // Set up file with existing data and existing metadata in store.
+    Void unused = storage.open(fileUri, WriteByteArrayOpener.create(CONTENT));
+
+    writeToMetadataStore(fileUri.toString(), DownloadMetadata.create("test", 10L));
+
+    // Create destination and clear.
+    DownloadMetadataStore store = createMetadataStore();
+    DownloadDestinationOpener opener = DownloadDestinationOpener.create(store);
+    DownloadDestination destination = storage.open(fileUri, opener);
+
+    destination.clear();
+
+    // Assert that destination now reflects the latest state.
+    assertThat(destination.numExistingBytes()).isEqualTo(0);
+    assertThat(destination.readMetadata()).isEqualTo(emptyMetadata);
+  }
+
+  private DownloadMetadataStore createMetadataStore() throws Exception {
+    switch (implUnderTest) {
+      case SHARED_PREFERENCES:
+        return new SharedPreferencesDownloadMetadata(downloadMetadataSp, EXECUTOR_SERVICE);
+    }
+    throw new AssertionError(); // Exhaustive switch
+  }
+
+  private void writeToMetadataStore(String fileUri, DownloadMetadata metadataToWrite)
+      throws InterruptedException, ExecutionException, TimeoutException {
+    switch (implUnderTest) {
+      case SHARED_PREFERENCES:
+        downloadMetadataSp.edit().clear().commit();
+        new SharedPreferencesDownloadMetadata(downloadMetadataSp, EXECUTOR_SERVICE)
+            .upsert(Uri.parse(fileUri), metadataToWrite)
+            .get(TIMEOUT, SECONDS);
+        break;
+    }
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/monitors/BUILD b/javatests/com/google/android/libraries/mobiledatadownload/file/monitors/BUILD
new file mode 100644
index 0000000..23895dc
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/monitors/BUILD
@@ -0,0 +1,30 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_local_test")
+
+package(
+    default_visibility = ["//:__subpackages__"],
+    licenses = ["notice"],
+)
+
+android_local_test(
+    name = "ByteCountingOutputMonitorTest",
+    size = "small",
+    srcs = ["ByteCountingOutputMonitorTest.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/monitors",
+        "//javatests/com/google/android/libraries/mobiledatadownload/testing:FakeTimeSource",
+        "@truth",
+    ],
+)
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/monitors/ByteCountingOutputMonitorTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/monitors/ByteCountingOutputMonitorTest.java
new file mode 100644
index 0000000..422a8a5
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/monitors/ByteCountingOutputMonitorTest.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.monitors;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import com.google.android.libraries.mobiledatadownload.testing.FakeTimeSource;
+import java.util.concurrent.atomic.AtomicLong;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public final class ByteCountingOutputMonitorTest {
+
+  private ByteCountingOutputMonitor testOutputMonitor;
+  private long testStorageLength = 0;
+  private final FakeTimeSource clock = new FakeTimeSource();
+  private static final long LOG_FREQUENCY = 1L;
+
+  private class TestCounter implements ByteCountingOutputMonitor.Counter {
+    private final AtomicLong buffer = new AtomicLong();
+
+    @Override
+    public void bufferCounter(int len) {
+      buffer.getAndAdd(len);
+    }
+
+    @Override
+    public void flushCounter() {
+      testStorageLength += buffer.getAndSet(0);
+    }
+  }
+
+  @Before
+  public void setUp() {
+    testOutputMonitor =
+        new ByteCountingOutputMonitor(
+            new TestCounter(), clock::currentTimeMillis, LOG_FREQUENCY, SECONDS);
+  }
+
+  @Test
+  public void testBytesWrittenInsideInterval_shouldNotFlush() throws InterruptedException {
+    // allow enough time to pass to enable flushing
+    clock.advance(LOG_FREQUENCY, SECONDS);
+    testOutputMonitor.bytesWritten(new byte[1], 0, 1);
+    testOutputMonitor.bytesWritten(new byte[1], 0, 1);
+
+    assertThat(testStorageLength).isEqualTo(1);
+  }
+
+  @Test
+  public void testBytesWrittenOutsideInterval_shouldFlush() throws InterruptedException {
+    testOutputMonitor.bytesWritten(new byte[1], 0, 1);
+    clock.advance(LOG_FREQUENCY, SECONDS);
+    testOutputMonitor.bytesWritten(new byte[1], 0, 1);
+
+    assertThat(testStorageLength).isEqualTo(2);
+  }
+
+  @Test
+  public void testBytesWrittenAfterClose_shouldFlush() throws InterruptedException {
+    // allow enough time to pass to enable flushing
+    clock.advance(LOG_FREQUENCY, SECONDS);
+    testOutputMonitor.bytesWritten(new byte[1], 0, 1);
+    testOutputMonitor.close();
+
+    assertThat(testStorageLength).isEqualTo(1);
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/openers/AssetFileDescriptorOpenerAndroidTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/AssetFileDescriptorOpenerAndroidTest.java
new file mode 100644
index 0000000..0526075
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/AssetFileDescriptorOpenerAndroidTest.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.createFile;
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.Assert.assertThrows;
+
+import android.content.res.AssetFileDescriptor;
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.JavaFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.FileDescriptorLeakChecker;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.TemporaryUri;
+import com.google.android.libraries.mobiledatadownload.file.samples.ByteCountingMonitor;
+import com.google.common.collect.ImmutableList;
+import com.google.common.io.CharStreams;
+import java.io.FileNotFoundException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public final class AssetFileDescriptorOpenerAndroidTest {
+
+  private SynchronousFileStorage storage;
+
+  @Rule public FileDescriptorLeakChecker leakChecker = new FileDescriptorLeakChecker();
+  @Rule public TemporaryUri tmpUri = new TemporaryUri();
+
+  @Before
+  public void initStorage() throws Exception {
+    storage = new SynchronousFileStorage(ImmutableList.of(new JavaFileBackend()));
+  }
+
+  @Test
+  public void openAssetFileDescriptor_shouldReadFile() throws Exception {
+    Uri uri = tmpUri.newUri();
+    createFile(storage, uri, "content");
+    try (AssetFileDescriptor result = storage.open(uri, AssetFileDescriptorOpener.create())) {
+      assertThat(result.getLength()).isEqualTo(7);
+      InputStream in = result.createInputStream();
+      Reader reader = new InputStreamReader(in, UTF_8);
+      assertThat(CharStreams.toString(reader)).isEqualTo("content");
+    }
+  }
+
+  @Test
+  public void openAssetFileDescriptor_withMissingFile_throwsFileNotFound() throws Exception {
+    Uri uri = Uri.parse("file:/does-not-exist");
+    assertThrows(
+        FileNotFoundException.class, () -> storage.open(uri, AssetFileDescriptorOpener.create()));
+  }
+
+  @Test
+  public void openAssetFileDescriptor_withMonitor_shouldReadFile() throws Exception {
+    SynchronousFileStorage storageWithMonitor =
+        new SynchronousFileStorage(
+            ImmutableList.of(new JavaFileBackend()),
+            ImmutableList.of(),
+            ImmutableList.of(new ByteCountingMonitor()));
+
+    Uri uri = tmpUri.newUri();
+    byte[] content = StreamUtils.makeArrayOfBytesContent();
+    StreamUtils.createFile(storageWithMonitor, uri, content);
+
+    try (InputStream in =
+        storage.open(uri, AssetFileDescriptorOpener.create()).createInputStream()) {
+      assertThat(StreamUtils.readFileInBytesFromSource(in)).isEqualTo(content);
+    }
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/openers/BUILD b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/BUILD
new file mode 100644
index 0000000..a770dea
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/BUILD
@@ -0,0 +1,513 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_application_test", "android_local_test")
+
+package(
+    default_visibility = ["//:__subpackages__"],
+    licenses = ["notice"],
+)
+
+android_application_test(
+    name = "AssetFileDescriptorOpenerAndroidTest",
+    timeout = "moderate",
+    srcs = [
+        "AssetFileDescriptorOpenerAndroidTest.java",
+    ],
+    manifest = "//javatests/com/google/android/libraries/mobiledatadownload/file:AndroidManifest.xml",
+    target_devices = [
+        "//tools/android/emulated_devices/generic_phone:google_23_x86",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing:extras",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:asset_file_descriptor",
+        "//java/com/google/android/libraries/mobiledatadownload/file/samples",
+        "@com_google_guava_guava",
+        "@junit",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "IntegrityUriComputingOpenerTest",
+    srcs = ["IntegrityUriComputingOpenerTest.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:integrity_uri_computer",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:integrity",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:proto_fragments",
+        "//proto:transform_java_proto_lite",
+        "@com_google_guava_guava",
+        "@truth",
+    ],
+)
+
+android_application_test(
+    name = "NativeReadOpenerAndroidTest",
+    timeout = "moderate",
+    srcs = [
+        "NativeReadOpenerAndroidTest.java",
+    ],
+    manifest = "//javatests/com/google/android/libraries/mobiledatadownload/file:AndroidManifest.xml",
+    target_devices = [
+        "//tools/android/emulated_devices/generic_phone:google_23_x86",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file_descriptor",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing:extras",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:closeable_uri",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:native",
+        "@com_google_guava_guava",
+        "@junit",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "NativeReadOpenerTest",
+    srcs = [
+        "NativeReadOpenerTest.java",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing:matchers",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:native",
+        "//java/com/google/android/libraries/mobiledatadownload/file/spi",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:proto",
+        "@com_google_guava_guava",
+        "@mockito",
+        "@truth",
+    ],
+)
+
+android_application_test(
+    name = "MappedByteBufferOpenerAndroidTest",
+    timeout = "moderate",
+    srcs = [
+        "MappedByteBufferOpenerAndroidTest.java",
+    ],
+    manifest = "//javatests/com/google/android/libraries/mobiledatadownload/file:AndroidManifest.xml",
+    shard_count = 2,
+    target_devices = [
+        "//tools/android/emulated_devices/generic_phone:google_23_x86",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing:extras",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:memory_mapped_bytes",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:compress",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:proto",
+        "@com_google_guava_guava",
+        "@junit",
+        "@truth",
+    ],
+)
+
+android_application_test(
+    name = "ParcelFileDescriptorOpenerAndroidTest",
+    timeout = "moderate",
+    srcs = [
+        "ParcelFileDescriptorOpenerAndroidTest.java",
+    ],
+    manifest = "//javatests/com/google/android/libraries/mobiledatadownload/file:AndroidManifest.xml",
+    target_devices = [
+        "//tools/android/emulated_devices/generic_phone:google_23_x86",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing:extras",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:parcel_file_descriptor",
+        "//java/com/google/android/libraries/mobiledatadownload/file/samples",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:compress",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:proto",
+        "@com_google_guava_guava",
+        "@junit",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "ReadByteArrayOpenerTest",
+    srcs = [
+        "ReadByteArrayOpenerTest.java",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing:extras",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:bytes",
+        "//java/com/google/android/libraries/mobiledatadownload/file/samples",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:compress",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:proto",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "WriteByteArrayOpenerTest",
+    srcs = [
+        "WriteByteArrayOpenerTest.java",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/behaviors:syncing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:bytes",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:stream",
+        "//java/com/google/android/libraries/mobiledatadownload/file/samples",
+        "//java/com/google/android/libraries/mobiledatadownload/file/spi",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:compress",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:proto",
+        "@mockito",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "StringOpenerTest",
+    srcs = [
+        "StringOpenerTest.java",
+    ],
+    manifest = "//javatests/com/google/android/libraries/mobiledatadownload/file:AndroidManifest.xml",
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/behaviors:syncing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/internal:charsets",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:string",
+        "@mockito",
+        "@truth",
+    ],
+)
+
+android_application_test(
+    name = "ReadFileOpenerAndroidTest",
+    timeout = "moderate",
+    srcs = [
+        "ReadFileOpenerAndroidTest.java",
+    ],
+    manifest = "//javatests/com/google/android/libraries/mobiledatadownload/file:AndroidManifest.xml",
+    shard_count = 2,
+    target_devices = [
+        "//tools/android/emulated_devices/generic_phone:google_23_x86",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing:extras",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:stream",
+        "//java/com/google/android/libraries/mobiledatadownload/file/samples",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:compress",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:proto",
+        "@androidx_test",
+        "@com_google_guava_guava",
+        "@junit",
+        "@truth",
+    ],
+)
+
+android_application_test(
+    name = "RandomAccessFileOpenerAndroidTest",
+    timeout = "moderate",
+    srcs = [
+        "RandomAccessFileOpenerAndroidTest.java",
+    ],
+    manifest = "//javatests/com/google/android/libraries/mobiledatadownload/file:AndroidManifest.xml",
+    shard_count = 1,
+    target_devices = [
+        "//tools/android/emulated_devices/generic_phone:google_23_x86",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing:extras",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:random_access_file",
+        "@androidx_test",
+        "@com_google_guava_guava",
+        "@junit",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "ReadProtoOpenerTest",
+    srcs = [
+        "ReadProtoOpenerTest.java",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing:test_message_java_proto_lite",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:proto",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:string",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:compress",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:proto",
+        "@com_google_protobuf//:protobuf_lite",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "ReadStreamOpenerTest",
+    srcs = [
+        "ReadStreamOpenerTest.java",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:stream",
+        "//java/com/google/android/libraries/mobiledatadownload/file/spi",
+        "@mockito",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "RecursiveDeleteOpenerTest",
+    srcs = [
+        "RecursiveDeleteOpenerTest.java",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing:extras",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:recursive_delete",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:string",
+        "//java/com/google/android/libraries/mobiledatadownload/file/spi",
+        "@mockito",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "RecursiveSizeOpenerTest",
+    srcs = [
+        "RecursiveSizeOpenerTest.java",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing:extras",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:recursive_size",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:compress",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:proto",
+        "@truth",
+    ],
+)
+
+android_application_test(
+    name = "StreamMutationOpenerAndroidTest",
+    size = "large",
+    srcs = [
+        "StreamMutationOpenerAndroidTest.java",
+    ],
+    manifest = "StreamMutationOpenerAndroidManifest.xml",
+    target_devices = [
+        "//tools/android/emulated_devices/generic_phone:google_23_x86",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:lock_file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:stream_mutation",
+        "@androidx_test",
+        "@com_google_guava_guava",
+        "@junit",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "StreamMutationOpenerTest",
+    srcs = [
+        "StreamMutationOpenerTest.java",
+    ],
+    manifest = "//javatests/com/google/android/libraries/mobiledatadownload/file:AndroidManifest.xml",
+    manifest_values = {
+        "targetSdkVersion": "19",  # TODO(b/130907105): EncryptTransform should handle this internally
+    },
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/behaviors:syncing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing:extras",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:bytes",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:lock_file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:stream_mutation",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:string",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:compress",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:proto_fragments",
+        "//proto:transform_java_proto_lite",
+        "@com_google_guava_guava",
+        "@mockito",
+        "@truth",
+    ],
+)
+
+android_application_test(
+    name = "SystemLibraryOpenerAndroidTest",
+    size = "large",
+    srcs = [
+        "HelloNative.java",
+        "SystemLibraryOpenerAndroidTest.java",
+    ],
+    data = [
+        ":libhello1native.so",
+        ":libhello2native.so",
+    ],
+    manifest = "//javatests/com/google/android/libraries/mobiledatadownload/file:AndroidManifest.xml",
+    tags = ["notap"],  # Only works with --config=android_x86 so disabling from TAP.
+    target_devices = [
+        "//tools/android/emulated_devices/generic_phone:google_23_x86",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing:extras",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:stream",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:system_library",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:compress",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:proto",
+        "@androidx_test",
+        "@com_google_android_testing//:util",
+        "@com_google_guava_guava",
+        "@junit",
+        "@truth",
+    ],
+)
+
+android_application_test(
+    name = "WriteFileOpenerAndroidTest",
+    size = "large",
+    srcs = [
+        "WriteFileOpenerAndroidTest.java",
+    ],
+    manifest = "//javatests/com/google/android/libraries/mobiledatadownload/file:AndroidManifest.xml",
+    shard_count = 2,
+    target_devices = [
+        "//tools/android/emulated_devices/generic_phone:google_23_x86",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/internal:forwarding_stream",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing:extras",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:stream",
+        "//java/com/google/android/libraries/mobiledatadownload/file/spi",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:compress",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:proto",
+        "@androidx_test",
+        "@com_google_guava_guava",
+        "@junit",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "WriteProtoOpenerTest",
+    srcs = [
+        "WriteProtoOpenerTest.java",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/behaviors:syncing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing:extras",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing:test_message_java_proto_lite",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:proto",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:compress",
+        "@mockito",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "PipesTest",
+    srcs = [
+        "PipesTest.java",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:file",
+    ],
+)
+
+cc_binary(
+    name = "libhello1native.so",
+    testonly = 1,
+    linkshared = 1,
+    linkstatic = 1,
+    deps = [
+        ":hello1native_lib",
+    ],
+)
+
+cc_library(
+    name = "hello1native_lib",
+    testonly = 1,
+    srcs = ["hello1native.cc"],
+    deps = [
+        "@jdk_jni",
+    ],
+    alwayslink = 1,
+)
+
+cc_binary(
+    name = "libhello2native.so",
+    testonly = 1,
+    linkshared = 1,
+    linkstatic = 1,
+    deps = [
+        ":hello2native_lib",
+    ],
+)
+
+cc_library(
+    name = "hello2native_lib",
+    testonly = 1,
+    srcs = ["hello2native.cc"],
+    deps = [
+        "@jdk_jni",
+    ],
+    alwayslink = 1,
+)
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/openers/HelloNative.java b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/HelloNative.java
new file mode 100644
index 0000000..39634d8
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/HelloNative.java
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+/** Helper for testing access to native code. */
+public class HelloNative {
+  public static native String sayHello();
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/openers/IntegrityUriComputingOpenerTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/IntegrityUriComputingOpenerTest.java
new file mode 100644
index 0000000..d02faa0
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/IntegrityUriComputingOpenerTest.java
@@ -0,0 +1,98 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.createFile;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.JavaFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.TemporaryUri;
+import com.google.android.libraries.mobiledatadownload.file.transforms.IntegrityTransform;
+import com.google.android.libraries.mobiledatadownload.file.transforms.TransformProtoFragments;
+import com.google.common.collect.ImmutableList;
+import com.google.mobiledatadownload.TransformProto;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public final class IntegrityUriComputingOpenerTest {
+
+  private static final String ORIGTEXT = "This is some regular old text ABC 123 !@#";
+  private static final String ORIGTEXT_SHA256_B64 = "FoR1HrxdAhY05DE/gAUj0yjpzYpfWb0fJE+XBp8lY0o=";
+  private static final String ORIGTEXT_SHA256_B64_INCORRECT = "INCORRECT";
+
+  @Rule public TemporaryUri tmpUri = new TemporaryUri();
+
+  private SynchronousFileStorage storage;
+
+  @Before
+  public void setUpStorage() throws Exception {
+    storage =
+        new SynchronousFileStorage(
+            ImmutableList.of(new JavaFileBackend()), ImmutableList.of(new IntegrityTransform()));
+  }
+
+  @Test
+  public void integrityUriComputingOpener_shouldProduceCorrectChecksum() throws Exception {
+
+    Uri initialUri = createTestFile(ORIGTEXT);
+    Uri uriWithChecksum = storage.open(initialUri, IntegrityUriComputingOpener.create());
+    String digest = IntegrityTransform.getDigestIfPresent(uriWithChecksum);
+
+    assertThat(digest).isEqualTo(ORIGTEXT_SHA256_B64);
+  }
+
+  @Test
+  public void integrityUriComputingOpener_shouldIgnoreIncorrectInitialSpec() throws Exception {
+    TransformProto.Transform spec =
+        TransformProto.Transform.newBuilder()
+            .setIntegrity(
+                TransformProto.IntegrityTransform.newBuilder()
+                    .setSha256(ORIGTEXT_SHA256_B64_INCORRECT))
+            .build();
+    Uri initialUri = TransformProtoFragments.addOrReplaceTransform(createTestFile(ORIGTEXT), spec);
+    Uri uriWithChecksum = storage.open(initialUri, IntegrityUriComputingOpener.create());
+    String digest = IntegrityTransform.getDigestIfPresent(uriWithChecksum);
+
+    assertThat(digest).isEqualTo(ORIGTEXT_SHA256_B64);
+  }
+
+  @Test
+  public void integrityUriComputingOpener_shouldIgnoreIntegrityParamWithNoSubparam()
+      throws Exception {
+    TransformProto.Transform spec =
+        TransformProto.Transform.newBuilder()
+            .setIntegrity(TransformProto.IntegrityTransform.getDefaultInstance())
+            .build();
+
+    Uri initialUri = TransformProtoFragments.addOrReplaceTransform(createTestFile(ORIGTEXT), spec);
+    Uri uriWithChecksum = storage.open(initialUri, IntegrityUriComputingOpener.create());
+    String digest = IntegrityTransform.getDigestIfPresent(uriWithChecksum);
+
+    assertThat(digest).isEqualTo(ORIGTEXT_SHA256_B64);
+  }
+
+  private Uri createTestFile(String contents) throws Exception {
+    Uri uri = tmpUri.newUri();
+    createFile(storage, uri, contents);
+    return uri;
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/openers/MappedByteBufferOpenerAndroidTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/MappedByteBufferOpenerAndroidTest.java
new file mode 100644
index 0000000..854c15c
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/MappedByteBufferOpenerAndroidTest.java
@@ -0,0 +1,137 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.JavaFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.BufferingMonitor;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.FileDescriptorLeakChecker;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.NoOpMonitor;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.TemporaryUri;
+import com.google.android.libraries.mobiledatadownload.file.transforms.CompressTransform;
+import com.google.android.libraries.mobiledatadownload.file.transforms.TransformProtos;
+import com.google.common.collect.ImmutableList;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.MappedByteBuffer;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public final class MappedByteBufferOpenerAndroidTest {
+
+  private SynchronousFileStorage storage;
+
+  @Rule public TemporaryUri tmpUri = new TemporaryUri();
+  @Rule public FileDescriptorLeakChecker leakChecker = new FileDescriptorLeakChecker();
+
+  @Before
+  public void setUpStorage() throws Exception {
+    storage =
+        new SynchronousFileStorage(
+            ImmutableList.of(new JavaFileBackend()), ImmutableList.of(new CompressTransform()));
+  }
+
+  @Test
+  public void succeedsWithSimplePath() throws Exception {
+    Uri uri = tmpUri.newUri();
+    byte[] content = StreamUtils.makeArrayOfBytesContent();
+    StreamUtils.createFile(storage, uri, content);
+
+    MappedByteBuffer buffer = storage.open(uri, MappedByteBufferOpener.createForRead());
+    assertThat(extractBytes(buffer)).isEqualTo(content);
+  }
+
+  @Test
+  public void bufferIsReadOnly() throws Exception {
+    Uri uri = tmpUri.newUri();
+    byte[] content = StreamUtils.makeArrayOfBytesContent();
+    StreamUtils.createFile(storage, uri, content);
+
+    MappedByteBuffer buffer = storage.open(uri, MappedByteBufferOpener.createForRead());
+    assertThat(buffer.isReadOnly()).isTrue();
+  }
+
+  @Test
+  public void failsWithTransform() throws Exception {
+    Uri uri = tmpUri.newUriBuilder().withTransform(TransformProtos.DEFAULT_COMPRESS_SPEC).build();
+    byte[] content = StreamUtils.makeArrayOfBytesContent();
+    StreamUtils.createFile(storage, uri, content);
+
+    assertThrows(
+        IOException.class, () -> storage.open(uri, MappedByteBufferOpener.createForRead()));
+  }
+
+  @Test
+  public void failsWithMissingFile() throws Exception {
+    Uri uri = Uri.parse("file:/does-not-exist");
+
+    assertThrows(
+        IOException.class, () -> storage.open(uri, MappedByteBufferOpener.createForRead()));
+  }
+
+  @Test
+  public void failsWithActiveMonitor() throws Exception {
+    SynchronousFileStorage storageWithMonitor =
+        new SynchronousFileStorage(
+            ImmutableList.of(new JavaFileBackend()),
+            ImmutableList.of(),
+            ImmutableList.of(new BufferingMonitor()));
+
+    Uri uri = tmpUri.newUri();
+    byte[] content = StreamUtils.makeArrayOfBytesContent();
+    StreamUtils.createFile(storageWithMonitor, uri, content);
+
+    assertThrows(
+        IOException.class,
+        () -> storageWithMonitor.open(uri, MappedByteBufferOpener.createForRead()));
+  }
+
+  @Test
+  public void succeedsWithInactiveMonitor() throws Exception {
+    SynchronousFileStorage storageWithMonitor =
+        new SynchronousFileStorage(
+            ImmutableList.of(new JavaFileBackend()),
+            ImmutableList.of(),
+            ImmutableList.of(new NoOpMonitor()));
+
+    Uri uri = tmpUri.newUri();
+    byte[] content = StreamUtils.makeArrayOfBytesContent();
+    StreamUtils.createFile(storageWithMonitor, uri, content);
+
+    MappedByteBuffer buffer = storageWithMonitor.open(uri, MappedByteBufferOpener.createForRead());
+    assertThat(extractBytes(buffer)).isEqualTo(content);
+  }
+
+  /**
+   * Extracts the byte[] from the ByteBuffer. This method is forked from Guava ByteBuffers, which
+   * isn't available on Android.
+   */
+  private static byte[] extractBytes(ByteBuffer buf) {
+    byte[] result = new byte[buf.remaining()];
+    buf.get(result);
+    buf.position(buf.position() - result.length);
+    return result;
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/openers/NativeReadOpenerAndroidTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/NativeReadOpenerAndroidTest.java
new file mode 100644
index 0000000..b6ab3a8
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/NativeReadOpenerAndroidTest.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.createFile;
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.Assert.assertThrows;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.FileDescriptorUri;
+import com.google.android.libraries.mobiledatadownload.file.backends.JavaFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.FileDescriptorLeakChecker;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.TemporaryUri;
+import com.google.common.collect.ImmutableList;
+import com.google.common.io.CharStreams;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public final class NativeReadOpenerAndroidTest {
+
+  private SynchronousFileStorage storage;
+
+  @Rule public FileDescriptorLeakChecker leakChecker = new FileDescriptorLeakChecker();
+  @Rule public TemporaryUri tmpUri = new TemporaryUri();
+
+  @Before
+  public void initStorage() throws Exception {
+    storage = new SynchronousFileStorage(ImmutableList.of(new JavaFileBackend()));
+  }
+
+  @Test
+  public void openForNativeRead_returnsAWorkingFileDescriptor() throws Exception {
+    Uri uri = tmpUri.newUri();
+    createFile(storage, uri, "content");
+    CloseableUri result = storage.open(uri, NativeReadOpener.create());
+
+    assertThat(result.uri().getScheme()).isEqualTo("fd");
+    assertThat(FileDescriptorUri.getFd(result.uri())).isGreaterThan(0);
+
+    // Use proc filesystem to verify data.
+    File fdFile = new File("/proc/self/fd/" + result.uri().getSchemeSpecificPart());
+    InputStream in = new FileInputStream(fdFile);
+    Reader reader = new InputStreamReader(in, UTF_8);
+    assertThat(CharStreams.toString(reader)).isEqualTo("content");
+
+    // Verify closing behavior.
+    assertThat(fdFile.exists()).isTrue();
+    reader.close(); // This won't actually close the fd.
+    assertThat(fdFile.exists()).isTrue();
+    result.close(); // This does.
+    assertThat(fdFile.exists()).isFalse();
+  }
+
+  @Test
+  public void openForNativeRead_withMissingFile_throwsFileNotFound() throws Exception {
+    Uri uri = Uri.parse("file:/does-not-exist");
+    assertThrows(FileNotFoundException.class, () -> storage.open(uri, NativeReadOpener.create()));
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/openers/NativeReadOpenerTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/NativeReadOpenerTest.java
new file mode 100644
index 0000000..01d3d9c
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/NativeReadOpenerTest.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.FragmentParamMatchers.eqParam;
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.net.Uri;
+import android.util.Pair;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.FileUri;
+import com.google.android.libraries.mobiledatadownload.file.spi.Backend;
+import com.google.android.libraries.mobiledatadownload.file.spi.Monitor;
+import com.google.android.libraries.mobiledatadownload.file.spi.Transform;
+import com.google.android.libraries.mobiledatadownload.file.transforms.TransformProtos;
+import com.google.common.collect.ImmutableList;
+import com.google.thirdparty.robolectric.GoogleRobolectricTestRunner;
+import java.io.Closeable;
+import java.io.InputStream;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+@RunWith(GoogleRobolectricTestRunner.class)
+public final class NativeReadOpenerTest {
+
+  private SynchronousFileStorage storage;
+
+  @Rule public final MockitoRule mocks = MockitoJUnit.rule();
+
+  @Mock protected Backend fileBackend;
+  @Mock protected Transform compressTransform;
+  @Mock protected Monitor countingMonitor;
+  @Mock protected Closeable closeable;
+
+  @Before
+  public void initStorage() throws Exception {
+    when(fileBackend.name()).thenReturn("file");
+    when(compressTransform.name()).thenReturn("compress");
+    when(fileBackend.openForNativeRead(any()))
+        .thenReturn(Pair.create(Uri.parse("fd:123"), closeable));
+
+    storage =
+        new SynchronousFileStorage(
+            ImmutableList.of(fileBackend),
+            ImmutableList.of(compressTransform),
+            ImmutableList.of(countingMonitor));
+  }
+
+  @Test
+  public void nativeRead_shouldEncodeButNotInvokeTransforms() throws Exception {
+    String file1Filename = "file1.txt";
+    Uri file1CompressUri =
+        FileUri.builder()
+            .setPath(file1Filename)
+            .withTransform(TransformProtos.DEFAULT_COMPRESS_SPEC)
+            .build();
+    Uri compressParam = Uri.parse("#transform=compress");
+    assertThat(storage.open(file1CompressUri, NativeReadOpener.create())).isNotNull();
+    verify(compressTransform, never()).wrapForRead(eqParam(compressParam), any(InputStream.class));
+    verify(compressTransform).encode(eqParam(compressParam), eq(file1Filename));
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/openers/ParcelFileDescriptorOpenerAndroidTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/ParcelFileDescriptorOpenerAndroidTest.java
new file mode 100644
index 0000000..2b982bd
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/ParcelFileDescriptorOpenerAndroidTest.java
@@ -0,0 +1,108 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.createFile;
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.Assert.assertThrows;
+
+import android.net.Uri;
+import android.os.ParcelFileDescriptor;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.JavaFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.common.UnsupportedFileStorageOperation;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.FileDescriptorLeakChecker;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.TemporaryUri;
+import com.google.android.libraries.mobiledatadownload.file.samples.ByteCountingMonitor;
+import com.google.android.libraries.mobiledatadownload.file.transforms.CompressTransform;
+import com.google.android.libraries.mobiledatadownload.file.transforms.TransformProtos;
+import com.google.common.collect.ImmutableList;
+import com.google.common.io.CharStreams;
+import java.io.FileNotFoundException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public final class ParcelFileDescriptorOpenerAndroidTest {
+
+  private SynchronousFileStorage storage;
+
+  @Rule public FileDescriptorLeakChecker leakChecker = new FileDescriptorLeakChecker();
+  @Rule public TemporaryUri tmpUri = new TemporaryUri();
+
+  @Before
+  public void initStorage() throws Exception {
+    storage =
+        new SynchronousFileStorage(
+            ImmutableList.of(new JavaFileBackend()), ImmutableList.of(new CompressTransform()));
+  }
+
+  @Test
+  public void openParcelFileDescriptor_shouldReadFile() throws Exception {
+    Uri uri = tmpUri.newUri();
+    createFile(storage, uri, "content");
+    try (InputStream in =
+        new ParcelFileDescriptor.AutoCloseInputStream(
+            storage.open(uri, ParcelFileDescriptorOpener.create()))) {
+      Reader reader = new InputStreamReader(in, UTF_8);
+      assertThat(CharStreams.toString(reader)).isEqualTo("content");
+    }
+  }
+
+  @Test
+  public void openParcelFileDescriptor_withMissingFile_throwsFileNotFound() throws Exception {
+    Uri uri = Uri.parse("file:/does-not-exist");
+    assertThrows(
+        FileNotFoundException.class, () -> storage.open(uri, ParcelFileDescriptorOpener.create()));
+  }
+
+  @Test
+  public void openParcelFileDescriptor_withMonitor_shouldReadFile() throws Exception {
+    SynchronousFileStorage storageWithMonitor =
+        new SynchronousFileStorage(
+            ImmutableList.of(new JavaFileBackend()),
+            ImmutableList.of(),
+            ImmutableList.of(new ByteCountingMonitor()));
+    Uri uri = tmpUri.newUri();
+    byte[] content = StreamUtils.makeArrayOfBytesContent();
+    StreamUtils.createFile(storageWithMonitor, uri, content);
+
+    try (InputStream in =
+        new ParcelFileDescriptor.AutoCloseInputStream(
+            storageWithMonitor.open(uri, ParcelFileDescriptorOpener.create()))) {
+      assertThat(StreamUtils.readFileInBytesFromSource(in)).isEqualTo(content);
+    }
+  }
+
+  @Test
+  public void openParcelFileDescriptor_withTransform_throwsUnsupportedOperation() throws Exception {
+    Uri uri = tmpUri.newUriBuilder().withTransform(TransformProtos.DEFAULT_COMPRESS_SPEC).build();
+    byte[] content = StreamUtils.makeArrayOfBytesContent();
+    StreamUtils.createFile(storage, uri, content);
+
+    assertThrows(
+        UnsupportedFileStorageOperation.class,
+        () -> storage.open(uri, ParcelFileDescriptorOpener.create()));
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/openers/PipesTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/PipesTest.java
new file mode 100644
index 0000000..a3a4b3c
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/PipesTest.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import static org.junit.Assert.assertThrows;
+
+import android.os.Build;
+import com.google.android.libraries.mobiledatadownload.file.common.UnsupportedFileStorageOperation;
+import com.google.thirdparty.robolectric.GoogleRobolectricTestRunner;
+import java.io.File;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.robolectric.annotation.Config;
+
+/** Basic test to ensure that named pipes are only attempted at compatible sdk levels. */
+@RunWith(GoogleRobolectricTestRunner.class)
+public final class PipesTest {
+
+  @Rule public TemporaryFolder tmpFolder = new TemporaryFolder();
+
+  private static final String TAG = "PipesTest";
+  private static final AtomicInteger FIFO_COUNTER = new AtomicInteger();
+
+  @Test
+  @Config(sdk = {Build.VERSION_CODES.KITKAT})
+  public void makeFifo_belowLollipop_throwsUnsupportedFileStorageOperation() throws Exception {
+    assertThrows(
+        UnsupportedFileStorageOperation.class,
+        () -> Pipes.makeFifo(tmpFolder.newFolder(), TAG, FIFO_COUNTER));
+  }
+
+  @Test
+  @Config(sdk = {Build.VERSION_CODES.LOLLIPOP})
+  public void makeFifo_onLollipop_doesNotThrow() throws Exception {
+    // NOTE: the resultant pipe is invalid because Robolectric doesn't fully support
+    // Os.mkFifo, but we're happy as long as the call succeeds
+    File unusedFifo = Pipes.makeFifo(tmpFolder.newFolder(), TAG, FIFO_COUNTER);
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/openers/RandomAccessFileOpenerAndroidTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/RandomAccessFileOpenerAndroidTest.java
new file mode 100644
index 0000000..1deb53c
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/RandomAccessFileOpenerAndroidTest.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.FileUri;
+import com.google.android.libraries.mobiledatadownload.file.backends.JavaFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.FileDescriptorLeakChecker;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils;
+import com.google.common.collect.ImmutableList;
+import java.io.FileInputStream;
+import java.io.RandomAccessFile;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public final class RandomAccessFileOpenerAndroidTest {
+
+  private SynchronousFileStorage storage;
+
+  @Rule public TemporaryFolder tmpFolder = new TemporaryFolder();
+  @Rule public FileDescriptorLeakChecker leakChecker = new FileDescriptorLeakChecker();
+
+  @Before
+  public void setUpStorage() throws Exception {
+    storage = new SynchronousFileStorage(ImmutableList.of(new JavaFileBackend()));
+  }
+
+  @Test
+  public void succeedsWithSimplePath() throws Exception {
+    Uri uri = uriToNewTempFile().build();
+    byte[] content = StreamUtils.makeArrayOfBytesContent();
+    StreamUtils.createFile(storage, uri, content);
+
+    RandomAccessFileOpener opener = RandomAccessFileOpener.createForRead();
+    RandomAccessFile file = storage.open(uri, opener);
+    assertThat(StreamUtils.readFileInBytesFromSource(new FileInputStream(file.getFD())))
+        .isEqualTo(content);
+
+    file.close();
+  }
+
+  @Test
+  public void succeedsWithCreateForReadWrite() throws Exception {
+    Uri uri =
+        FileUri.builder()
+            .fromFile(tmpFolder.getRoot())
+            .appendPath("this/does/not/exist/foo.pb")
+            .build();
+
+    RandomAccessFileOpener opener = RandomAccessFileOpener.createForReadWrite();
+    try (RandomAccessFile file = storage.open(uri, opener)) {
+      file.write(123);
+      file.seek(0);
+      assertThat(file.read()).isEqualTo(123);
+    }
+  }
+
+  private FileUri.Builder uriToNewTempFile() throws Exception {
+    return FileUri.builder().fromFile(tmpFolder.newFile());
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/openers/ReadByteArrayOpenerTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/ReadByteArrayOpenerTest.java
new file mode 100644
index 0000000..111c0f4
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/ReadByteArrayOpenerTest.java
@@ -0,0 +1,110 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.createFile;
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.makeArrayOfBytesContent;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.JavaFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.FakeFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.TemporaryUri;
+import com.google.android.libraries.mobiledatadownload.file.samples.ByteCountingMonitor;
+import com.google.android.libraries.mobiledatadownload.file.transforms.CompressTransform;
+import com.google.android.libraries.mobiledatadownload.file.transforms.TransformProtos;
+import java.util.Arrays;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public final class ReadByteArrayOpenerTest {
+
+  @Rule public TemporaryUri tmpUri = new TemporaryUri();
+
+  private final FakeFileBackend fakeBackend = new FakeFileBackend();
+
+  public SynchronousFileStorage storageWithTransform() throws Exception {
+    return new SynchronousFileStorage(
+        Arrays.asList(new JavaFileBackend()), Arrays.asList(new CompressTransform()));
+  }
+
+  public SynchronousFileStorage storageWithMonitor() throws Exception {
+    return new SynchronousFileStorage(
+        Arrays.asList(new JavaFileBackend()),
+        Arrays.asList(),
+        Arrays.asList(new ByteCountingMonitor()));
+  }
+
+  public SynchronousFileStorage storageWithFakeBackend() throws Exception {
+    return new SynchronousFileStorage(Arrays.asList(fakeBackend));
+  }
+
+  @Test
+  public void directFile_producesArray() throws Exception {
+    SynchronousFileStorage storage = storageWithTransform();
+    Uri uri = tmpUri.newUri();
+    byte[] expected = makeArrayOfBytesContent();
+    createFile(storage, uri, expected);
+
+    ReadByteArrayOpener opener = ReadByteArrayOpener.create();
+    byte[] bytes = storage.open(uri, opener);
+
+    assertThat(bytes).isEqualTo(expected);
+  }
+
+  @Test
+  public void withMonitor_producesArray() throws Exception {
+    SynchronousFileStorage storage = storageWithMonitor();
+    Uri uri = tmpUri.newUri();
+    byte[] expected = makeArrayOfBytesContent();
+    createFile(storage, uri, expected);
+
+    ReadByteArrayOpener opener = ReadByteArrayOpener.create();
+    byte[] bytes = storage.open(uri, opener);
+
+    assertThat(bytes).isEqualTo(expected);
+  }
+
+  @Test
+  public void withTransform_producesArray() throws Exception {
+    SynchronousFileStorage storage = storageWithTransform();
+    Uri uri = tmpUri.newUriBuilder().withTransform(TransformProtos.DEFAULT_COMPRESS_SPEC).build();
+    byte[] expected = makeArrayOfBytesContent();
+    createFile(storage, uri, expected);
+
+    ReadByteArrayOpener opener = ReadByteArrayOpener.create();
+    byte[] bytes = storage.open(uri, opener);
+
+    assertThat(bytes).isEqualTo(expected);
+  }
+
+  @Test
+  public void withFakeBackend_producesArray() throws Exception {
+    SynchronousFileStorage storage = storageWithFakeBackend();
+    Uri uri = tmpUri.newUri();
+    byte[] expected = makeArrayOfBytesContent();
+
+    storage.open(uri, WriteByteArrayOpener.create(expected));
+
+    byte[] bytes = storage.open(uri, ReadByteArrayOpener.create());
+
+    assertThat(bytes).isEqualTo(expected);
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/openers/ReadFileOpenerAndroidTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/ReadFileOpenerAndroidTest.java
new file mode 100644
index 0000000..8626f5b
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/ReadFileOpenerAndroidTest.java
@@ -0,0 +1,238 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.makeContentThatExceedsOsBufferSize;
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.readFileFromSource;
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.writeFileToSink;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.content.Context;
+import android.net.Uri;
+import android.os.Process;
+import android.system.Os;
+import android.system.OsConstants;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.FileUri;
+import com.google.android.libraries.mobiledatadownload.file.backends.JavaFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.common.UnsupportedFileStorageOperation;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.AlwaysThrowsTransform;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.FileDescriptorLeakChecker;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils;
+import com.google.android.libraries.mobiledatadownload.file.samples.ByteCountingMonitor;
+import com.google.android.libraries.mobiledatadownload.file.transforms.CompressTransform;
+import com.google.android.libraries.mobiledatadownload.file.transforms.TransformProtos;
+import com.google.common.collect.ImmutableList;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public final class ReadFileOpenerAndroidTest {
+
+  private final String smallContent = "content";
+  private final String bigContent = makeContentThatExceedsOsBufferSize();
+  private SynchronousFileStorage storage;
+  private ExecutorService executor = Executors.newCachedThreadPool();
+  private final Context context = ApplicationProvider.getApplicationContext();
+
+  @Rule public TemporaryFolder tmpFolder = new TemporaryFolder();
+  @Rule public FileDescriptorLeakChecker leakChecker = new FileDescriptorLeakChecker();
+
+  @Before
+  public void setUpStorage() throws Exception {
+    storage =
+        new SynchronousFileStorage(
+            ImmutableList.of(new JavaFileBackend()),
+            ImmutableList.of(new CompressTransform(), new AlwaysThrowsTransform()));
+  }
+
+  @Test
+  public void compressAndReadBigContentFromPipe() throws Exception {
+    Uri uri = uriToNewTempFile().withTransform(TransformProtos.DEFAULT_COMPRESS_SPEC).build();
+    writeFileToSink(storage.open(uri, WriteStreamOpener.create()), bigContent);
+    ReadFileOpener opener =
+        ReadFileOpener.create().withFallbackToPipeUsingExecutor(executor, context);
+    File piped = storage.open(uri, opener);
+    assertThat(piped.getAbsolutePath()).endsWith(".fifo");
+    try (FileInputStream in = new FileInputStream(piped)) {
+      assertThat(readFileFromSource(in)).isEqualTo(bigContent);
+    }
+    assertThat(piped.exists()).isFalse();
+  }
+
+  @Test
+  public void compressAndReadSmallContentFromPipe() throws Exception {
+    Uri uri = uriToNewTempFile().withTransform(TransformProtos.DEFAULT_COMPRESS_SPEC).build();
+    writeFileToSink(storage.open(uri, WriteStreamOpener.create()), smallContent);
+    ReadFileOpener opener =
+        ReadFileOpener.create().withFallbackToPipeUsingExecutor(executor, context);
+    File piped = storage.open(uri, opener);
+    try (FileInputStream in = new FileInputStream(piped)) {
+      assertThat(readFileFromSource(in)).isEqualTo(smallContent);
+    }
+    assertThat(piped.exists()).isFalse();
+  }
+
+  @Test
+  public void compressWithPartialReadFromPipe_shouldNotLeak() throws Exception {
+    Uri uri = uriToNewTempFile().withTransform(TransformProtos.DEFAULT_COMPRESS_SPEC).build();
+    writeFileToSink(storage.open(uri, WriteStreamOpener.create()), bigContent);
+    ReadFileOpener opener =
+        ReadFileOpener.create().withFallbackToPipeUsingExecutor(executor, context);
+    File piped = storage.open(uri, opener);
+    assertThat(piped.getAbsolutePath()).endsWith(".fifo");
+    try (InputStream in = new FileInputStream(piped)) {
+      in.read(); // Just read 1 byte.
+    }
+    assertThrows(IOException.class, () -> opener.waitForPump());
+    assertThat(piped.exists()).isFalse();
+  }
+
+  @Test
+  public void compressAndReadFromPipeWithoutExecutor_shouldFail() throws Exception {
+    Uri uri = uriToNewTempFile().withTransform(TransformProtos.DEFAULT_COMPRESS_SPEC).build();
+    writeFileToSink(storage.open(uri, WriteStreamOpener.create()), bigContent);
+    assertThrows(IOException.class, () -> storage.open(uri, ReadFileOpener.create()));
+  }
+
+  @Test
+  public void readFromPlainFile() throws Exception {
+    Uri uri = uriToNewTempFile().build(); // No transforms.
+    writeFileToSink(storage.open(uri, WriteStreamOpener.create()), bigContent);
+    File direct =
+        storage.open(
+            uri, ReadFileOpener.create().withFallbackToPipeUsingExecutor(executor, context));
+    try (FileInputStream in = new FileInputStream(direct)) {
+      assertThat(direct.getAbsolutePath()).startsWith(tmpFolder.getRoot().toString());
+      assertThat(readFileFromSource(in)).isEqualTo(bigContent);
+    }
+    assertThat(direct.exists()).isTrue();
+  }
+
+  @Test
+  public void readingFromPipeWithException_shouldReturnEmptyPipe() throws Exception {
+    // A previous implementation had a race condition where it was possible to read from
+    // an unrelated file descriptor if an exception was thrown in background pump thread.
+    FileUri.Builder uriBuilder = uriToNewTempFile();
+    writeFileToSink(storage.open(uriBuilder.build(), WriteStreamOpener.create()), bigContent);
+    ReadFileOpener opener =
+        ReadFileOpener.create().withFallbackToPipeUsingExecutor(executor, context);
+    File file =
+        storage.open(
+            uriBuilder.build().buildUpon().encodedFragment("transform=alwaysthrows").build(),
+            opener);
+    try (FileInputStream in = new FileInputStream(file)) {
+      assertThat(readFileFromSource(in)).isEmpty();
+    }
+    assertThrows(IOException.class, () -> opener.waitForPump());
+    assertThat(file.exists()).isFalse();
+  }
+
+  @Test
+  public void multipleStreams_shouldCreateMultipleFifos() throws Exception {
+    Uri uri = uriToNewTempFile().withTransform(TransformProtos.DEFAULT_COMPRESS_SPEC).build();
+    writeFileToSink(storage.open(uri, WriteStreamOpener.create()), bigContent);
+    File piped0 =
+        storage.open(
+            uri, ReadFileOpener.create().withFallbackToPipeUsingExecutor(executor, context));
+    File piped1 =
+        storage.open(
+            uri, ReadFileOpener.create().withFallbackToPipeUsingExecutor(executor, context));
+    File piped2 =
+        storage.open(
+            uri, ReadFileOpener.create().withFallbackToPipeUsingExecutor(executor, context));
+    assertThat(piped0.getAbsolutePath()).endsWith("-0.fifo");
+    assertThat(piped1.getAbsolutePath()).endsWith("-1.fifo");
+    assertThat(piped2.getAbsolutePath()).endsWith("-2.fifo");
+    try (FileInputStream in0 = new FileInputStream(piped0);
+        FileInputStream in1 = new FileInputStream(piped1);
+        FileInputStream in2 = new FileInputStream(piped2)) {
+      assertThat(readFileFromSource(in2)).isEqualTo(bigContent);
+      assertThat(readFileFromSource(in0)).isEqualTo(bigContent);
+      assertThat(readFileFromSource(in1)).isEqualTo(bigContent);
+    }
+    assertThat(piped0.exists()).isFalse();
+    assertThat(piped1.exists()).isFalse();
+    assertThat(piped2.exists()).isFalse();
+  }
+
+  @Test
+  public void staleFifo_isDeletedAndReplaced() throws Exception {
+    Uri uri = uriToNewTempFile().withTransform(TransformProtos.DEFAULT_COMPRESS_SPEC).build();
+    writeFileToSink(storage.open(uri, WriteStreamOpener.create()), bigContent);
+    ReadFileOpener opener =
+        ReadFileOpener.create().withFallbackToPipeUsingExecutor(executor, context);
+    String staleFifoName = ".mobstore-ReadFileOpener-" + Process.myPid() + "-0.fifo";
+    File staleFifo = new File(context.getCacheDir(), staleFifoName);
+    Os.mkfifo(staleFifo.getAbsolutePath(), OsConstants.S_IRUSR | OsConstants.S_IWUSR);
+
+    File piped = storage.open(uri, opener);
+    assertThat(piped).isEqualTo(staleFifo);
+    try (FileInputStream in = new FileInputStream(piped)) {
+      assertThat(readFileFromSource(in)).isEqualTo(bigContent);
+    }
+    assertThat(piped.exists()).isFalse();
+  }
+
+  @Test
+  public void shortCircuit_succeedsWithSimplePath() throws Exception {
+    Uri uri = uriToNewTempFile().build();
+    writeFileToSink(storage.open(uri, WriteStreamOpener.create()), smallContent);
+    ReadFileOpener opener = ReadFileOpener.create().withShortCircuit();
+    File file = storage.open(uri, opener);
+    assertThat(readFileFromSource(new FileInputStream(file))).isEqualTo(smallContent);
+  }
+
+  @Test
+  public void shortCircuit_isRejectedWithTransforms() throws Exception {
+    Uri uri = uriToNewTempFile().withTransform(TransformProtos.DEFAULT_COMPRESS_SPEC).build();
+    ReadFileOpener opener = ReadFileOpener.create().withShortCircuit();
+    assertThrows(UnsupportedFileStorageOperation.class, () -> storage.open(uri, opener));
+  }
+
+  @Test
+  public void shortCircuit_succeedsWithMonitors() throws Exception {
+    SynchronousFileStorage storageWithMonitor =
+        new SynchronousFileStorage(
+            ImmutableList.of(new JavaFileBackend()),
+            ImmutableList.of(),
+            ImmutableList.of(new ByteCountingMonitor()));
+    Uri uri = uriToNewTempFile().build();
+    byte[] content = StreamUtils.makeArrayOfBytesContent();
+    StreamUtils.createFile(storageWithMonitor, uri, content);
+
+    ReadFileOpener opener = ReadFileOpener.create().withShortCircuit();
+    File file = storageWithMonitor.open(uri, opener);
+    assertThat(StreamUtils.readFileInBytesFromSource(new FileInputStream(file))).isEqualTo(content);
+  }
+
+  // TODO(b/69319355): replace with TemporaryUri
+  private FileUri.Builder uriToNewTempFile() throws Exception {
+    return FileUri.builder().fromFile(tmpFolder.newFile());
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/openers/ReadProtoOpenerTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/ReadProtoOpenerTest.java
new file mode 100644
index 0000000..db7caef
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/ReadProtoOpenerTest.java
@@ -0,0 +1,152 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.JavaFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.TemporaryUri;
+import com.google.android.libraries.mobiledatadownload.file.transforms.CompressTransform;
+import com.google.android.libraries.mobiledatadownload.file.transforms.TransformProtos;
+import com.google.android.libraries.storage.file.common.testing.TestMessageProto.ExtendableProto;
+import com.google.android.libraries.storage.file.common.testing.TestMessageProto.ExtensionProto;
+import com.google.android.libraries.storage.file.common.testing.TestMessageProto.FooProto;
+import com.google.protobuf.ExtensionRegistryLite;
+import com.google.protobuf.InvalidProtocolBufferException;
+import java.util.Arrays;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public final class ReadProtoOpenerTest {
+
+  private static final FooProto TEST_PROTO =
+      FooProto.newBuilder().setText("foo text").setBoolean(true).build();
+
+  private SynchronousFileStorage storage;
+
+  @Rule public TemporaryUri tmpUri = new TemporaryUri();
+
+  @Before
+  public void setUpStorage() throws Exception {
+    storage =
+        new SynchronousFileStorage(
+            Arrays.asList(new JavaFileBackend()), Arrays.asList(new CompressTransform()));
+  }
+
+  @Test
+  public void create_fromMessageParser_returnsOpenerWithCorrectGenericType() throws Exception {
+    Uri uri = tmpUri.newUri();
+    storage.open(uri, WriteProtoOpener.create(TEST_PROTO));
+
+    ReadProtoOpener<FooProto> opener = ReadProtoOpener.create(FooProto.parser());
+    FooProto unusedToCheckCompilation = storage.open(uri, opener);
+
+    // Ensure Java compiler can infer the correct generic when Opener is passed in directly
+    unusedToCheckCompilation = storage.open(uri, ReadProtoOpener.create(FooProto.parser()));
+  }
+
+  @Test
+  public void create_fromMessageInstance_returnsOpenerWithCorrectGenericType() throws Exception {
+    Uri uri = tmpUri.newUri();
+    storage.open(uri, WriteProtoOpener.create(TEST_PROTO));
+
+    ReadProtoOpener<FooProto> opener = ReadProtoOpener.create(TEST_PROTO);
+    FooProto unusedToCheckCompilation = storage.open(uri, opener);
+
+    // Ensure Java compiler can infer the correct generic when Opener is passed in directly
+    unusedToCheckCompilation = storage.open(uri, ReadProtoOpener.create(TEST_PROTO));
+  }
+
+  @Test
+  public void open_readsFullProtoFromFile() throws Exception {
+    Uri uri = tmpUri.newUri();
+    storage.open(uri, WriteProtoOpener.create(TEST_PROTO));
+
+    assertThat(storage.open(uri, ReadProtoOpener.create(FooProto.parser()))).isEqualTo(TEST_PROTO);
+    assertThat(storage.open(uri, ReadProtoOpener.create(TEST_PROTO))).isEqualTo(TEST_PROTO);
+  }
+
+  @Test
+  public void open_readsEmptyProtoFromFile() throws Exception {
+    FooProto emptyProto = FooProto.getDefaultInstance();
+    Uri uri = tmpUri.newUri();
+    storage.open(uri, WriteProtoOpener.create(emptyProto));
+
+    assertThat(storage.open(uri, ReadProtoOpener.create(FooProto.parser()))).isEqualTo(emptyProto);
+  }
+
+  @Test
+  public void open_invokesTransforms() throws Exception {
+    Uri uri = tmpUri.newUriBuilder().withTransform(TransformProtos.DEFAULT_COMPRESS_SPEC).build();
+    storage.open(uri, WriteProtoOpener.create(TEST_PROTO));
+
+    assertThat(storage.open(uri, ReadProtoOpener.create(FooProto.parser()))).isEqualTo(TEST_PROTO);
+  }
+
+  @Test
+  public void open_throwsIOExceptionOnBadParse() throws Exception {
+    Uri uri = tmpUri.newUri();
+    storage.open(uri, WriteStringOpener.create("not a proto"));
+
+    assertThrows(
+        InvalidProtocolBufferException.class,
+        () -> storage.open(uri, ReadProtoOpener.create(FooProto.parser())));
+  }
+
+  @Test
+  public void withRegistry_readsExtension() throws Exception {
+    Uri uri = tmpUri.newUri();
+    ExtendableProto extendable = createProtoWithExtension();
+    storage.open(uri, WriteProtoOpener.create(extendable));
+
+    ReadProtoOpener<ExtendableProto> opener =
+        ReadProtoOpener.create(ExtendableProto.parser())
+            .withExtensionRegistry(ExtensionRegistryLite.getGeneratedRegistry());
+
+    ExtendableProto actualExtendable = storage.open(uri, opener);
+    assertThat(actualExtendable.hasExtension(ExtensionProto.extension)).isTrue();
+    assertThat(actualExtendable.getExtension(ExtensionProto.extension).getFoo().getText())
+        .isEqualTo("foo text");
+  }
+
+  @Test
+  public void withOutRegistry_failsToReadsExtension() throws Exception {
+    Uri uri = tmpUri.newUri();
+    ExtendableProto extendable = createProtoWithExtension();
+    storage.open(uri, WriteProtoOpener.create(extendable));
+
+    ReadProtoOpener<ExtendableProto> opener = ReadProtoOpener.create(ExtendableProto.parser());
+
+    ExtendableProto actualExtendable = storage.open(uri, opener);
+    assertThat(actualExtendable.hasExtension(ExtensionProto.extension)).isFalse();
+  }
+
+  private ExtendableProto createProtoWithExtension() {
+    ExtendableProto extendable =
+        ExtendableProto.newBuilder()
+            .setExtension(
+                ExtensionProto.extension, ExtensionProto.newBuilder().setFoo(TEST_PROTO).build())
+            .build();
+    return extendable;
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/openers/ReadStreamOpenerTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/ReadStreamOpenerTest.java
new file mode 100644
index 0000000..297be6f
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/ReadStreamOpenerTest.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.TemporaryUri;
+import com.google.android.libraries.mobiledatadownload.file.spi.Backend;
+import java.io.BufferedInputStream;
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.util.Arrays;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public final class ReadStreamOpenerTest {
+
+  private final InputStream backendStream = new ByteArrayInputStream(new byte[1]);
+  private SynchronousFileStorage storage;
+
+  @Rule public TemporaryUri tmpUri = new TemporaryUri();
+
+  @Before
+  public void setUpStorage() throws Exception {
+    Backend mockBackend = mock(Backend.class);
+    when(mockBackend.name()).thenReturn("file");
+    when(mockBackend.openForRead(any())).thenReturn(backendStream);
+
+    storage = new SynchronousFileStorage(Arrays.asList(mockBackend));
+  }
+
+  @Test
+  public void open_withoutTransforms_returnsRawBackendStream() throws Exception {
+    Uri uri = tmpUri.newUri();
+
+    try (InputStream result = storage.open(uri, ReadStreamOpener.create())) {
+      assertThat(result).isEqualTo(backendStream);
+    }
+  }
+
+  @Test
+  public void open_withBufferedIo_returnsBufferedInputStream() throws Exception {
+    Uri uri = tmpUri.newUri();
+
+    try (InputStream result = storage.open(uri, ReadStreamOpener.create().withBufferedIo())) {
+      assertThat(result).isInstanceOf(BufferedInputStream.class);
+    }
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/openers/RecursiveDeleteOpenerTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/RecursiveDeleteOpenerTest.java
new file mode 100644
index 0000000..b3154c9
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/RecursiveDeleteOpenerTest.java
@@ -0,0 +1,146 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.spy;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.JavaFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.FakeFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.TemporaryUri;
+import com.google.android.libraries.mobiledatadownload.file.spi.Backend;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+@RunWith(RobolectricTestRunner.class)
+public final class RecursiveDeleteOpenerTest {
+
+  @Rule public TemporaryUri tmpUri = new TemporaryUri();
+
+  private final SynchronousFileStorage storage =
+      new SynchronousFileStorage(Arrays.asList(new JavaFileBackend()));
+
+  @Test
+  public void open_nonExistentUri_throwsException() throws IOException {
+    Uri dir = tmpUri.newDirectoryUri();
+    Uri missing = Uri.withAppendedPath(dir, "a");
+    assertThrows(IOException.class, () -> storage.open(missing, RecursiveDeleteOpener.create()));
+  }
+
+  @Test
+  public void open_file_deletesFile() throws IOException {
+    Uri file = tmpUri.newUri();
+    storage.open(file, WriteStringOpener.create("junk"));
+
+    storage.open(file, RecursiveDeleteOpener.create());
+
+    assertThat(storage.exists(file)).isFalse();
+  }
+
+  @Test
+  public void open_emptyDirectory_deletesDirectory() throws IOException {
+    Uri dir = tmpUri.newDirectoryUri();
+
+    storage.open(dir, RecursiveDeleteOpener.create());
+
+    assertThat(storage.exists(dir)).isFalse();
+  }
+
+  @Test
+  public void open_nonEmptyDirectory_deletesChildrenThenDirectory() throws IOException {
+    Uri dir = tmpUri.newDirectoryUri();
+
+    // TODO: consider adding FileUri.fromFileUri to make this cleaner
+    Uri file0 = Uri.withAppendedPath(dir, "a");
+    Uri file1 = Uri.withAppendedPath(dir, "b");
+    storage.open(file0, WriteStringOpener.create("junk"));
+    storage.open(file1, WriteStringOpener.create("junk"));
+
+    storage.open(dir, RecursiveDeleteOpener.create());
+
+    assertThat(storage.exists(file0)).isFalse();
+    assertThat(storage.exists(file1)).isFalse();
+    assertThat(storage.exists(dir)).isFalse();
+  }
+
+  @Test
+  public void open_directoryTree_recursesMultipleLevels() throws IOException {
+    Uri dir = tmpUri.newDirectoryUri();
+
+    Uri subDir = Uri.withAppendedPath(dir, "subDir");
+    Uri subSubDir = Uri.withAppendedPath(subDir, "subSubDir");
+    Uri emptySubDir = Uri.withAppendedPath(dir, "emptySubDir");
+    storage.createDirectory(subDir);
+    storage.createDirectory(subSubDir);
+    storage.createDirectory(emptySubDir);
+
+    List<Uri> fileUris = new ArrayList<>();
+    for (int i = 0; i != 5; i++) {
+      Uri uri = Uri.withAppendedPath(dir, Integer.toString(i));
+      storage.open(uri, WriteStringOpener.create(Integer.toString(i)));
+      fileUris.add(uri);
+    }
+
+    storage.open(dir, RecursiveDeleteOpener.create());
+
+    assertThat(storage.exists(dir)).isFalse();
+    assertThat(storage.exists(subDir)).isFalse();
+    assertThat(storage.exists(subSubDir)).isFalse();
+    assertThat(storage.exists(emptySubDir)).isFalse();
+    for (Uri uri : fileUris) {
+      assertThat(storage.exists(uri)).isFalse();
+    }
+  }
+
+  @Test
+  @Config(sdk = 19) // addSuppressed is only available on SDK 19+
+  public void open_suppressesExceptionsUntilEnd() throws Exception {
+    Backend spyBackend = spy(new FakeFileBackend());
+    SynchronousFileStorage storage = new SynchronousFileStorage(Arrays.asList(spyBackend));
+
+    Uri dir = tmpUri.newDirectoryUri();
+
+    Uri deletableFile0 = Uri.withAppendedPath(dir, "a");
+    Uri undeletableFile = Uri.withAppendedPath(dir, "subDir/b");
+    Uri deletableFile1 = Uri.withAppendedPath(dir, "subDir/subSubDir/c");
+    storage.open(undeletableFile, WriteStringOpener.create("a"));
+    storage.open(deletableFile0, WriteStringOpener.create("b"));
+    storage.open(deletableFile1, WriteStringOpener.create("c"));
+
+    doThrow(IOException.class).when(spyBackend).deleteFile(undeletableFile);
+
+    IOException expected =
+        assertThrows(IOException.class, () -> storage.open(dir, RecursiveDeleteOpener.create()));
+
+    assertThat(expected.getSuppressed()).hasLength(3); // one for the file, two for the directories
+
+    assertThat(storage.exists(deletableFile0)).isFalse();
+    assertThat(storage.exists(deletableFile1)).isFalse();
+    assertThat(storage.exists(undeletableFile)).isTrue();
+    assertThat(storage.exists(dir)).isTrue();
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/openers/RecursiveSizeOpenerTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/RecursiveSizeOpenerTest.java
new file mode 100644
index 0000000..c5551bf
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/RecursiveSizeOpenerTest.java
@@ -0,0 +1,142 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.createFile;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.FileUri;
+import com.google.android.libraries.mobiledatadownload.file.backends.JavaFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.DummyTransforms;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.TemporaryUri;
+import com.google.android.libraries.mobiledatadownload.file.transforms.CompressTransform;
+import com.google.android.libraries.mobiledatadownload.file.transforms.TransformProtos;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.util.Arrays;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public final class RecursiveSizeOpenerTest {
+
+  @Rule public TemporaryUri tmpUri = new TemporaryUri();
+
+  private final SynchronousFileStorage storage =
+      new SynchronousFileStorage(
+          Arrays.asList(new JavaFileBackend()),
+          Arrays.asList(new CompressTransform(), DummyTransforms.CAP_FILENAME_TRANSFORM));
+
+  @Test
+  public void open_nonExistentUri_throwsFileNotFoundException() throws IOException {
+    Uri missing = tmpUri.newUri();
+    assertThrows(
+        FileNotFoundException.class, () -> storage.open(missing, RecursiveSizeOpener.create()));
+  }
+
+  @Test
+  public void open_file_throwsFileNotFoundException() throws IOException {
+    Uri file = tmpUri.newUri();
+    createFile(storage, file, "12345");
+    assertThrows(
+        FileNotFoundException.class, () -> storage.open(file, RecursiveSizeOpener.create()));
+  }
+
+  @Test
+  public void open_emptyDirectory_returns0() throws IOException {
+    Uri dir = tmpUri.newDirectoryUri();
+    assertThat(storage.open(dir, RecursiveSizeOpener.create())).isEqualTo(0);
+  }
+
+  @Test
+  public void open_nonEmptyDirectory_returnsSizeOfChildrenFiles() throws IOException {
+    Uri dir = tmpUri.newDirectoryUri();
+
+    // TODO: consider adding FileUri.fromFileUri to make this cleaner
+    createFile(
+        storage, FileUri.builder().setPath(dir.getPath()).appendPath("file0").build(), "12345");
+    createFile(
+        storage, FileUri.builder().setPath(dir.getPath()).appendPath("file1").build(), "678");
+
+    assertThat(storage.open(dir, RecursiveSizeOpener.create())).isEqualTo(8);
+  }
+
+  @Test
+  public void open_directoryTree_recurses() throws IOException {
+    Uri dir = tmpUri.newDirectoryUri();
+    Uri subDir = FileUri.builder().setPath(dir.getPath()).appendPath("subDir").build();
+    Uri subSubDir = FileUri.builder().setPath(subDir.getPath()).appendPath("subSubDir").build();
+    Uri emptySubDir = FileUri.builder().setPath(dir.getPath()).appendPath("emptySubDir").build();
+
+    // TODO: consider adding FileUri.fromFileUri to make this cleaner
+    storage.createDirectory(subDir);
+    storage.createDirectory(subSubDir);
+    storage.createDirectory(emptySubDir);
+    createFile(storage, FileUri.builder().setPath(dir.getPath()).appendPath("a").build(), "1");
+    createFile(storage, FileUri.builder().setPath(dir.getPath()).appendPath("b").build(), "2");
+    createFile(storage, FileUri.builder().setPath(subDir.getPath()).appendPath("c").build(), "3");
+    createFile(
+        storage, FileUri.builder().setPath(subSubDir.getPath()).appendPath("d").build(), "45");
+    createFile(
+        storage, FileUri.builder().setPath(subSubDir.getPath()).appendPath("e").build(), "67");
+
+    assertThat(storage.open(dir, RecursiveSizeOpener.create())).isEqualTo(7);
+  }
+
+  @Test
+  public void open_canFindChildRegardlessOfFilenameEncoding() throws IOException {
+    Uri dir = tmpUri.newDirectoryUri();
+    Uri dirWithTransform =
+        FileUri.builder()
+            .setPath(dir.getPath())
+            .build()
+            .buildUpon()
+            .encodedFragment("transform=cap")
+            .build();
+    Uri childWithTransform =
+        FileUri.builder()
+            .setPath(dir.getPath())
+            .appendPath("this-will-be-all-caps-on-disk")
+            .build()
+            .buildUpon()
+            .encodedFragment("transform=cap")
+            .build();
+    createFile(storage, childWithTransform, "12345");
+
+    assertThat(storage.open(dir, RecursiveSizeOpener.create())).isEqualTo(5);
+    assertThat(storage.open(dirWithTransform, RecursiveSizeOpener.create())).isEqualTo(5);
+  }
+
+  @Test
+  public void open_returnsOnDiskSizeNotLogicalTransformedSize() throws IOException {
+    Uri dir = tmpUri.newDirectoryUri();
+    Uri child =
+        FileUri.builder()
+            .setPath(dir.getPath())
+            .appendPath("filename")
+            .withTransform(TransformProtos.DEFAULT_COMPRESS_SPEC)
+            .build();
+    String content = "this content should not be decompressed when calculating on-disk size";
+    createFile(storage, child, content);
+
+    assertThat(storage.open(dir, RecursiveSizeOpener.create())).isLessThan((long) content.length());
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/openers/StreamMutationOpenerAndroidManifest.xml b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/StreamMutationOpenerAndroidManifest.xml
new file mode 100644
index 0000000..280fc33
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/StreamMutationOpenerAndroidManifest.xml
@@ -0,0 +1,33 @@
+<!--
+/*
+ * Copyright 2022 Google LLC
+ *
+ * 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.
+ */
+-->
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+  package="com.google.android.libraries.mobiledatadownload.file.openers">
+    <uses-sdk
+            android:minSdkVersion="15"
+            android:targetSdkVersion="23"/>
+    <application>
+        <uses-library android:name="android.test.runner" />
+        <service android:enabled="true" android:exported="false"
+          android:process=":TestHelper"
+          android:name=".StreamMutationOpenerAndroidTest$TestHelper" />
+    </application>
+    <instrumentation
+        android:name="com.google.android.apps.common.testing.testrunner.Google3InstrumentationTestRunner"
+        android:targetPackage="com.google.android.libraries.mobiledatadownload.file.openers" />
+</manifest>
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/openers/StreamMutationOpenerAndroidTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/StreamMutationOpenerAndroidTest.java
new file mode 100644
index 0000000..bceb0dd
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/StreamMutationOpenerAndroidTest.java
@@ -0,0 +1,301 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.createFile;
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.readFile;
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.Assert.assertThrows;
+
+import android.app.Activity;
+import android.app.Service;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.net.Uri;
+import android.os.Binder;
+import android.os.IBinder;
+import android.util.Log;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.rule.ServiceTestRule;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.JavaFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.TemporaryUri;
+import com.google.common.base.Ascii;
+import com.google.common.collect.ImmutableList;
+import com.google.common.io.ByteStreams;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TestName;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public final class StreamMutationOpenerAndroidTest {
+  private static final String TAG = "TestStreamMutationOpenerAndroid";
+
+  private final Context context = ApplicationProvider.getApplicationContext();
+
+  @Rule public TemporaryUri tmpUri = new TemporaryUri();
+  @Rule public final ServiceTestRule serviceRule = new ServiceTestRule();
+  @Rule public TestName testName = new TestName();
+
+  public static SynchronousFileStorage storage() {
+    return new SynchronousFileStorage(ImmutableList.of(new JavaFileBackend()));
+  }
+
+  @Test
+  public void interleaveMutations_withoutLocking_lacksIsolation() throws Exception {
+    serviceRule.startService(new Intent(context, TestHelper.class));
+
+    SynchronousFileStorage storage = storage();
+    Uri dirUri = tmpUri.newDirectoryUri();
+    Uri uri = dirUri.buildUpon().appendPath("testfile").build();
+
+    createFile(storage, uri, "content");
+    sendToHelper(uri);
+
+    try (StreamMutationOpener.Mutator mutator = storage.open(uri, StreamMutationOpener.create())) {
+      mutator.mutate(
+          (InputStream in, OutputStream out) -> {
+            assertThat(readFile(storage, uri)).isEqualTo("content");
+
+            String read = new String(ByteStreams.toByteArray(in), UTF_8);
+            String write = Ascii.toUpperCase(read);
+            assertThat(write).isEqualTo("CONTENT");
+
+            sendToHelper(uri);
+
+            assertThat(readFile(storage, uri)).isEqualTo("tnetnoc");
+
+            out.write(write.getBytes(UTF_8));
+            // This write hasn't closed, so the destination file is unchanged. This mutation isn't
+            // applied to the result of the first one and it will overwrite it.
+            assertThat(readFile(storage, uri)).isEqualTo("tnetnoc");
+            return true;
+          });
+    }
+
+    String actual = readFile(storage, uri);
+    assertThat(actual).isEqualTo("CONTENT"); // Only the second mutation is applied.
+    assertThat(storage.children(dirUri)).hasSize(1);
+  }
+
+  /** Helper for interleaveMutations_withoutLocking_lacksIsolation. */
+  private static class InterleaveMutationsWithoutLockingLacksIsolationBroadcastReceiver
+      extends TestingBroadcastReceiver {
+    @Override
+    public void run() {
+      SynchronousFileStorage storage = storage();
+
+      try (StreamMutationOpener.Mutator mutator =
+          storage.open(uri, StreamMutationOpener.create())) {
+        mutator.mutate(
+            (InputStream in, OutputStream out) -> {
+              String read = new String(ByteStreams.toByteArray(in), UTF_8);
+              done.signal();
+              resume.await();
+              out.write(new StringBuilder(read).reverse().toString().getBytes(UTF_8));
+              return true;
+            });
+      } catch (Exception ex) {
+        Log.e(TAG, "failed", ex);
+        setResultCode(Activity.RESULT_CANCELED);
+      }
+      done.signal();
+    }
+  }
+
+  @Test
+  public void interleaveMutations_withLocking() throws Exception {
+    serviceRule.startService(new Intent(context, TestHelper.class));
+
+    SynchronousFileStorage storage = storage();
+    Uri dirUri = tmpUri.newDirectoryUri();
+    Uri uri = dirUri.buildUpon().appendPath("testfile").build();
+
+    createFile(storage, uri, "content");
+    sendToHelper(uri);
+
+    // At first we fail to acquire the lock.
+    LockFileOpener nonBlockingLocking = LockFileOpener.createExclusive().nonBlocking(true);
+    assertThrows(
+        IOException.class,
+        () -> storage.open(uri, StreamMutationOpener.create().withLocking(nonBlockingLocking)));
+
+    // Peek at the file, ignoring advisory locks.
+    assertThat(readFile(storage, uri)).isEqualTo("content");
+
+    sendToHelper(uri);
+
+    // Now the lock should be free and we can proceed safely.
+    LockFileOpener blockingLocking = LockFileOpener.createExclusive().nonBlocking(true);
+    try (StreamMutationOpener.Mutator mutator =
+        storage.open(uri, StreamMutationOpener.create().withLocking(blockingLocking))) {
+      mutator.mutate(
+          (InputStream in, OutputStream out) -> {
+            String read = new String(ByteStreams.toByteArray(in), UTF_8);
+            assertThat(read).isEqualTo("tnetnoc");
+            String write = Ascii.toUpperCase(read);
+            out.write(write.getBytes(UTF_8));
+            return true;
+          });
+    }
+    String actual = readFile(storage, uri);
+    assertThat(actual).isEqualTo("TNETNOC");
+    assertThat(storage.children(dirUri)).hasSize(2); // data file + lock file
+  }
+
+  /** Helper for interleaveMutations_withLocking. */
+  private static class InterleaveMutationsWithLockingBroadcastReceiver
+      extends TestingBroadcastReceiver {
+    @Override
+    public void run() {
+      SynchronousFileStorage storage = storage();
+      try {
+        LockFileOpener locking = LockFileOpener.createExclusive();
+        try (StreamMutationOpener.Mutator mutator =
+            storage.open(uri, StreamMutationOpener.create().withLocking(locking))) {
+          mutator.mutate(
+              (InputStream in, OutputStream out) -> {
+                String read = new String(ByteStreams.toByteArray(in), UTF_8);
+                done.signal();
+                resume.await();
+                out.write(new StringBuilder(read).reverse().toString().getBytes(UTF_8));
+                return true;
+              });
+        }
+      } catch (Exception ex) {
+        Log.e(TAG, "failed", ex);
+        setResultCode(Activity.RESULT_CANCELED);
+      }
+      done.signal();
+    }
+  }
+
+  // Testing infrastructure from here down.
+  // TODO(b/120859198): Refactor into shared code.
+  private abstract static class TestingBroadcastReceiver extends BroadcastReceiver
+      implements Runnable {
+    protected final Latch resume = new Latch();
+    protected final Latch done = new Latch();
+    protected Uri uri;
+    private Thread thread = null;
+
+    @Override
+    public void onReceive(Context context, Intent intent) {
+      setResultCode(Activity.RESULT_OK);
+      uri = Uri.parse(intent.getExtras().getString("uri"));
+      if (thread == null) {
+        thread = new Thread(this);
+        thread.start();
+      } else {
+        resume.signal();
+      }
+      done.await();
+    }
+
+    @Override
+    public abstract void run();
+  }
+
+  public static class TestHelper extends Service {
+    private final BroadcastReceiver withoutLocking =
+        new InterleaveMutationsWithoutLockingLacksIsolationBroadcastReceiver();
+    private final BroadcastReceiver withLocking =
+        new InterleaveMutationsWithLockingBroadcastReceiver();
+    private final IBinder dummyBinder = new Binder() {};
+
+    @Override
+    public IBinder onBind(Intent intent) {
+      return dummyBinder;
+    }
+
+    @Override
+    public void onCreate() {
+      registerReceiver(
+          withoutLocking, new IntentFilter("interleaveMutations_withoutLocking_lacksIsolation"));
+      registerReceiver(withLocking, new IntentFilter("interleaveMutations_withLocking"));
+    }
+
+    @Override
+    public void onDestroy() {
+      unregisterReceiver(withoutLocking);
+      unregisterReceiver(withLocking);
+    }
+  }
+
+  /** A broadcast receiver that allows caller to wait until it receives an intent. */
+  private static class NotifyingBroadcastReceiver extends BroadcastReceiver {
+    private final Latch received = new Latch();
+    private boolean success = true;
+
+    @Override
+    public void onReceive(Context context, Intent intent) {
+      success = (getResultCode() == Activity.RESULT_OK);
+      received.signal();
+    }
+
+    public void awaitDelivery() throws Exception {
+      received.await();
+      if (!success) {
+        throw new Exception("broadcast handler failed");
+      }
+    }
+  }
+
+  /** A simple latch that resets itself after await. */
+  private static class Latch {
+    boolean signaled = false;
+
+    synchronized void signal() {
+      signaled = true;
+      notify();
+    }
+
+    synchronized void await() {
+      while (!signaled) {
+        try {
+          wait();
+        } catch (InterruptedException ex) {
+          // Ignore.
+        }
+      }
+      signaled = false;
+    }
+  }
+
+  /** Sends params to helper and wait for it to finish processing them. */
+  private void sendToHelper(Uri uri) throws IOException {
+    Intent intent = new Intent(testName.getMethodName());
+    intent.putExtra("uri", uri.toString());
+    NotifyingBroadcastReceiver receiver = new NotifyingBroadcastReceiver();
+    context.sendOrderedBroadcast(
+        intent, null, receiver, null, Activity.RESULT_FIRST_USER, null, null);
+    try {
+      receiver.awaitDelivery();
+    } catch (IOException ex) {
+      throw ex;
+    } catch (Exception ex) {
+      throw new IOException(ex);
+    }
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/openers/StreamMutationOpenerTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/StreamMutationOpenerTest.java
new file mode 100644
index 0000000..7992b77
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/StreamMutationOpenerTest.java
@@ -0,0 +1,437 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.createFile;
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.Assert.assertThrows;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.JavaFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.behaviors.SyncingBehavior;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.TemporaryUri;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.WritesThrowTransform;
+import com.google.android.libraries.mobiledatadownload.file.transforms.CompressTransform;
+import com.google.android.libraries.mobiledatadownload.file.transforms.TransformProtoFragments;
+import com.google.common.base.Ascii;
+import com.google.common.io.ByteStreams;
+import com.google.mobiledatadownload.TransformProto;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.Arrays;
+import java.util.concurrent.CountDownLatch;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public final class StreamMutationOpenerTest {
+
+  @Rule public TemporaryUri tmpUri = new TemporaryUri();
+
+  public SynchronousFileStorage storageWithTransform() throws Exception {
+    return new SynchronousFileStorage(
+        Arrays.asList(new JavaFileBackend()),
+        Arrays.asList(new CompressTransform(), new WritesThrowTransform()));
+  }
+
+  @Test
+  public void okIfFileDoesNotExist() throws Exception {
+    SynchronousFileStorage storage = storageWithTransform();
+    Uri dirUri = tmpUri.newDirectoryUri();
+    Uri uri = dirUri.buildUpon().appendPath("testfile").build();
+    String content = "content";
+
+    assertThat(storage.children(dirUri)).isEmpty();
+    try (StreamMutationOpener.Mutator mutator = storage.open(uri, StreamMutationOpener.create())) {
+      mutator.mutate(
+          (InputStream in, OutputStream out) -> {
+            byte[] read = ByteStreams.toByteArray(in);
+            assertThat(read).hasLength(0);
+            out.write(content.getBytes(UTF_8));
+            return true;
+          });
+    }
+
+    ReadByteArrayOpener opener = ReadByteArrayOpener.create();
+    String actual = new String(storage.open(uri, opener), UTF_8);
+
+    assertThat(actual).isEqualTo(content);
+  }
+
+  @Test
+  public void willFailToOverwriteDirectory() throws Exception {
+    SynchronousFileStorage storage = storageWithTransform();
+    Uri uri = tmpUri.newDirectoryUri();
+    String content = "content";
+
+    try (StreamMutationOpener.Mutator mutator = storage.open(uri, StreamMutationOpener.create())) {
+      assertThrows(
+          IOException.class,
+          () ->
+              mutator.mutate(
+                  (InputStream in, OutputStream out) -> {
+                    out.write(content.getBytes(UTF_8));
+                    return true;
+                  }));
+    }
+  }
+
+  @Test
+  public void canMutate() throws Exception {
+    SynchronousFileStorage storage = storageWithTransform();
+    Uri uri = tmpUri.newUri();
+    String content = "content";
+    String expected = Ascii.toUpperCase(content);
+    createFile(storage, uri, content);
+
+    try (StreamMutationOpener.Mutator mutator = storage.open(uri, StreamMutationOpener.create())) {
+      mutator.mutate(
+          (InputStream in, OutputStream out) -> {
+            String read = new String(ByteStreams.toByteArray(in), UTF_8);
+            out.write(Ascii.toUpperCase(read).getBytes(UTF_8));
+            return true;
+          });
+    }
+
+    ReadByteArrayOpener opener = ReadByteArrayOpener.create();
+    String actual = new String(storage.open(uri, opener), UTF_8);
+
+    assertThat(actual).isEqualTo(expected);
+  }
+
+  @Test
+  public void canMutate_butNotCommit() throws Exception {
+    SynchronousFileStorage storage = storageWithTransform();
+    Uri uri = tmpUri.newUri();
+    String content = "content";
+    createFile(storage, uri, content);
+
+    try (StreamMutationOpener.Mutator mutator = storage.open(uri, StreamMutationOpener.create())) {
+      mutator.mutate(
+          (InputStream in, OutputStream out) -> {
+            String read = new String(ByteStreams.toByteArray(in), UTF_8);
+            out.write(Ascii.toUpperCase(read).getBytes(UTF_8));
+            return false;
+          });
+    }
+
+    ReadByteArrayOpener opener = ReadByteArrayOpener.create();
+    String actual = new String(storage.open(uri, opener), UTF_8);
+
+    assertThat(actual).isEqualTo(content); // Unchanged.
+  }
+
+  @Test
+  public void canMutate_repeatedly() throws Exception {
+    SynchronousFileStorage storage = storageWithTransform();
+    Uri uri = tmpUri.newUri();
+    String content = "content";
+    String expected = "TNETNOC";
+    createFile(storage, uri, content);
+
+    try (StreamMutationOpener.Mutator mutator = storage.open(uri, StreamMutationOpener.create())) {
+      mutator.mutate(
+          (InputStream in, OutputStream out) -> {
+            String read = new String(ByteStreams.toByteArray(in), UTF_8);
+            out.write(Ascii.toUpperCase(read).getBytes(UTF_8));
+            return true;
+          });
+      mutator.mutate(
+          (InputStream in, OutputStream out) -> {
+            String read = new String(ByteStreams.toByteArray(in), UTF_8);
+            out.write(new StringBuilder(read).reverse().toString().getBytes(UTF_8));
+            return true;
+          });
+    }
+
+    ReadByteArrayOpener opener = ReadByteArrayOpener.create();
+    String actual = new String(storage.open(uri, opener), UTF_8);
+
+    assertThat(actual).isEqualTo(expected);
+  }
+
+  @Test
+  public void canMutate_withSync() throws Exception {
+    SynchronousFileStorage storage = storageWithTransform();
+    Uri uri = tmpUri.newUri();
+    String content = "content";
+    String expected = Ascii.toUpperCase(content);
+    storage.open(uri, WriteStringOpener.create(content));
+
+    SyncingBehavior syncing = Mockito.spy(new SyncingBehavior());
+    try (StreamMutationOpener.Mutator mutator =
+        storage.open(uri, StreamMutationOpener.create().withBehaviors(syncing))) {
+      mutator.mutate(
+          (InputStream in, OutputStream out) -> {
+            String read = new String(ByteStreams.toByteArray(in), UTF_8);
+            out.write(Ascii.toUpperCase(read).getBytes(UTF_8));
+            return true;
+          });
+    }
+    Mockito.verify(syncing).sync();
+
+    String actual = storage.open(uri, ReadStringOpener.create());
+    assertThat(actual).isEqualTo(expected);
+  }
+
+  @Test
+  public void okIfFileDoesNotExist_withExclusiveLock() throws Exception {
+    SynchronousFileStorage storage = storageWithTransform();
+    Uri dirUri = tmpUri.newDirectoryUri();
+    Uri uri = dirUri.buildUpon().appendPath("testfile").build();
+    String content = "content";
+
+    LockFileOpener locking = LockFileOpener.createExclusive();
+    try (StreamMutationOpener.Mutator mutator =
+        storage.open(uri, StreamMutationOpener.create().withLocking(locking))) {
+      mutator.mutate(
+          (InputStream in, OutputStream out) -> {
+            assertThat(storage.open(uri, LockFileOpener.createExclusive().nonBlocking(true)))
+                .isNull();
+            assertThat(storage.open(uri, LockFileOpener.createReadOnlyShared().nonBlocking(true)))
+                .isNull();
+            byte[] read = ByteStreams.toByteArray(in);
+            assertThat(read).hasLength(0);
+            out.write(content.getBytes(UTF_8));
+            return true;
+          });
+    }
+
+    ReadByteArrayOpener opener = ReadByteArrayOpener.create();
+    String actual = new String(storage.open(uri, opener), UTF_8);
+
+    assertThat(actual).isEqualTo(content);
+  }
+
+  @Test
+  public void canMutate_withExclusiveLock() throws Exception {
+    SynchronousFileStorage storage = storageWithTransform();
+    Uri uri = tmpUri.newUri();
+    String content = "content";
+    String expected = Ascii.toUpperCase(content);
+    createFile(storage, uri, content);
+
+    LockFileOpener locking = LockFileOpener.createExclusive();
+    try (StreamMutationOpener.Mutator mutator =
+        storage.open(uri, StreamMutationOpener.create().withLocking(locking))) {
+      mutator.mutate(
+          (InputStream in, OutputStream out) -> {
+            assertThat(storage.open(uri, LockFileOpener.createExclusive().nonBlocking(true)))
+                .isNull();
+            assertThat(storage.open(uri, LockFileOpener.createReadOnlyShared().nonBlocking(true)))
+                .isNull();
+            String read = new String(ByteStreams.toByteArray(in), UTF_8);
+            out.write(Ascii.toUpperCase(read).getBytes(UTF_8));
+            return true;
+          });
+    }
+
+    ReadByteArrayOpener opener = ReadByteArrayOpener.create();
+    String actual = new String(storage.open(uri, opener), UTF_8);
+
+    assertThat(actual).isEqualTo(expected);
+  }
+
+  @Test
+  public void rollsBack_afterIOException() throws Exception {
+    SynchronousFileStorage storage = storageWithTransform();
+    Uri dirUri = tmpUri.newDirectoryUri();
+    Uri uri = dirUri.buildUpon().appendPath("testfile").build();
+    String content = "content";
+    createFile(storage, uri, content);
+
+    assertThat(storage.children(dirUri)).hasSize(1);
+
+    Uri uriForPartialWrite =
+        uri.buildUpon().encodedFragment("transform=writethrows(write_length=1)").build();
+    try (StreamMutationOpener.Mutator mutator =
+        storage.open(uriForPartialWrite, StreamMutationOpener.create())) {
+      mutator.mutate(
+          (InputStream in, OutputStream out) -> {
+            assertThat(storage.children(dirUri)).hasSize(2);
+            String read = new String(ByteStreams.toByteArray(in), UTF_8);
+            out.write(Ascii.toUpperCase(read).getBytes(UTF_8));
+            throw new IOException("something went wrong");
+          });
+    } catch (IOException ex) {
+      // Ignore.
+    }
+    assertThat(storage.children(dirUri)).hasSize(1);
+
+    ReadByteArrayOpener opener = ReadByteArrayOpener.create();
+    String actual = new String(storage.open(uri, opener), UTF_8);
+
+    assertThat(actual).isEqualTo(content); // Still original content.
+  }
+
+  @Test
+  public void rollsBack_afterRuntimeException() throws Exception {
+    SynchronousFileStorage storage = storageWithTransform();
+    Uri dirUri = tmpUri.newDirectoryUri();
+    Uri uri = dirUri.buildUpon().appendPath("testfile").build();
+    String content = "content";
+    createFile(storage, uri, content);
+
+    assertThat(storage.children(dirUri)).hasSize(1);
+    try (StreamMutationOpener.Mutator mutator = storage.open(uri, StreamMutationOpener.create())) {
+      mutator.mutate(
+          (InputStream in, OutputStream out) -> {
+            assertThat(storage.children(dirUri)).hasSize(2);
+            String read = new String(ByteStreams.toByteArray(in), UTF_8);
+            out.write(Ascii.toUpperCase(read).getBytes(UTF_8));
+            throw new RuntimeException("something went wrong");
+          });
+    } catch (IOException ex) {
+      // Ignore RuntimeException wrapped in IOException.
+    }
+    assertThat(storage.children(dirUri)).hasSize(1);
+
+    ReadByteArrayOpener opener = ReadByteArrayOpener.create();
+    String actual = new String(storage.open(uri, opener), UTF_8);
+
+    assertThat(actual).isEqualTo(content); // Still original content.
+  }
+
+  @Test
+  public void okIfStreamsAreWrapped() throws Exception {
+    SynchronousFileStorage storage = storageWithTransform();
+    Uri uri = tmpUri.newUri();
+
+    // Write path
+    try (StreamMutationOpener.Mutator mutator = storage.open(uri, StreamMutationOpener.create())) {
+      mutator.mutate(
+          (InputStream in, OutputStream out) -> {
+            try (DataOutputStream dos = new DataOutputStream(out)) {
+              dos.writeLong(42);
+            }
+            return true;
+          });
+    }
+
+    // Read path (slightly-overloaded use of StreamMutationOpener, since we're not doing a mutation)
+    try (StreamMutationOpener.Mutator mutator = storage.open(uri, StreamMutationOpener.create())) {
+      mutator.mutate(
+          (InputStream in, OutputStream out) -> {
+            try (DataInputStream dis = new DataInputStream(in)) {
+              assertThat(dis.readLong()).isEqualTo(42);
+            }
+            return true;
+          });
+    }
+  }
+
+  @Test
+  public void canMutate_withTransforms() throws Exception {
+    SynchronousFileStorage storage = storageWithTransform();
+    Uri dirUri = tmpUri.newDirectoryUri();
+    Uri uri =
+        TransformProtoFragments.addOrReplaceTransform(
+            dirUri.buildUpon().appendPath("testfile").build(),
+            TransformProto.Transform.newBuilder()
+                .setCompress(TransformProto.CompressTransform.getDefaultInstance())
+                .build());
+
+    String content = "content";
+    String expected = Ascii.toUpperCase(content);
+    createFile(storage, uri, content);
+
+    try (StreamMutationOpener.Mutator mutator = storage.open(uri, StreamMutationOpener.create())) {
+      mutator.mutate(
+          (InputStream in, OutputStream out) -> {
+            String read = new String(ByteStreams.toByteArray(in), UTF_8);
+            byte[] plaintext = Ascii.toUpperCase(read).getBytes(UTF_8);
+            out.write(plaintext);
+            out.flush();
+
+            // Check that the tmpfile is compressed.
+            Uri tmp = null;
+            for (Uri childUri : storage.children(dirUri)) {
+              if (childUri.getPath().contains(".mobstore_tmp")) {
+                tmp = childUri;
+                break;
+              }
+            }
+            assertThat(tmp).isNotNull();
+            byte[] compressed = storage.open(tmp, ReadByteArrayOpener.create());
+            assertThat(compressed.length).isGreaterThan(0);
+            assertThat(compressed).isNotEqualTo(plaintext);
+            return true;
+          });
+    }
+
+    ReadByteArrayOpener opener = ReadByteArrayOpener.create();
+    String actual = new String(storage.open(uri, opener), UTF_8);
+
+    assertThat(actual).isEqualTo(expected);
+  }
+
+  @Test
+  public void multiThreadWithoutLock_lacksIsolation() throws Exception {
+    SynchronousFileStorage storage = storageWithTransform();
+    Uri uri = tmpUri.newUri();
+    CountDownLatch latch = new CountDownLatch(1);
+    CountDownLatch latch2 = new CountDownLatch(1);
+    CountDownLatch latch3 = new CountDownLatch(1);
+    Thread thread =
+        new Thread(
+            () -> {
+              try (StreamMutationOpener.Mutator mutator =
+                  storage.open(uri, StreamMutationOpener.create())) {
+                mutator.mutate(
+                    (InputStream in, OutputStream out) -> {
+                      latch.countDown();
+                      out.write("other-thread".getBytes(UTF_8));
+                      try {
+                        latch2.await();
+                      } catch (InterruptedException ex) {
+                        throw new IOException(ex);
+                      }
+                      return true;
+                    });
+                latch3.countDown();
+              } catch (Exception ex) {
+                throw new RuntimeException(ex);
+              }
+            });
+    thread.setDaemon(true);
+    thread.start();
+    latch.await();
+
+    try (StreamMutationOpener.Mutator mutator = storage.open(uri, StreamMutationOpener.create())) {
+      mutator.mutate(
+          (InputStream in, OutputStream out) -> {
+            out.write("this-thread".getBytes(UTF_8));
+            return true;
+          });
+    }
+
+    ReadByteArrayOpener opener = ReadByteArrayOpener.create();
+    assertThat(new String(storage.open(uri, opener), UTF_8)).isEqualTo("this-thread");
+
+    latch2.countDown();
+    latch3.await();
+
+    assertThat(new String(storage.open(uri, opener), UTF_8)).isEqualTo("other-thread");
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/openers/StringOpenerTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/StringOpenerTest.java
new file mode 100644
index 0000000..1fef635
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/StringOpenerTest.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.JavaFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.behaviors.SyncingBehavior;
+import com.google.android.libraries.mobiledatadownload.file.common.internal.Charsets;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.TemporaryUri;
+import java.util.Arrays;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public final class StringOpenerTest {
+
+  SynchronousFileStorage storage;
+  @Rule public TemporaryUri tmpUri = new TemporaryUri();
+
+  @Before
+  public void setUpStorage() throws Exception {
+    storage = new SynchronousFileStorage(Arrays.asList(new JavaFileBackend()));
+  }
+
+  @Test
+  public void withMonitor_writesString() throws Exception {
+
+    Uri uri = tmpUri.newUri();
+    String expected = "The five boxing wizards jump quickly";
+    storage.open(uri, WriteStringOpener.create(expected));
+    assertThat(storage.open(uri, ReadStringOpener.create())).isEqualTo(expected);
+  }
+
+  @Test
+  public void writesString_withDifferentCharsets() throws Exception {
+    Uri uri = tmpUri.newUri();
+    String expected = "The five boxing wizards jump quickly";
+
+    storage.open(uri, WriteStringOpener.create(expected).withCharset(Charsets.US_ASCII));
+    assertThat(storage.open(uri, ReadStringOpener.create().withCharset(Charsets.US_ASCII)))
+        .isEqualTo(expected);
+
+    storage.open(uri, WriteStringOpener.create(expected).withCharset(Charsets.ISO_8859_1));
+    assertThat(storage.open(uri, ReadStringOpener.create().withCharset(Charsets.ISO_8859_1)))
+        .isEqualTo(expected);
+  }
+
+  @Test
+  public void invokes_autoSync() throws Exception {
+    Uri uri1 = tmpUri.newUri();
+    SyncingBehavior syncing = Mockito.spy(new SyncingBehavior());
+    storage.open(uri1, WriteStringOpener.create("some string").withBehaviors(syncing));
+    Mockito.verify(syncing).sync();
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/openers/SystemLibraryOpenerAndroidTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/SystemLibraryOpenerAndroidTest.java
new file mode 100644
index 0000000..e1b7029
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/SystemLibraryOpenerAndroidTest.java
@@ -0,0 +1,115 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.content.Context;
+import android.net.Uri;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.android.apps.common.testing.util.AndroidTestUtil;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.JavaFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.FileDescriptorLeakChecker;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.TemporaryUri;
+import com.google.android.libraries.mobiledatadownload.file.transforms.CompressTransform;
+import com.google.android.libraries.mobiledatadownload.file.transforms.TransformProtos;
+import com.google.common.collect.ImmutableList;
+import com.google.common.io.ByteStreams;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.concurrent.Callable;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public final class SystemLibraryOpenerAndroidTest {
+
+  private static final String SO_DIR =
+      "third_party/java_src/android_libs/mobiledatadownload/javatests/com/google/android/libraries/mobiledatadownload/file/openers/";
+  private static final String HELLO1NATIVE_SO = SO_DIR + "libhello1native.so";
+  private static final String HELLO2NATIVE_SO = SO_DIR + "libhello2native.so";
+  private final Context context = ApplicationProvider.getApplicationContext();
+  private SynchronousFileStorage storage;
+
+  @Rule public TemporaryUri tmpUri = new TemporaryUri();
+  @Rule public FileDescriptorLeakChecker leakChecker = new FileDescriptorLeakChecker();
+
+  @Before
+  public void initializeStorage() throws Exception {
+    storage =
+        new SynchronousFileStorage(
+            ImmutableList.of(new JavaFileBackend()), ImmutableList.of(new CompressTransform()));
+  }
+
+  @Test
+  public void openFromFileWithoutCache_shouldLoad() throws Exception {
+    Uri uri = tmpUri.newUri();
+    try (InputStream in =
+            AndroidTestUtil.getTestDataInputStream(context.getContentResolver(), HELLO1NATIVE_SO);
+        OutputStream out = storage.open(uri, WriteStreamOpener.create())) {
+      ByteStreams.copy(in, out);
+      storage.open(uri, SystemLibraryOpener.create());
+      assertThat(HelloNative.sayHello()).isEqualTo("hello1");
+    }
+  }
+
+  @Test
+  public void openCompressedFromFileWithoutCache_shouldFail() throws Exception {
+    Uri uri = tmpUri.newUriBuilder().withTransform(TransformProtos.DEFAULT_COMPRESS_SPEC).build();
+    try (InputStream in =
+            AndroidTestUtil.getTestDataInputStream(context.getContentResolver(), HELLO1NATIVE_SO);
+        OutputStream out = storage.open(uri, WriteStreamOpener.create())) {
+      ByteStreams.copy(in, out);
+    }
+    assertThrows(IOException.class, () -> storage.open(uri, SystemLibraryOpener.create()));
+  }
+
+  @Test
+  public void openCompressedFromFileWithCache_shouldSucceed() throws Exception {
+    Uri uri = tmpUri.newUriBuilder().withTransform(TransformProtos.DEFAULT_COMPRESS_SPEC).build();
+    try (InputStream in =
+            AndroidTestUtil.getTestDataInputStream(context.getContentResolver(), HELLO2NATIVE_SO);
+        OutputStream out = storage.open(uri, WriteStreamOpener.create())) {
+      ByteStreams.copy(in, out);
+    }
+    Uri cacheDir = tmpUri.getRootUri();
+
+    Callable<Integer> countLibs =
+        () -> {
+          int numLibs = 0;
+          for (Uri child : storage.children(cacheDir)) {
+            if (child.getPath().endsWith(".so")) {
+              numLibs++;
+            }
+          }
+          return numLibs;
+        };
+    assertThat(countLibs.call()).isEqualTo(0); // Initially, no libraries
+
+    storage.open(uri, SystemLibraryOpener.create().withCacheDirectory(cacheDir));
+    assertThat(HelloNative.sayHello()).isEqualTo("hello2");
+    assertThat(countLibs.call()).isEqualTo(1); // Now we see the one library.
+
+    storage.open(uri, SystemLibraryOpener.create().withCacheDirectory(cacheDir));
+    assertThat(countLibs.call()).isEqualTo(1); // The one library is found again.
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/openers/WriteByteArrayOpenerTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/WriteByteArrayOpenerTest.java
new file mode 100644
index 0000000..78c373e
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/WriteByteArrayOpenerTest.java
@@ -0,0 +1,134 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.makeArrayOfBytesContent;
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.makeByteContentThatExceedsOsBufferSize;
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.readFileInBytesFromSource;
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.JavaFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.behaviors.SyncingBehavior;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.TemporaryUri;
+import com.google.android.libraries.mobiledatadownload.file.samples.ByteCountingMonitor;
+import com.google.android.libraries.mobiledatadownload.file.spi.Backend;
+import com.google.android.libraries.mobiledatadownload.file.transforms.CompressTransform;
+import com.google.android.libraries.mobiledatadownload.file.transforms.TransformProtos;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.util.Arrays;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public final class WriteByteArrayOpenerTest {
+
+  @Rule public TemporaryUri tmpUri = new TemporaryUri();
+
+  @Rule public final MockitoRule mocks = MockitoJUnit.rule();
+
+  @Mock private Backend mockBackend;
+
+  public SynchronousFileStorage storageWithTransform() throws Exception {
+    return new SynchronousFileStorage(
+        Arrays.asList(new JavaFileBackend()), Arrays.asList(new CompressTransform()));
+  }
+
+  public SynchronousFileStorage storageWithMonitor() throws Exception {
+    return new SynchronousFileStorage(
+        Arrays.asList(new JavaFileBackend()),
+        Arrays.asList(),
+        Arrays.asList(new ByteCountingMonitor()));
+  }
+
+  public SynchronousFileStorage storageWithMockBackend() throws Exception {
+    when(mockBackend.name()).thenReturn("mock");
+    return new SynchronousFileStorage(Arrays.asList(mockBackend));
+  }
+
+  @Test
+  public void directFile_writesArray() throws Exception {
+    SynchronousFileStorage storage = storageWithTransform();
+    Uri uri = tmpUri.newUri();
+    byte[] expected = makeArrayOfBytesContent();
+    storage.open(uri, WriteByteArrayOpener.create(expected));
+    assertThat(readFileInBytesFromSource(storage.open(uri, ReadStreamOpener.create())))
+        .isEqualTo(expected);
+  }
+
+  @Test
+  public void directFile_writesArrayThatExceedsOsBufferSize() throws Exception {
+    SynchronousFileStorage storage = storageWithTransform();
+    Uri uri = tmpUri.newUri();
+    byte[] expected = makeByteContentThatExceedsOsBufferSize();
+    storage.open(uri, WriteByteArrayOpener.create(expected));
+    assertThat(readFileInBytesFromSource(storage.open(uri, ReadStreamOpener.create())))
+        .isEqualTo(expected);
+  }
+
+  @Test
+  public void withMonitor_writesArray() throws Exception {
+    SynchronousFileStorage storage = storageWithMonitor();
+    Uri uri = tmpUri.newUri();
+    byte[] expected = makeArrayOfBytesContent();
+    storage.open(uri, WriteByteArrayOpener.create(expected));
+    assertThat(readFileInBytesFromSource(storage.open(uri, ReadStreamOpener.create())))
+        .isEqualTo(expected);
+  }
+
+  @Test
+  public void withTransform_producesArray() throws Exception {
+    SynchronousFileStorage storage = storageWithTransform();
+    Uri uri = tmpUri.newUriBuilder().withTransform(TransformProtos.DEFAULT_COMPRESS_SPEC).build();
+    byte[] expected = makeArrayOfBytesContent();
+    storage.open(uri, WriteByteArrayOpener.create(expected));
+    assertThat(readFileInBytesFromSource(storage.open(uri, ReadStreamOpener.create())))
+        .isEqualTo(expected);
+  }
+
+  @Test
+  public void withMockBackend_producesArray() throws Exception {
+    SynchronousFileStorage storage = storageWithMockBackend();
+    Uri uri = Uri.parse("mock:/");
+    byte[] expected = makeArrayOfBytesContent();
+    when(mockBackend.openForWrite(any())).thenReturn(new ByteArrayOutputStream());
+    when(mockBackend.openForRead(any())).thenReturn(new ByteArrayInputStream(expected));
+
+    storage.open(uri, WriteByteArrayOpener.create(expected));
+    assertThat(readFileInBytesFromSource(storage.open(uri, ReadStreamOpener.create())))
+        .isEqualTo(expected);
+  }
+
+  @Test
+  public void invokes_autoSync() throws Exception {
+    SynchronousFileStorage storage = storageWithTransform();
+    Uri uri1 = tmpUri.newUri();
+    SyncingBehavior syncing = Mockito.spy(new SyncingBehavior());
+    storage.open(
+        uri1, WriteByteArrayOpener.create(makeArrayOfBytesContent()).withBehaviors(syncing));
+    Mockito.verify(syncing).sync();
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/openers/WriteFileOpenerAndroidTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/WriteFileOpenerAndroidTest.java
new file mode 100644
index 0000000..d22d692
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/WriteFileOpenerAndroidTest.java
@@ -0,0 +1,268 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.makeContentThatExceedsOsBufferSize;
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.readFileFromSource;
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.writeFileToSink;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.content.Context;
+import android.net.Uri;
+import android.os.Process;
+import android.system.Os;
+import android.system.OsConstants;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.FileUri;
+import com.google.android.libraries.mobiledatadownload.file.backends.JavaFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.common.FileConvertible;
+import com.google.android.libraries.mobiledatadownload.file.common.internal.ForwardingOutputStream;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.AlwaysThrowsTransform;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.FileDescriptorLeakChecker;
+import com.google.android.libraries.mobiledatadownload.file.spi.Backend;
+import com.google.android.libraries.mobiledatadownload.file.transforms.CompressTransform;
+import com.google.android.libraries.mobiledatadownload.file.transforms.TransformProtos;
+import com.google.common.collect.ImmutableList;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public final class WriteFileOpenerAndroidTest {
+
+  private final String bigContent = makeContentThatExceedsOsBufferSize();
+  private final String smallContent = "content";
+  private SynchronousFileStorage storage;
+  private ExecutorService executor = Executors.newCachedThreadPool();
+  private final Context context = ApplicationProvider.getApplicationContext();
+
+  @Rule public TemporaryFolder tmpFolder = new TemporaryFolder();
+  @Rule public FileDescriptorLeakChecker leakChecker = new FileDescriptorLeakChecker();
+
+  @Before
+  public void setUpStorage() throws Exception {
+    storage =
+        new SynchronousFileStorage(
+            ImmutableList.of(new JavaFileBackend()),
+            ImmutableList.of(new CompressTransform(), new AlwaysThrowsTransform()));
+  }
+
+  @Test
+  public void compressAndWriteToPipe() throws Exception {
+    Uri uri = uriToNewTempFile().withTransform(TransformProtos.DEFAULT_COMPRESS_SPEC).build();
+    File pipedFile;
+    try (WriteFileOpener.FileCloser piped =
+        storage.open(
+            uri, WriteFileOpener.create().withFallbackToPipeUsingExecutor(executor, context))) {
+      pipedFile = piped.file();
+      assertThat(pipedFile.getAbsolutePath()).endsWith(".fifo");
+      writeFileToSink(new FileOutputStream(pipedFile), bigContent);
+    }
+    assertThat(readFileFromSource(storage.open(uri, ReadStreamOpener.create())))
+        .isEqualTo(bigContent);
+    assertThat(pipedFile.exists()).isFalse();
+  }
+
+  @Test
+  public void compressButDontWriteToPipe_shouldNotLeak() throws Exception {
+    Uri uri = uriToNewTempFile().withTransform(TransformProtos.DEFAULT_COMPRESS_SPEC).build();
+    File pipedFile;
+    try (WriteFileOpener.FileCloser piped =
+        storage.open(
+            uri, WriteFileOpener.create().withFallbackToPipeUsingExecutor(executor, context))) {
+      pipedFile = piped.file();
+      assertThat(pipedFile.getAbsolutePath()).endsWith(".fifo");
+    }
+    assertThat(pipedFile.exists()).isFalse();
+  }
+
+  @Test
+  public void staleFifo_isDeletedAndReplaced() throws Exception {
+    Uri uri = uriToNewTempFile().withTransform(TransformProtos.DEFAULT_COMPRESS_SPEC).build();
+
+    String staleFifoName = ".mobstore-WriteFileOpener-" + Process.myPid() + "-0.fifo";
+    File staleFifo = new File(context.getCacheDir(), staleFifoName);
+    Os.mkfifo(staleFifo.getAbsolutePath(), OsConstants.S_IRUSR | OsConstants.S_IWUSR);
+
+    File pipedFile;
+    try (WriteFileOpener.FileCloser piped =
+        storage.open(
+            uri, WriteFileOpener.create().withFallbackToPipeUsingExecutor(executor, context))) {
+      pipedFile = piped.file();
+      assertThat(pipedFile).isEqualTo(staleFifo);
+      writeFileToSink(new FileOutputStream(pipedFile), bigContent);
+    }
+
+    assertThat(readFileFromSource(storage.open(uri, ReadStreamOpener.create())))
+        .isEqualTo(bigContent);
+    assertThat(staleFifo.exists()).isFalse();
+  }
+
+  @Test
+  public void multipleStreams_shouldCreateMultipleFifos() throws Exception {
+    Uri uri0 = uriToNewTempFile().withTransform(TransformProtos.DEFAULT_COMPRESS_SPEC).build();
+    Uri uri1 = uriToNewTempFile().withTransform(TransformProtos.DEFAULT_COMPRESS_SPEC).build();
+    Uri uri2 = uriToNewTempFile().withTransform(TransformProtos.DEFAULT_COMPRESS_SPEC).build();
+
+    WriteFileOpener.FileCloser piped0 =
+        storage.open(
+            uri0, WriteFileOpener.create().withFallbackToPipeUsingExecutor(executor, context));
+    WriteFileOpener.FileCloser piped1 =
+        storage.open(
+            uri1, WriteFileOpener.create().withFallbackToPipeUsingExecutor(executor, context));
+    WriteFileOpener.FileCloser piped2 =
+        storage.open(
+            uri2, WriteFileOpener.create().withFallbackToPipeUsingExecutor(executor, context));
+
+    assertThat(piped0.file().getAbsolutePath()).endsWith("-0.fifo");
+    assertThat(piped1.file().getAbsolutePath()).endsWith("-1.fifo");
+    assertThat(piped2.file().getAbsolutePath()).endsWith("-2.fifo");
+
+    writeFileToSink(new FileOutputStream(piped0.file()), bigContent + "0");
+    writeFileToSink(new FileOutputStream(piped1.file()), bigContent + "1");
+    writeFileToSink(new FileOutputStream(piped2.file()), bigContent + "2");
+
+    piped0.close();
+    piped1.close();
+    piped2.close();
+
+    assertThat(readFileFromSource(storage.open(uri0, ReadStreamOpener.create())))
+        .isEqualTo(bigContent + "0");
+    assertThat(readFileFromSource(storage.open(uri1, ReadStreamOpener.create())))
+        .isEqualTo(bigContent + "1");
+    assertThat(readFileFromSource(storage.open(uri2, ReadStreamOpener.create())))
+        .isEqualTo(bigContent + "2");
+
+    assertThat(piped0.file().exists()).isFalse();
+    assertThat(piped1.file().exists()).isFalse();
+    assertThat(piped2.file().exists()).isFalse();
+  }
+
+  @Test
+  public void compressAndWriteToPipeWithoutExecutor_shouldFail() throws Exception {
+    Uri uri = uriToNewTempFile().withTransform(TransformProtos.DEFAULT_COMPRESS_SPEC).build();
+    assertThrows(IOException.class, () -> storage.open(uri, WriteFileOpener.create()));
+  }
+
+  @Test
+  public void writeBigContentWithException_shouldThrowEPipeAndPropagate() throws Exception {
+    Uri uri =
+        uriToNewTempFile().build().buildUpon().encodedFragment("transform=alwaysthrows").build();
+    WriteFileOpener.FileCloser piped =
+        storage.open(
+            uri, WriteFileOpener.create().withFallbackToPipeUsingExecutor(executor, context));
+    // Throws EPIPE while writing.
+    assertThrows(
+        IOException.class, () -> writeFileToSink(new FileOutputStream(piped.file()), bigContent));
+    // Throws underlying exception when closing.
+    assertThrows(IOException.class, () -> piped.close());
+    assertThat(piped.file().exists()).isFalse();
+  }
+
+  @Test
+  public void writeSmallContentWithException_shouldPropagate() throws Exception {
+    Uri uri =
+        uriToNewTempFile().build().buildUpon().encodedFragment("transform=alwaysthrows").build();
+    WriteFileOpener.FileCloser piped =
+        storage.open(
+            uri, WriteFileOpener.create().withFallbackToPipeUsingExecutor(executor, context));
+    // Small content is buffered and pump failure is is not visible.
+    writeFileToSink(new FileOutputStream(piped.file()), smallContent);
+    // Throws underlying exception when closing.
+    assertThrows(IOException.class, () -> piped.close());
+    assertThat(piped.file().exists()).isFalse();
+  }
+
+  @Test
+  public void writeToPlainFile() throws Exception {
+    Uri uri = uriToNewTempFile().build(); // No transforms.
+    try (WriteFileOpener.FileCloser direct =
+        storage.open(
+            uri, WriteFileOpener.create().withFallbackToPipeUsingExecutor(executor, context))) {
+      assertThat(direct.file().getAbsolutePath()).startsWith(tmpFolder.getRoot().toString());
+      writeFileToSink(new FileOutputStream(direct.file()), bigContent);
+      assertThat(readFileFromSource(storage.open(uri, ReadStreamOpener.create())))
+          .isEqualTo(bigContent);
+    }
+  }
+
+  @Test
+  public void writeToPlainFile_shouldNotPrematurelyCloseStream() throws Exception {
+    // No transforms, write to stub test backend
+    storage = new SynchronousFileStorage(ImmutableList.of(new BufferingBackend()));
+    File file = tmpFolder.newFile();
+    Uri uri = Uri.parse("buffer:///" + file.getAbsolutePath());
+
+    try (WriteFileOpener.FileCloser direct = storage.open(uri, WriteFileOpener.create())) {
+      writeFileToSink(new FileOutputStream(direct.file()), bigContent);
+    }
+    assertThat(readFileFromSource(new FileInputStream(file))).isEqualTo(bigContent);
+  }
+
+  private FileUri.Builder uriToNewTempFile() throws Exception {
+    return FileUri.builder().fromFile(tmpFolder.newFile());
+  }
+
+  /** A backend that uses temporary files to buffer IO operations. */
+  private static class BufferingBackend implements Backend {
+    @Override
+    public String name() {
+      return "buffer";
+    }
+
+    @Override
+    public OutputStream openForWrite(Uri uri) throws IOException {
+      File tempFile = new File(uri.getPath() + ".tmp");
+      File finalFile = new File(uri.getPath());
+      return new BufferingOutputStream(new FileOutputStream(tempFile), tempFile, finalFile);
+    }
+
+    private static class BufferingOutputStream extends ForwardingOutputStream
+        implements FileConvertible {
+      private final File tempFile;
+      private final File finalFile;
+
+      BufferingOutputStream(OutputStream stream, File tempFile, File finalFile) {
+        super(stream);
+        this.tempFile = tempFile;
+        this.finalFile = finalFile;
+      }
+
+      @Override
+      public File toFile() {
+        return tempFile;
+      }
+
+      @Override
+      public void close() throws IOException {
+        out.close();
+        tempFile.renameTo(finalFile);
+      }
+    }
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/openers/WriteProtoOpenerTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/WriteProtoOpenerTest.java
new file mode 100644
index 0000000..3950d2e
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/WriteProtoOpenerTest.java
@@ -0,0 +1,115 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.openers;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.behaviors.SyncingBehavior;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.FakeFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.TemporaryUri;
+import com.google.android.libraries.mobiledatadownload.file.transforms.CompressTransform;
+import com.google.android.libraries.storage.file.common.testing.TestMessageProto.FooProto;
+import java.io.IOException;
+import java.util.Arrays;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public final class WriteProtoOpenerTest {
+
+  private static final FooProto TEST_PROTO =
+      FooProto.newBuilder()
+          .setText("all work and no play makes jack a dull boy all work and no play makes jack a")
+          .setBoolean(true)
+          .build();
+
+  private SynchronousFileStorage storage;
+  private final FakeFileBackend backend = new FakeFileBackend();
+
+  @Rule public TemporaryUri tmpUri = new TemporaryUri();
+
+  @Before
+  public void setUpStorage() throws Exception {
+    storage =
+        new SynchronousFileStorage(Arrays.asList(backend), Arrays.asList(new CompressTransform()));
+  }
+
+  @Test
+  public void writesProto() throws Exception {
+    Uri uri = tmpUri.newUri();
+    storage.open(uri, WriteProtoOpener.create(TEST_PROTO));
+
+    FooProto actual = storage.open(uri, ReadProtoOpener.create(FooProto.parser()));
+    assertThat(actual).isEqualTo(TEST_PROTO);
+  }
+
+  @Test
+  public void writesProto_withTransform() throws Exception {
+    Uri uri = tmpUri.newUri().buildUpon().encodedFragment("transform=compress").build();
+    storage.open(uri, WriteProtoOpener.create(TEST_PROTO));
+
+    FooProto actual = storage.open(uri, ReadProtoOpener.create(FooProto.parser()));
+    assertThat(actual).isEqualTo(TEST_PROTO);
+    assertThat(storage.fileSize(uri)).isLessThan(TEST_PROTO.getSerializedSize());
+  }
+
+  @Test
+  public void failedWrite_noChange() throws Exception {
+    Uri uri = tmpUri.newUri();
+    storage.open(uri, WriteProtoOpener.create(TEST_PROTO));
+
+    FooProto modifiedProto = TEST_PROTO.toBuilder().setBoolean(false).build();
+    IOException expected = new IOException("expected");
+    backend.setFailure(FakeFileBackend.OperationType.WRITE_STREAM, expected);
+
+    assertThrows(
+        IOException.class, () -> storage.open(uri, WriteProtoOpener.create(modifiedProto)));
+
+    FooProto actual = storage.open(uri, ReadProtoOpener.create(FooProto.parser()));
+    assertThat(actual).isEqualTo(TEST_PROTO);
+  }
+
+  @Test
+  public void failedRename_noChange() throws Exception {
+    Uri uri = tmpUri.newUri();
+    storage.open(uri, WriteProtoOpener.create(TEST_PROTO));
+
+    FooProto modifiedProto = TEST_PROTO.toBuilder().setBoolean(false).build();
+    IOException expected = new IOException("expected");
+    backend.setFailure(FakeFileBackend.OperationType.MANAGE, expected);
+
+    assertThrows(
+        IOException.class, () -> storage.open(uri, WriteProtoOpener.create(modifiedProto)));
+
+    FooProto actual = storage.open(uri, ReadProtoOpener.create(FooProto.parser()));
+    assertThat(actual).isEqualTo(TEST_PROTO);
+  }
+
+  @Test
+  public void invokes_autoSync() throws Exception {
+    Uri uri1 = tmpUri.newUri();
+    SyncingBehavior syncing = Mockito.spy(new SyncingBehavior());
+    storage.open(uri1, WriteProtoOpener.create(TEST_PROTO).withBehaviors(syncing));
+    Mockito.verify(syncing).sync();
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/openers/hello1native.cc b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/hello1native.cc
new file mode 100644
index 0000000..fcbe7bc
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/hello1native.cc
@@ -0,0 +1,24 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+#include <jni.h>
+
+namespace {
+
+extern "C" JNIEXPORT jstring JNICALL
+Java_com_google_android_libraries_storage_file_openers_HelloNative_sayHello(
+    JNIEnv* env) {
+  return env->NewStringUTF("hello1");
+}
+
+}  // namespace
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/openers/hello2native.cc b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/hello2native.cc
new file mode 100644
index 0000000..fe22b72
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/openers/hello2native.cc
@@ -0,0 +1,24 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+#include <jni.h>
+
+namespace {
+
+extern "C" JNIEXPORT jstring JNICALL
+Java_com_google_android_libraries_storage_file_openers_HelloNative_sayHello(
+    JNIEnv* env) {
+  return env->NewStringUTF("hello2");
+}
+
+}  // namespace
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/samples/BUILD b/javatests/com/google/android/libraries/mobiledatadownload/file/samples/BUILD
new file mode 100644
index 0000000..9652356
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/samples/BUILD
@@ -0,0 +1,37 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_local_test")
+
+package(
+    default_visibility = ["//:__subpackages__"],
+    licenses = ["notice"],
+)
+
+android_local_test(
+    name = "SamplesTest",
+    size = "small",
+    srcs = ["SamplesTest.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file_adapter",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:stream",
+        "//java/com/google/android/libraries/mobiledatadownload/file/samples",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:compress",
+        "@com_google_guava_guava",
+        "@truth",
+    ],
+)
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/samples/SamplesTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/samples/SamplesTest.java
new file mode 100644
index 0000000..808734e
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/samples/SamplesTest.java
@@ -0,0 +1,119 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.samples;
+
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.createFile;
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.readFile;
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import android.net.Uri;
+import android.text.TextUtils;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.FileUriAdapter;
+import com.google.android.libraries.mobiledatadownload.file.backends.JavaFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.common.Sizable;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.TemporaryUri;
+import com.google.android.libraries.mobiledatadownload.file.openers.ReadStreamOpener;
+import com.google.android.libraries.mobiledatadownload.file.transforms.CompressTransform;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+import com.google.common.io.BaseEncoding;
+import com.google.thirdparty.robolectric.GoogleRobolectricTestRunner;
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(GoogleRobolectricTestRunner.class)
+public final class SamplesTest {
+  SynchronousFileStorage storage;
+  ByteCountingMonitor byteCounter;
+
+  @Rule public TemporaryUri tmpUri = new TemporaryUri();
+
+  @Before
+  public void setupFileStorage() {
+    byteCounter = new ByteCountingMonitor();
+    storage =
+        new SynchronousFileStorage(
+            ImmutableList.of(new JavaFileBackend(), new Base64Backend()),
+            ImmutableList.of(
+                new Base64Transform(), new CapitalizationTransform(), new CompressTransform()),
+            ImmutableList.of(byteCounter));
+  }
+
+  @Test
+  public void capitalizeAndBase64ShouldRoundTrip() throws Exception {
+    Uri uri = tmpUri.newUri().buildUpon().encodedFragment("transform=capitalize+base64").build();
+    createFile(storage, uri, "SOME ALL CAPS TEXT");
+    assertThat(readFile(storage, uri)).isEqualTo("SOME ALL CAPS TEXT");
+
+    // Check raw file and manually decoded result.
+    List<String> path = Lists.newArrayList(uri.getPathSegments());
+    String filename = path.get(path.size() - 1);
+    filename = filename.toLowerCase(); // capitalize
+    filename = BaseEncoding.base64().encode(filename.getBytes(UTF_8)); // base64
+    path.set(path.size() - 1, filename);
+    Uri encodedUri = uri.buildUpon().path(TextUtils.join("/", path)).build();
+    File encodedFile = FileUriAdapter.instance().toFile(encodedUri);
+    BufferedReader rawReader =
+        new BufferedReader(new InputStreamReader(new FileInputStream(encodedFile), UTF_8));
+    String encodedText = rawReader.readLine();
+    assertThat(encodedText).isEqualTo("c29tZSBhbGwgY2FwcyB0ZXh0");
+    String lowercaseText = new String(BaseEncoding.base64().decode(encodedText), UTF_8);
+    assertThat(lowercaseText).isEqualTo("some all caps text");
+    rawReader.close();
+
+    assertThat(byteCounter.stats())
+        .isEqualTo(new long[] {encodedText.length(), encodedText.length()});
+  }
+
+  @Test
+  public void capitalizeAndBase64AndCompressShouldRoundTrip() throws Exception {
+    Uri uri =
+        tmpUri.newUri().buildUpon().encodedFragment("transform=capitalize+base64+compress").build();
+    createFile(storage, uri, "SOME ALL CAPS TEXT");
+    assertThat(readFile(storage, uri)).isEqualTo("SOME ALL CAPS TEXT");
+    assertThat(byteCounter.stats()).isEqualTo(new long[] {32, 32});
+  }
+
+  @Test
+  public void base64BackendShouldRoundTrip() throws Exception {
+    Uri uri = tmpUri.newUri().buildUpon().scheme("base64").build();
+    createFile(storage, uri, "SOME ALL CAPS TEXT");
+    assertThat(readFile(storage, uri)).isEqualTo("SOME ALL CAPS TEXT");
+    assertThat(byteCounter.stats())
+        .isEqualTo(new long[] {"SOME ALL CAPS TEXT".length(), "SOME ALL CAPS TEXT".length()});
+  }
+
+  @Test
+  public void capitalizationTransform_shouldBeSizable() throws Exception {
+    Uri uri = tmpUri.newUri().buildUpon().encodedFragment("transform=capitalize").build();
+    String text = "SOME ALL CAPS TEXT";
+    createFile(storage, uri, text);
+    try (InputStream in = storage.open(uri, ReadStreamOpener.create())) {
+      assertThat(in instanceof Sizable).isTrue();
+      assertThat(((Sizable) in).size()).isEqualTo(text.length());
+    }
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/transforms/BUILD b/javatests/com/google/android/libraries/mobiledatadownload/file/transforms/BUILD
new file mode 100644
index 0000000..7b66e71
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/transforms/BUILD
@@ -0,0 +1,98 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_library", "android_local_test")
+
+package(
+    default_visibility = ["//:__subpackages__"],
+    licenses = ["notice"],
+)
+
+android_local_test(
+    name = "BufferTransformTest",
+    size = "small",
+    srcs = ["BufferTransformTest.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:stream",
+        "//java/com/google/android/libraries/mobiledatadownload/file/spi",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:buffer",
+        "@com_google_guava_guava",
+        "@mockito",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "CompressTransformTest",
+    size = "small",
+    srcs = ["CompressTransformTest.java"],
+    data = [
+        "//javatests/com/google/android/libraries/mobiledatadownload/file/transforms/testdata:golden.deflate",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/samples",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:compress",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:proto",
+        "//proto:transform_java_proto_lite",
+        "@com_google_guava_guava",
+        "@com_google_runfiles",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "IntegrityTransformTest",
+    size = "small",
+    srcs = ["IntegrityTransformTest.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/behaviors:compute_uri",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:stream",
+        "//java/com/google/android/libraries/mobiledatadownload/file/spi",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:integrity",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:proto",
+        "//proto:transform_java_proto_lite",
+        "@com_google_guava_guava",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "TransformProtosTest",
+    size = "small",
+    srcs = ["TransformProtosTest.java"],
+    manifest = "//javatests/com/google/android/libraries/mobiledatadownload/file:AndroidManifest.xml",
+    deps = [
+        ":android_library_proto_data",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:proto",
+        "//proto:transform_java_proto_lite",
+        "@androidx_test",
+        "@com_google_guava_guava",
+        "@com_google_protobuf//:parsers",
+        "@truth",
+    ],
+)
+
+android_library(
+    name = "android_library_proto_data",
+    manifest = "//javatests/com/google/android/libraries/mobiledatadownload/file:AndroidManifest.xml",
+    resource_files = ["//javatests/com/google/android/libraries/mobiledatadownload/file/transforms/testdata:transforms.pb"],
+)
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/transforms/BufferTransformTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/transforms/BufferTransformTest.java
new file mode 100644
index 0000000..8e608bb
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/transforms/BufferTransformTest.java
@@ -0,0 +1,160 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.transforms;
+
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.createFile;
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.readFile;
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.JavaFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.TemporaryUri;
+import com.google.android.libraries.mobiledatadownload.file.openers.WriteStreamOpener;
+import com.google.android.libraries.mobiledatadownload.file.spi.Backend;
+import com.google.android.libraries.mobiledatadownload.file.spi.Transform;
+import com.google.common.collect.ImmutableList;
+import java.io.ByteArrayOutputStream;
+import java.io.OutputStream;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public final class BufferTransformTest {
+
+  private static final String PLAINTEXT = "This is some regular old plaintext ABC 123 !@#\n";
+
+  @Rule public TemporaryUri tmpUri = new TemporaryUri();
+  @Rule public final MockitoRule mocks = MockitoJUnit.rule();
+
+  @Mock Backend mockBackend;
+
+  private SynchronousFileStorage storage;
+  private SynchronousFileStorage mockedStorage;
+
+  @Before
+  public void setUpStorage() throws Exception {
+    storage =
+        new SynchronousFileStorage(
+            ImmutableList.of(new JavaFileBackend()), ImmutableList.of(new BufferTransform()));
+
+    when(mockBackend.name()).thenReturn("file");
+    mockedStorage =
+        new SynchronousFileStorage(
+            ImmutableList.of(mockBackend), ImmutableList.of(new BufferTransform()));
+  }
+
+  @Test
+  public void name_isBuffer() throws Exception {
+    Transform transform = new BufferTransform();
+    assertThat(transform.name()).isEqualTo("buffer");
+  }
+
+  @Test
+  public void writeThenRead() throws Exception {
+    Uri uri =
+        tmpUri.newUriBuilder().build().buildUpon().encodedFragment("transform=buffer").build();
+
+    createFile(storage, uri, PLAINTEXT);
+
+    assertThat(readFile(storage, uri)).isEqualTo(PLAINTEXT);
+  }
+
+  @Test
+  public void param_hasDefaultBufferSize() throws Exception {
+    Uri uri =
+        tmpUri.newUriBuilder().build().buildUpon().encodedFragment("transform=buffer").build();
+    ByteArrayOutputStream backendOutputStream = new ByteArrayOutputStream();
+    when(mockBackend.openForWrite(any(Uri.class))).thenReturn(backendOutputStream);
+
+    try (OutputStream outputStream = mockedStorage.open(uri, WriteStreamOpener.create())) {
+      outputStream.write(new byte[8191]);
+      outputStream.write(new byte[2]);
+
+      assertThat(backendOutputStream.size()).isEqualTo(8191);
+    }
+  }
+
+  @Test
+  public void param_controlsBufferSize() throws Exception {
+    Uri uri =
+        tmpUri
+            .newUriBuilder()
+            .build()
+            .buildUpon()
+            .encodedFragment("transform=buffer(size=100)")
+            .build();
+    ByteArrayOutputStream backendOutputStream = new ByteArrayOutputStream();
+    when(mockBackend.openForWrite(any(Uri.class))).thenReturn(backendOutputStream);
+
+    try (OutputStream outputStream = mockedStorage.open(uri, WriteStreamOpener.create())) {
+      outputStream.write(new byte[99]);
+      outputStream.write(new byte[2]);
+
+      assertThat(backendOutputStream.size()).isEqualTo(99);
+    }
+  }
+
+  @Test
+  public void write_buffers() throws Exception {
+    Uri uri =
+        tmpUri.newUriBuilder().build().buildUpon().encodedFragment("transform=buffer").build();
+    ByteArrayOutputStream backendOutputStream = new ByteArrayOutputStream();
+    when(mockBackend.openForWrite(any(Uri.class))).thenReturn(backendOutputStream);
+    byte[] expectedBuffer = new byte[8192];
+    for (int i = 0; i < 8; i++) {
+      expectedBuffer[i * 1024] = (byte) i;
+    }
+
+    try (OutputStream outputStream = mockedStorage.open(uri, WriteStreamOpener.create())) {
+      byte[] bytes = new byte[1024];
+      for (int i = 0; i < 8; i++) {
+        bytes[0] = (byte) i;
+        outputStream.write(bytes);
+        assertThat(backendOutputStream.size()).isEqualTo(0);
+      }
+      outputStream.flush();
+
+      assertThat(backendOutputStream.size()).isEqualTo(8192);
+    }
+  }
+
+  @Test
+  public void flush_emptiesBuffer() throws Exception {
+    Uri uri =
+        tmpUri.newUriBuilder().build().buildUpon().encodedFragment("transform=buffer").build();
+    ByteArrayOutputStream backendOutputStream = new ByteArrayOutputStream();
+    when(mockBackend.openForWrite(any(Uri.class))).thenReturn(backendOutputStream);
+    byte[] expectedBuffer = new byte[8192];
+    System.arraycopy(PLAINTEXT.getBytes(UTF_8), 0, expectedBuffer, 0, PLAINTEXT.length());
+
+    try (OutputStream outputStream = mockedStorage.open(uri, WriteStreamOpener.create())) {
+      outputStream.write(PLAINTEXT.getBytes(UTF_8));
+      assertThat(backendOutputStream.size()).isEqualTo(0);
+      outputStream.flush();
+      assertThat(backendOutputStream.size()).isEqualTo(PLAINTEXT.length());
+    }
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/transforms/CompressTransformTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/transforms/CompressTransformTest.java
new file mode 100644
index 0000000..ef6e202
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/transforms/CompressTransformTest.java
@@ -0,0 +1,116 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.transforms;
+
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.createFile;
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.readFile;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.FileUri;
+import com.google.android.libraries.mobiledatadownload.file.backends.JavaFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.TemporaryUri;
+import com.google.android.libraries.mobiledatadownload.file.samples.ByteCountingMonitor;
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
+import com.google.devtools.build.runtime.Runfiles;
+import java.io.File;
+import java.util.Collections;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public final class CompressTransformTest {
+
+  private ByteCountingMonitor byteCounter;
+  private SynchronousFileStorage storage;
+
+  @Rule public TemporaryUri tmpUri = new TemporaryUri();
+
+  @Before
+  public void setupFileStorage() {
+    byteCounter = new ByteCountingMonitor();
+    storage =
+        new SynchronousFileStorage(
+            ImmutableList.of(new JavaFileBackend()),
+            ImmutableList.of(new CompressTransform()),
+            ImmutableList.of(byteCounter));
+  }
+
+  @Test
+  public void counterWithCompress() throws Exception {
+    Uri uri = tmpUri.newUriBuilder().withTransform(TransformProtos.DEFAULT_COMPRESS_SPEC).build();
+    String text = "compress me!";
+    createFile(storage, uri, text);
+    assertThat(byteCounter.stats()).isEqualTo(new long[] {0, 20});
+    int ratio = text.length() / 20;
+    assertThat(ratio).isEqualTo(0);
+
+    readFile(storage, uri);
+    assertThat(byteCounter.stats()).isEqualTo(new long[] {20, 20});
+
+    text = Joiner.on("\n").join(Collections.nCopies(10, text));
+    createFile(storage, uri, text);
+    assertThat(byteCounter.stats()).isEqualTo(new long[] {20, 44});
+    ratio = text.length() / (44 - 20);
+    assertThat(ratio).isEqualTo(5);
+
+    text = Joiner.on("\n").join(Collections.nCopies(10, text));
+    createFile(storage, uri, text);
+    assertThat(byteCounter.stats()).isEqualTo(new long[] {20, 76});
+    ratio = text.length() / (76 - 44);
+    assertThat(ratio).isEqualTo(40);
+  }
+
+  @Test
+  public void compressGoldenFile() throws Exception {
+    // Arbitrary data.
+    String toWrite = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+
+    FileUri.Builder builder = tmpUri.newUriBuilder();
+    Uri uriWithoutTransform = builder.build();
+    Uri uriWithTransform = builder.withTransform(TransformProtos.DEFAULT_COMPRESS_SPEC).build();
+    createFile(storage, uriWithTransform, toWrite);
+
+    // Read back raw bytes.
+    String fileContents = readFile(storage, uriWithoutTransform);
+
+    // Upload the result to Sponge for easy updating.
+    File undeclaredOutputDir = getDefaultUndeclaredOutputDir();
+    FileUri.Builder spongeBuilder = FileUri.builder();
+    spongeBuilder.fromFile(undeclaredOutputDir).appendPath("golden.deflate");
+    Uri undeclaredOutputUri = spongeBuilder.build();
+    createFile(storage, undeclaredOutputUri, fileContents);
+
+    // Check data against golden file.
+    File goldenFile =
+        Runfiles.location(
+            "third_party/java_src/android_libs/mobiledatadownload/javatests/com/google/android/libraries/mobiledatadownload/file/transforms/testdata/golden.deflate");
+    Uri goldenUri = FileUri.fromFile(goldenFile);
+    assertThat(fileContents).isEqualTo(readFile(storage, goldenUri));
+  }
+
+  // Copied from UndeclaredOutputs.java - we can't use it directly due to a proto dependency
+  // conflict.
+  private static File getDefaultUndeclaredOutputDir() {
+    String dir = System.getenv("TEST_UNDECLARED_OUTPUTS_DIR");
+    return new File(dir);
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/transforms/IntegrityTransformTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/transforms/IntegrityTransformTest.java
new file mode 100644
index 0000000..9463a53
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/transforms/IntegrityTransformTest.java
@@ -0,0 +1,250 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.transforms;
+
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.createFile;
+import static com.google.android.libraries.mobiledatadownload.file.common.testing.StreamUtils.readFile;
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.Assert.assertThrows;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.FileUri;
+import com.google.android.libraries.mobiledatadownload.file.backends.JavaFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.behaviors.UriComputingBehavior;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.TemporaryUri;
+import com.google.android.libraries.mobiledatadownload.file.openers.AppendStreamOpener;
+import com.google.android.libraries.mobiledatadownload.file.openers.ReadStreamOpener;
+import com.google.android.libraries.mobiledatadownload.file.openers.WriteStreamOpener;
+import com.google.android.libraries.mobiledatadownload.file.spi.Transform;
+import com.google.common.collect.ImmutableList;
+import com.google.common.io.ByteStreams;
+import com.google.thirdparty.robolectric.GoogleRobolectricTestRunner;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.Writer;
+import java.util.concurrent.Future;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(GoogleRobolectricTestRunner.class)
+public final class IntegrityTransformTest {
+
+  private static final String ORIGTEXT = "This is some regular old text ABC 123 !@#";
+  private static final String ORIGTEXT_SHA256_B64 = "FoR1HrxdAhY05DE/gAUj0yjpzYpfWb0fJE+XBp8lY0o=";
+
+  @Rule public TemporaryUri tmpUri = new TemporaryUri();
+
+  private SynchronousFileStorage storage;
+
+  @Before
+  public void setUpStorage() throws Exception {
+    storage =
+        new SynchronousFileStorage(
+            ImmutableList.of(new JavaFileBackend()), ImmutableList.of(new IntegrityTransform()));
+  }
+
+  @Test
+  public void name_isIntegrity() throws Exception {
+    Transform transform = new IntegrityTransform();
+    assertThat(transform.name()).isEqualTo("integrity");
+  }
+
+  @Test
+  public void write_shouldComputeChecksum() throws Exception {
+    Uri uri = tmpUri.newUriBuilder().withTransform(TransformProtos.DEFAULT_INTEGRITY_SPEC).build();
+    Future<Uri> uriFuture;
+    UriComputingBehavior computeUri = new UriComputingBehavior(uri);
+    try (OutputStream sink =
+            storage.open(uri, WriteStreamOpener.create().withBehaviors(computeUri));
+        Writer writer = new OutputStreamWriter(sink, UTF_8)) {
+      writer.write(ORIGTEXT);
+      uriFuture = computeUri.uriFuture();
+    }
+
+    Uri uriWithHash = uriFuture.get();
+
+    assertThat(IntegrityTransform.getDigestIfPresent(uriWithHash)).isEqualTo(ORIGTEXT_SHA256_B64);
+  }
+
+  @Test
+  public void consumeStream_shouldComputeChecksum() throws Exception {
+    FileUri.Builder builder = tmpUri.newUriBuilder();
+    createFile(storage, builder.build(), ORIGTEXT);
+    Uri uri = builder.withTransform(TransformProtos.DEFAULT_INTEGRITY_SPEC).build();
+
+    UriComputingBehavior computeUri = new UriComputingBehavior(uri);
+    Future<Uri> uriFuture;
+    try (InputStream in = storage.open(uri, ReadStreamOpener.create().withBehaviors(computeUri))) {
+      ByteStreams.exhaust(in);
+      uriFuture = computeUri.uriFuture();
+    }
+
+    assertThat(IntegrityTransform.getDigestIfPresent(uriFuture.get()))
+        .isEqualTo(ORIGTEXT_SHA256_B64);
+  }
+
+  @Test
+  public void consumeStream_shouldBothVerifyAndComputeChecksum() throws Exception {
+    FileUri.Builder builder = tmpUri.newUriBuilder();
+    createFile(storage, builder.build(), ORIGTEXT);
+    Uri uri = builder.withTransform(TransformProtos.DEFAULT_INTEGRITY_SPEC).build();
+
+    UriComputingBehavior computeUri = new UriComputingBehavior(uri);
+    Future<Uri> uriFuture;
+    try (InputStream in = storage.open(uri, ReadStreamOpener.create().withBehaviors(computeUri))) {
+      ByteStreams.exhaust(in);
+      uriFuture = computeUri.uriFuture();
+    }
+
+    assertThat(IntegrityTransform.getDigestIfPresent(uriFuture.get()))
+        .isEqualTo(ORIGTEXT_SHA256_B64);
+
+    // Try again, using the uriWithHash as input, so that hash is verified and computed
+    // simultaneously.
+    UriComputingBehavior computeUri2 = new UriComputingBehavior(uriFuture.get());
+    Future<Uri> uriFuture2;
+    try (InputStream in =
+        storage.open(uriFuture.get(), ReadStreamOpener.create().withBehaviors(computeUri2))) {
+      ByteStreams.exhaust(in);
+      uriFuture2 = computeUri2.uriFuture();
+    }
+
+    assertThat(IntegrityTransform.getDigestIfPresent(uriFuture2.get()))
+        .isEqualTo(ORIGTEXT_SHA256_B64);
+  }
+
+  @Test
+  public void readFully_shouldComputeChecksum() throws Exception {
+    // Buffer is exact length, does not reach EOF. Depends on close() to compute checksum.
+    FileUri.Builder builder = tmpUri.newUriBuilder();
+    createFile(storage, builder.build(), ORIGTEXT);
+    Uri uri = builder.withTransform(TransformProtos.DEFAULT_INTEGRITY_SPEC).build();
+
+    Future<Uri> result;
+    byte[] buffer = new byte[ORIGTEXT.length()];
+    UriComputingBehavior computeUri = new UriComputingBehavior(uri);
+    try (InputStream input =
+        storage.open(uri, ReadStreamOpener.create().withBehaviors(computeUri))) {
+      ByteStreams.readFully(input, buffer);
+      result = computeUri.uriFuture();
+    }
+
+    assertThat(buffer).isEqualTo(ORIGTEXT.getBytes("UTF-8"));
+    assertThat(IntegrityTransform.getDigestIfPresent(result.get())).isEqualTo(ORIGTEXT_SHA256_B64);
+  }
+
+  @Test
+  public void readWithWithPrematureClose_shouldComputeDifferentChecksum() throws Exception {
+    // This behavior is not necessarily useful, but is here to be documented.
+    FileUri.Builder builder = tmpUri.newUriBuilder();
+    createFile(storage, builder.build(), ORIGTEXT);
+    Uri uri = builder.withTransform(TransformProtos.DEFAULT_INTEGRITY_SPEC).build();
+
+    byte[] buffer = new byte[ORIGTEXT.length() - 1]; // Full length - 1.
+    Future<Uri> result;
+    UriComputingBehavior computeUri = new UriComputingBehavior(uri);
+    try (InputStream input =
+        storage.open(uri, ReadStreamOpener.create().withBehaviors(computeUri))) {
+      ByteStreams.readFully(input, buffer);
+      result = computeUri.uriFuture();
+    }
+
+    assertThat(buffer).isNotEqualTo(ORIGTEXT.getBytes("UTF-8"));
+    assertThat(IntegrityTransform.getDigestIfPresent(result.get()))
+        .isNotEqualTo(ORIGTEXT_SHA256_B64);
+  }
+
+  @Test
+  public void read_shouldValidateChecksum() throws Exception {
+    FileUri.Builder builder = tmpUri.newUriBuilder();
+    Uri uriWithoutTransform = builder.build();
+    Uri uri = builder.withTransform(TransformProtos.DEFAULT_INTEGRITY_SPEC).build();
+    createFile(storage, uriWithoutTransform, ORIGTEXT);
+
+    Future<Uri> uriFuture;
+    UriComputingBehavior computeUri = new UriComputingBehavior(uri);
+    try (InputStream in = storage.open(uri, ReadStreamOpener.create().withBehaviors(computeUri))) {
+      ByteStreams.exhaust(in);
+      uriFuture = computeUri.uriFuture();
+    }
+
+    assertThat(IntegrityTransform.getDigestIfPresent(uriFuture.get()))
+        .isEqualTo(ORIGTEXT_SHA256_B64);
+    assertThat(readFile(storage, uri)).isEqualTo(ORIGTEXT);
+    assertThat(readFile(storage, uriFuture.get())).isEqualTo(ORIGTEXT);
+
+    try (Writer writer =
+        new OutputStreamWriter(
+            storage.open(uriWithoutTransform, AppendStreamOpener.create()), UTF_8)) {
+      writer.write("pwned");
+    }
+    assertThat(readFile(storage, uri)).endsWith("pwned");
+    assertThrows(IOException.class, () -> readFile(storage, uriFuture.get()));
+  }
+
+  @Test
+  public void read_shouldValidateChecksumWithReadingOneByteAtATime() throws Exception {
+    FileUri.Builder builder = tmpUri.newUriBuilder();
+    Uri uriWithoutTransform = builder.build();
+    Uri uri = builder.withTransform(TransformProtos.DEFAULT_INTEGRITY_SPEC).build();
+    createFile(storage, uriWithoutTransform, ORIGTEXT);
+
+    Future<Uri> uriFuture;
+    UriComputingBehavior computeUri = new UriComputingBehavior(uri);
+    try (InputStream in = storage.open(uri, ReadStreamOpener.create().withBehaviors(computeUri))) {
+      ByteStreams.exhaust(in);
+      uriFuture = computeUri.uriFuture();
+    }
+    try (Writer writer =
+        new OutputStreamWriter(
+            storage.open(uriWithoutTransform, AppendStreamOpener.create()), UTF_8)) {
+      writer.write("pwned");
+    }
+
+    assertThrows(IOException.class, () -> readFile(storage, uriFuture.get()));
+  }
+
+  @Test
+  public void partialReadAndClose_shouldFailValidation() throws Exception {
+    FileUri.Builder builder = tmpUri.newUriBuilder();
+    createFile(storage, builder.build(), ORIGTEXT);
+    Uri uri = builder.withTransform(TransformProtos.DEFAULT_INTEGRITY_SPEC).build();
+
+    Future<Uri> uriFuture;
+    UriComputingBehavior computeUri = new UriComputingBehavior(uri);
+    try (InputStream in = storage.open(uri, ReadStreamOpener.create().withBehaviors(computeUri))) {
+      ByteStreams.exhaust(in);
+      uriFuture = computeUri.uriFuture();
+    }
+
+    assertThat(IntegrityTransform.getDigestIfPresent(uriFuture.get()))
+        .isEqualTo(ORIGTEXT_SHA256_B64);
+    assertThat(readFile(storage, uri)).isEqualTo(ORIGTEXT);
+    assertThat(readFile(storage, uriFuture.get())).isEqualTo(ORIGTEXT);
+
+    // Fills a buffer with full length - 1.
+    byte[] buffer = new byte[ORIGTEXT.length() - 1];
+    InputStream input = storage.open(uriFuture.get(), ReadStreamOpener.create());
+    ByteStreams.readFully(input, buffer);
+    assertThrows(IOException.class, () -> input.close());
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/transforms/TransformProtosTest.java b/javatests/com/google/android/libraries/mobiledatadownload/file/transforms/TransformProtosTest.java
new file mode 100644
index 0000000..59d5f02
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/transforms/TransformProtosTest.java
@@ -0,0 +1,155 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.file.transforms;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.net.Uri;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.common.collect.ImmutableList;
+import com.google.mobiledatadownload.TransformProto;
+import com.google.protobuf.contrib.android.ProtoParsers;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public final class TransformProtosTest {
+
+  @Test
+  public void emptyProto_producesEmptyTransform() throws Exception {
+    TransformProto.Transforms emptyTransforms = TransformProto.Transforms.getDefaultInstance();
+    String fragment = TransformProtos.toEncodedFragment(emptyTransforms);
+    assertThat(fragment).isNull();
+    assertThat(
+            Uri.parse("http://foo.bar#transform=foo")
+                .buildUpon()
+                .encodedFragment(fragment)
+                .build()
+                .toString())
+        .isEqualTo("http://foo.bar");
+  }
+
+  @Test
+  public void missingTransform_throwsException() throws Exception {
+    TransformProto.Transforms invalidTransform =
+        TransformProto.Transforms.newBuilder()
+            .addTransform(TransformProto.Transform.getDefaultInstance())
+            .build();
+
+    assertThrows(
+        IllegalArgumentException.class, () -> TransformProtos.toEncodedFragment(invalidTransform));
+  }
+
+  @Test
+  public void compressProto_producesCompressTransform() throws Exception {
+    TransformProto.Transforms transformsProto =
+        TransformProto.Transforms.newBuilder()
+            .addTransform(
+                TransformProto.Transform.newBuilder()
+                    .setCompress(TransformProto.CompressTransform.getDefaultInstance()))
+            .build();
+    String fragment = TransformProtos.toEncodedFragment(transformsProto);
+    assertThat(fragment).isEqualTo("transform=compress");
+  }
+
+  @Test
+  public void encryptProto_producesEncryptTransform() throws Exception {
+    TransformProto.Transforms transformsProto =
+        TransformProto.Transforms.newBuilder()
+            .addTransform(
+                TransformProto.Transform.newBuilder()
+                    .setEncrypt(TransformProto.EncryptTransform.getDefaultInstance()))
+            .build();
+    String fragment = TransformProtos.toEncodedFragment(transformsProto);
+    assertThat(fragment).isEqualTo("transform=encrypt");
+  }
+
+  @Test
+  public void integrityProto_producesIntegrityTransform() throws Exception {
+    TransformProto.Transforms transformsProto =
+        TransformProto.Transforms.newBuilder()
+            .addTransform(
+                TransformProto.Transform.newBuilder()
+                    .setIntegrity(TransformProto.IntegrityTransform.getDefaultInstance()))
+            .build();
+    String fragment = TransformProtos.toEncodedFragment(transformsProto);
+    assertThat(fragment).isEqualTo("transform=integrity");
+  }
+
+  @Test
+  public void zipProto_producesZipTransform() throws Exception {
+    TransformProto.Transforms transformsProto =
+        TransformProto.Transforms.newBuilder()
+            .addTransform(
+                TransformProto.Transform.newBuilder()
+                    .setZip(TransformProto.ZipTransform.newBuilder().setTarget("abc")))
+            .build();
+    String fragment = TransformProtos.toEncodedFragment(transformsProto);
+    assertThat(fragment).isEqualTo("transform=zip(target=abc)");
+  }
+
+  @Test
+  public void customProto_producesCustomTransform() throws Exception {
+    TransformProto.Transforms transformsProto =
+        TransformProto.Transforms.newBuilder()
+            .addTransform(
+                TransformProto.Transform.newBuilder()
+                    .setCustom(
+                        TransformProto.CustomTransform.newBuilder()
+                            .setName("custom")
+                            .addAllSubparam(
+                                ImmutableList.of(
+                                    TransformProto.CustomTransform.SubParam.newBuilder()
+                                        .setKey("key1")
+                                        .setValue("value1")
+                                        .build(),
+                                    TransformProto.CustomTransform.SubParam.newBuilder()
+                                        .setKey("key2")
+                                        .setValue("=?!")
+                                        .build()))))
+            .build();
+    String fragment = TransformProtos.toEncodedFragment(transformsProto);
+    assertThat(fragment).isEqualTo("transform=custom(key1=value1,key2=%3D%3F%21)");
+  }
+
+  @Test
+  public void textProto_producesMultiTransform() throws Exception {
+    TransformProto.Transforms transformsProto = getTransformsFromTextProto();
+    String fragment = TransformProtos.toEncodedFragment(transformsProto);
+    assertThat(fragment)
+        .isEqualTo("transform=compress+encrypt(aes_gcm_key=a%2Bbc)+integrity(sha256=ab%2Bc)");
+  }
+
+  @Test
+  public void canonicalExample_addProtoTransformToUri() throws Exception {
+    TransformProto.Transforms transformsProto = getTransformsFromTextProto();
+    String fragment = TransformProtos.toEncodedFragment(transformsProto);
+    Uri uri = Uri.parse("file:/tmp/foo.txt").buildUpon().encodedFragment(fragment).build();
+    assertThat(uri.toString())
+        .isEqualTo(
+            "file:/tmp/foo.txt"
+                + "#transform=compress+encrypt(aes_gcm_key=a%2Bbc)+integrity(sha256=ab%2Bc)");
+  }
+
+  private TransformProto.Transforms getTransformsFromTextProto() {
+    return ProtoParsers.parseFromRawRes(
+        ApplicationProvider.getApplicationContext(),
+        TransformProto.Transforms.parser(),
+        R.raw.transforms_data_pb);
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/transforms/testdata/BUILD b/javatests/com/google/android/libraries/mobiledatadownload/file/transforms/testdata/BUILD
new file mode 100644
index 0000000..b1326e9
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/transforms/testdata/BUILD
@@ -0,0 +1,34 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("//tools/build_rules/text_to_binary:def.bzl", "proto_data")
+
+package(
+    default_visibility = ["//:__subpackages__"],
+    licenses = ["notice"],
+)
+
+exports_files([
+    # NOTE: generated by CompressTransformTest#compressGoldenFile
+    "golden.deflate",
+])
+
+proto_data(
+    name = "transforms.pb",
+    src = "transforms.pb.txt",
+    out = "res/raw/transforms_data_pb",
+    proto_deps = [
+        "//proto:transform_proto",
+    ],
+    proto_name = "mobstore.proto.Transforms",
+)
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/transforms/testdata/golden.deflate b/javatests/com/google/android/libraries/mobiledatadownload/file/transforms/testdata/golden.deflate
new file mode 100644
index 0000000..f58cc0d
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/transforms/testdata/golden.deflate
Binary files differ
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/file/transforms/testdata/transforms.pb.txt b/javatests/com/google/android/libraries/mobiledatadownload/file/transforms/testdata/transforms.pb.txt
new file mode 100644
index 0000000..0fdd571
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/file/transforms/testdata/transforms.pb.txt
@@ -0,0 +1,13 @@
+transform {
+  compress {}
+}
+transform {
+  encrypt {
+    aes_gcm_key_base64: "a+bc"
+  }
+}
+transform {
+  integrity {
+    sha256: "ab+c"
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/internal/AndroidManifest.xml b/javatests/com/google/android/libraries/mobiledatadownload/internal/AndroidManifest.xml
new file mode 100644
index 0000000..82140c5
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/internal/AndroidManifest.xml
@@ -0,0 +1,31 @@
+<!--
+/*
+ * Copyright 2022 Google LLC
+ *
+ * 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.
+ */
+-->
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.google.android.libraries.mobiledatadownload.internal">
+  <!-- Set minSdkVersion to 21 because android.os.symlink is only available after api level 21. -->
+  <uses-sdk android:minSdkVersion="21" android:targetSdkVersion="29"/>
+  <application>
+        <uses-library android:name="android.test.runner" />
+
+  </application>
+    <instrumentation
+        android:name="com.google.android.apps.common.testing.testrunner.Google3InstrumentationTestRunner"
+        android:targetPackage="com.google.android.libraries.mobiledatadownload.internal" />
+</manifest>
+
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/internal/BUILD b/javatests/com/google/android/libraries/mobiledatadownload/internal/BUILD
new file mode 100644
index 0000000..d72ab91
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/internal/BUILD
@@ -0,0 +1,338 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("//javatests/com/google/android/libraries/mobiledatadownload:test_defs.bzl", "mdd_local_test")
+load("@build_bazel_rules_android//android:rules.bzl", "android_library")
+load("//java/com/google/android/libraries/mobiledatadownload/file/common/testing:build_defs.bzl", "android_test_multi_api")
+
+package(
+    default_visibility = ["//:__subpackages__"],
+    licenses = ["notice"],
+)
+
+mdd_local_test(
+    name = "MobileDataDownloadManagerTest",
+    srcs = ["MobileDataDownloadManagerTest.java"],
+    test_class = "com.google.android.libraries.mobiledatadownload.internal.MobileDataDownloadManagerTest",
+    deps = [
+        ":MddTestUtil",
+        "//java/com/google/android/libraries/mobiledatadownload:DownloadException",
+        "//java/com/google/android/libraries/mobiledatadownload:FileSource",
+        "//java/com/google/android/libraries/mobiledatadownload:SilentFeedback",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:ExpirationHandler",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:FileGroupManager",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:FileGroupsMetadata",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:MddExceptions",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:Migrations",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:MobileDataDownloadManager",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:SharedFileManager",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:SharedFilesMetadata",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/experimentation:DownloadStageManager",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/experimentation:NoOpDownloadStageManager",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:EventLogger",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:FileGroupStatsLogger",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LoggingStateStore",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:NetworkLogger",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:NoOpLoggingState",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:StorageLogger",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:FileGroupUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:SharedPreferencesUtil",
+        "//javatests/com/google/android/libraries/mobiledatadownload/testing:FakeTimeSource",
+        "//javatests/com/google/android/libraries/mobiledatadownload/testing:TestFlags",
+        "//proto:transform_java_proto_lite",
+        "@androidx_test",
+        "@com_google_guava_guava",
+        "@com_google_protobuf//:protobuf_lite",
+        "@mockito",
+        "@truth",
+    ],
+)
+
+mdd_local_test(
+    name = "DataFileGroupValidatorTest",
+    srcs = ["DataFileGroupValidatorTest.java"],
+    test_class = "com.google.android.libraries.mobiledatadownload.internal.DataFileGroupValidatorTest",
+    deps = [
+        ":MddTestUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:DataFileGroupValidator",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:Migrations",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "//javatests/com/google/android/libraries/mobiledatadownload/testing:TestFlags",
+        "//proto:transform_java_proto_lite",
+        "@androidx_test",
+        "@com_google_guava_guava",
+        "@truth",
+    ],
+)
+
+mdd_local_test(
+    name = "MigrationsTest",
+    srcs = ["MigrationsTest.java"],
+    test_class = "com.google.android.libraries.mobiledatadownload.internal.MigrationsTest",
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload:SilentFeedback",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:Migrations",
+        "@androidx_test",
+        "@truth",
+    ],
+)
+
+mdd_local_test(
+    name = "FileGroupManagerTest",
+    srcs = ["FileGroupManagerTest.java"],
+    test_class = "com.google.android.libraries.mobiledatadownload.internal.FileGroupManagerTest",
+    deps = [
+        ":MddTestUtil",
+        "//java/com/google/android/libraries/mobiledatadownload:AccountSource",
+        "//java/com/google/android/libraries/mobiledatadownload:AggregateException",
+        "//java/com/google/android/libraries/mobiledatadownload:DownloadException",
+        "//java/com/google/android/libraries/mobiledatadownload:FileSource",
+        "//java/com/google/android/libraries/mobiledatadownload:SilentFeedback",
+        "//java/com/google/android/libraries/mobiledatadownload/account:AccountUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:android",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "//java/com/google/android/libraries/mobiledatadownload/file/spi",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:FileGroupManager",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:FileGroupsMetadata",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:MddExceptions",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:SharedFileManager",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:SharedFilesMetadata",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:SharedPreferencesFileGroupsMetadata",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:SharedPreferencesSharedFilesMetadata",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/downloader:DownloaderCallbackImpl",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/downloader:MddFileDownloader",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/experimentation:DownloadStageManager",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/experimentation:NoOpDownloadStageManager",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:EventLogger",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:DirectoryUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:FileGroupUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/monitor:DownloadProgressMonitor",
+        "//javatests/com/google/android/libraries/mobiledatadownload/testing:FakeTimeSource",
+        "//javatests/com/google/android/libraries/mobiledatadownload/testing:TestFlags",
+        "@com_google_guava_guava",
+        "@com_google_protobuf//:any_proto",
+        "@com_google_protobuf//:protobuf_lite",
+        "@com_google_protobuf//:wrappers_proto",
+        "@mockito",
+        "@robolectric",
+        "@truth",
+    ],
+)
+
+mdd_local_test(
+    name = "FileGroupsMetadataTest",
+    srcs = ["FileGroupsMetadataTest.java"],
+    test_class = "com.google.android.libraries.mobiledatadownload.internal.FileGroupsMetadataTest",
+    deps = [
+        ":MddTestUtil",
+        "//java/com/google/android/libraries/mobiledatadownload:SilentFeedback",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:android",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:FileGroupsMetadata",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:SharedPreferencesFileGroupsMetadata",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:EventLogger",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:DirectoryUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:FileGroupUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:FileGroupsMetadataUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:ProtoConversionUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:SharedPreferencesUtil",
+        "//javatests/com/google/android/libraries/mobiledatadownload/testing:FakeTimeSource",
+        "//javatests/com/google/android/libraries/mobiledatadownload/testing:TestFlags",
+        "//proto:download_config_java_proto_lite",
+        "@androidx_test",
+        "@com_google_guava_guava",
+        "@mockito",
+        "@truth",
+    ],
+)
+
+mdd_local_test(
+    name = "ExpirationHandlerTest",
+    srcs = ["ExpirationHandlerTest.java"],
+    test_class = "com.google.android.libraries.mobiledatadownload.internal.ExpirationHandlerTest",
+    deps = [
+        ":MddTestUtil",
+        "//java/com/google/android/libraries/mobiledatadownload:SilentFeedback",
+        "//java/com/google/android/libraries/mobiledatadownload/delta:DeltaDecoder",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/spi",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:ExpirationHandler",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:FileGroupsMetadata",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:Migrations",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:SharedFileManager",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:SharedFilesMetadata",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:SharedPreferencesFileGroupsMetadata",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:SharedPreferencesSharedFilesMetadata",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/downloader:MddFileDownloader",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:EventLogger",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:DirectoryUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/monitor:DownloadProgressMonitor",
+        "//javatests/com/google/android/libraries/mobiledatadownload/testing:FakeTimeSource",
+        "//javatests/com/google/android/libraries/mobiledatadownload/testing:TestFlags",
+        "@androidx_test",
+        "@com_google_guava_guava",
+        "@mockito",
+        "@truth",
+    ],
+)
+
+mdd_local_test(
+    name = "SharedFileManagerTest",
+    srcs = ["SharedFileManagerTest.java"],
+    test_class = "com.google.android.libraries.mobiledatadownload.internal.SharedFileManagerTest",
+    deps = [
+        ":MddTestUtil",
+        "//java/com/google/android/libraries/mobiledatadownload:DownloadException",
+        "//java/com/google/android/libraries/mobiledatadownload:FileSource",
+        "//java/com/google/android/libraries/mobiledatadownload:SilentFeedback",
+        "//java/com/google/android/libraries/mobiledatadownload/delta:DeltaDecoder",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:android",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:blob_uri",
+        "//java/com/google/android/libraries/mobiledatadownload/file/spi",
+        "//java/com/google/android/libraries/mobiledatadownload/file/transforms:compress",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:FileGroupsMetadata",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:MddExceptions",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:Migrations",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:SharedFileManager",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:SharedFilesMetadata",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:SharedPreferencesSharedFilesMetadata",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/downloader:DownloaderCallbackImpl",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/downloader:MddFileDownloader",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:EventLogger",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:DirectoryUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:FileGroupUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:SharedPreferencesUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/monitor:DownloadProgressMonitor",
+        "//javatests/com/google/android/libraries/mobiledatadownload/testing:TestFlags",
+        "@androidx_test",
+        "@com_google_guava_guava",
+        "@com_google_protobuf//:protobuf_lite",
+        "@mockito",
+        "@truth",
+    ],
+)
+
+mdd_local_test(
+    name = "SharedFilesMetadataTest",
+    srcs = ["SharedFilesMetadataTest.java"],
+    test_class = "com.google.android.libraries.mobiledatadownload.internal.SharedFilesMetadataTest",
+    deps = [
+        ":MddTestUtil",
+        "//java/com/google/android/libraries/mobiledatadownload:SilentFeedback",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:android",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:Migrations",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:SharedFilesMetadata",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:SharedPreferencesSharedFilesMetadata",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:EventLogger",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:SharedFilesMetadataUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:SharedPreferencesUtil",
+        "//javatests/com/google/android/libraries/mobiledatadownload/testing:TestFlags",
+        "//proto:transform_java_proto_lite",
+        "@androidx_test",
+        "@com_google_guava_guava",
+        "@mockito",
+        "@truth",
+    ],
+)
+
+android_library(
+    name = "MddTestUtil",
+    testonly = 1,
+    srcs = ["MddTestUtil.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/internal:SharedFilesMetadata",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "//proto:download_config_java_proto_lite",
+        "//proto:transform_java_proto_lite",
+        "@androidx_test",
+        "@com_google_android_testing//:util",
+        "@com_google_protobuf//:protobuf_lite",
+        "@truth",
+        "@ub_uiautomator",
+    ],
+)
+
+android_test_multi_api(
+    name = "MddIsolatedStructuresTest",
+    size = "large",
+    srcs = ["MddIsolatedStructuresTest.java"],
+    manifest = "AndroidManifest.xml",
+    multidex = "native",
+    target_apis = [
+        "21",
+        "22",
+        "23",
+        "24",
+        "25",
+        "26",
+        "27",
+        "28",
+        "29",
+    ],
+    deps = [
+        ":MddTestUtil",
+        "//java/com/google/android/libraries/mobiledatadownload:AggregateException",
+        "//java/com/google/android/libraries/mobiledatadownload:DownloadException",
+        "//java/com/google/android/libraries/mobiledatadownload:ExperimentationConfig",
+        "//java/com/google/android/libraries/mobiledatadownload:Flags",
+        "//java/com/google/android/libraries/mobiledatadownload:SilentFeedback",
+        "//java/com/google/android/libraries/mobiledatadownload/account:AccountUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:android",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing:fake_file_backend",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:bytes",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/spi",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:FileGroupManager",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:FileGroupsMetadata",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:MddExceptions",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:SharedFileManager",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:SharedFilesMetadata",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:SharedPreferencesFileGroupsMetadata",
+        "//java/com/google/android/libraries/mobiledatadownload/internal:SharedPreferencesSharedFilesMetadata",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/downloader:DownloaderCallbackImpl",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/downloader:MddFileDownloader",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/experimentation:NoOpDownloadStageManager",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:EventLogger",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:DirectoryUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:FileGroupUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:SymlinkUtil",
+        "//java/com/google/android/libraries/mobiledatadownload/monitor:DownloadProgressMonitor",
+        "//javatests/com/google/android/libraries/mobiledatadownload/testing:FakeTimeSource",
+        "//javatests/com/google/android/libraries/mobiledatadownload/testing:TestFlags",
+        "//proto:transform_java_proto_lite",
+        "@android_sdk_linux",
+        "@androidx_test",
+        "@com_google_guava_guava",
+        "@com_google_protobuf//:any_proto",
+        "@com_google_protobuf//:protobuf_lite",
+        "@com_google_protobuf//:wrappers_proto",
+        "@junit",
+        "@mockito",
+        "@truth",
+    ],
+)
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/internal/DataFileGroupValidatorTest.java b/javatests/com/google/android/libraries/mobiledatadownload/internal/DataFileGroupValidatorTest.java
new file mode 100644
index 0000000..78ee83d
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/internal/DataFileGroupValidatorTest.java
@@ -0,0 +1,613 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.Context;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.android.libraries.mobiledatadownload.internal.Migrations.FileKeyVersion;
+import com.google.android.libraries.mobiledatadownload.testing.TestFlags;
+import com.google.common.base.Optional;
+import com.google.mobiledatadownload.TransformProto.CompressTransform;
+import com.google.mobiledatadownload.TransformProto.EncryptTransform;
+import com.google.mobiledatadownload.TransformProto.IntegrityTransform;
+import com.google.mobiledatadownload.TransformProto.Transform;
+import com.google.mobiledatadownload.TransformProto.Transforms;
+import com.google.mobiledatadownload.TransformProto.ZipTransform;
+import com.google.mobiledatadownload.internal.MetadataProto.BaseFile;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFile;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFile.AndroidSharingChecksumType;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFile.AndroidSharingType;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFile.ChecksumType;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal;
+import com.google.mobiledatadownload.internal.MetadataProto.DeltaFile;
+import com.google.mobiledatadownload.internal.MetadataProto.DeltaFile.DiffDecoder;
+import com.google.mobiledatadownload.internal.MetadataProto.DownloadConditions;
+import com.google.mobiledatadownload.internal.MetadataProto.DownloadConditions.DeviceNetworkPolicy;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+/**
+ * Unit tests for {@link
+ * com.google.android.libraries.mobiledatadownload.internal.DataFileGroupValidator}.
+ */
+@RunWith(RobolectricTestRunner.class)
+public class DataFileGroupValidatorTest {
+
+  private static final String TEST_GROUP = "test-group";
+  private Context context;
+  private final TestFlags flags = new TestFlags();
+
+  @Before
+  public void setUp() {
+
+    context = ApplicationProvider.getApplicationContext();
+  }
+
+  @Test
+  public void testAddGroupForDownload_compressedFile() throws Exception {
+    Migrations.setCurrentVersion(context, FileKeyVersion.USE_CHECKSUM_ONLY);
+    Migrations.setMigratedToNewFileKey(context, true);
+
+    DataFileGroupInternal.Builder fileGroupBuilder =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder();
+    DataFileGroupInternal dataFileGroup =
+        fileGroupBuilder
+            .setFile(
+                0,
+                fileGroupBuilder.getFile(0).toBuilder()
+                    .setDownloadedFileChecksum("downloadchecksum")
+                    .setDownloadTransforms(
+                        Transforms.newBuilder()
+                            .addTransform(
+                                Transform.newBuilder()
+                                    .setCompress(CompressTransform.getDefaultInstance()))))
+            .build();
+
+    assertThat(DataFileGroupValidator.isValidGroup(dataFileGroup, context, flags)).isTrue();
+  }
+
+  @Test
+  public void testAddGroupForDownload_compressedFile_noDownloadChecksum() throws Exception {
+    Migrations.setCurrentVersion(context, FileKeyVersion.USE_CHECKSUM_ONLY);
+    Migrations.setMigratedToNewFileKey(context, true);
+
+    DataFileGroupInternal.Builder fileGroupBuilder =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder();
+    DataFileGroupInternal dataFileGroup =
+        fileGroupBuilder
+            .setFile(
+                0,
+                fileGroupBuilder.getFile(0).toBuilder()
+                    // Set valid download transforms so it won't fail transforms validation
+                    .setDownloadTransforms(
+                        Transforms.newBuilder()
+                            .addTransform(
+                                Transform.newBuilder()
+                                    .setCompress(CompressTransform.getDefaultInstance()))))
+            .build();
+
+    assertThat(DataFileGroupValidator.isValidGroup(dataFileGroup, context, flags)).isFalse();
+  }
+
+  @Test
+  public void testAddGroupForDownload_encryptFileTransform() throws Exception {
+    Migrations.setCurrentVersion(context, FileKeyVersion.USE_CHECKSUM_ONLY);
+    Migrations.setMigratedToNewFileKey(context, true);
+    DataFileGroupInternal.Builder fileGroupBuilder =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder();
+    DataFileGroupInternal dataFileGroup =
+        fileGroupBuilder
+            .setFile(
+                0,
+                fileGroupBuilder.getFile(0).toBuilder()
+                    .setDownloadTransforms(
+                        Transforms.newBuilder()
+                            .addTransform(
+                                Transform.newBuilder()
+                                    .setEncrypt(EncryptTransform.getDefaultInstance())))
+                    .setDownloadedFileChecksum("downloadchecksum"))
+            .build();
+
+    assertThat(DataFileGroupValidator.isValidGroup(dataFileGroup, context, flags)).isTrue();
+  }
+
+  @Test
+  public void testAddGroupForDownload_integrityFileTransform() throws Exception {
+    Migrations.setCurrentVersion(context, FileKeyVersion.USE_CHECKSUM_ONLY);
+    Migrations.setMigratedToNewFileKey(context, true);
+    DataFileGroupInternal.Builder fileGroupBuilder =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder();
+    DataFileGroupInternal dataFileGroup =
+        fileGroupBuilder
+            .setFile(
+                0,
+                fileGroupBuilder.getFile(0).toBuilder()
+                    .setDownloadTransforms(
+                        Transforms.newBuilder()
+                            .addTransform(
+                                Transform.newBuilder()
+                                    .setIntegrity(IntegrityTransform.getDefaultInstance())))
+                    .setDownloadedFileChecksum("downloadchecksum"))
+            .build();
+
+    assertThat(DataFileGroupValidator.isValidGroup(dataFileGroup, context, flags)).isTrue();
+  }
+
+  @Test
+  public void testAddGroupForDownload_zip() {
+    flags.enableZipFolder = Optional.of(true);
+    Migrations.setCurrentVersion(context, FileKeyVersion.USE_CHECKSUM_ONLY);
+    Migrations.setMigratedToNewFileKey(context, true);
+    DataFileGroupInternal.Builder fileGroupBuilder =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder();
+    DataFileGroupInternal dataFileGroup =
+        fileGroupBuilder
+            .setFile(
+                0,
+                fileGroupBuilder.getFile(0).toBuilder()
+                    .setDownloadTransforms(
+                        Transforms.newBuilder()
+                            .addTransform(
+                                Transform.newBuilder()
+                                    .setZip(ZipTransform.newBuilder().setTarget("*").build())))
+                    .setDownloadedFileChecksum("DOWNLOADEDFILECHECKSUM"))
+            .build();
+    assertThat(DataFileGroupValidator.isValidGroup(dataFileGroup, context, flags)).isTrue();
+  }
+
+  @Test
+  public void testAddGroupForDownload_zip_featureOff() {
+    flags.enableZipFolder = Optional.of(false);
+    Migrations.setCurrentVersion(context, FileKeyVersion.USE_CHECKSUM_ONLY);
+    Migrations.setMigratedToNewFileKey(context, true);
+    DataFileGroupInternal.Builder fileGroupBuilder =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder();
+    DataFileGroupInternal dataFileGroup =
+        fileGroupBuilder
+            .setFile(
+                0,
+                fileGroupBuilder.getFile(0).toBuilder()
+                    .setDownloadTransforms(
+                        Transforms.newBuilder()
+                            .addTransform(
+                                Transform.newBuilder()
+                                    .setZip(ZipTransform.newBuilder().setTarget("*").build())))
+                    .setDownloadedFileChecksum("DOWNLOADEDFILECHECKSUM"))
+            .build();
+    assertThat(DataFileGroupValidator.isValidGroup(dataFileGroup, context, flags)).isFalse();
+  }
+
+  @Test
+  public void testAddGroupForDownload_zip_noDownloadFileChecksum() {
+    flags.enableZipFolder = Optional.of(true);
+    Migrations.setCurrentVersion(context, FileKeyVersion.USE_CHECKSUM_ONLY);
+    Migrations.setMigratedToNewFileKey(context, true);
+    DataFileGroupInternal.Builder fileGroupBuilder =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder();
+    DataFileGroupInternal dataFileGroup =
+        fileGroupBuilder
+            .setFile(
+                0,
+                fileGroupBuilder.getFile(0).toBuilder()
+                    .setDownloadTransforms(
+                        Transforms.newBuilder()
+                            .addTransform(
+                                Transform.newBuilder()
+                                    .setZip(ZipTransform.newBuilder().setTarget("*").build()))))
+            .build();
+    assertThat(DataFileGroupValidator.isValidGroup(dataFileGroup, context, flags)).isFalse();
+  }
+
+  @Test
+  public void testAddGroupForDownload_zip_targetOneFile() {
+    flags.enableZipFolder = Optional.of(true);
+    Migrations.setCurrentVersion(context, FileKeyVersion.USE_CHECKSUM_ONLY);
+    Migrations.setMigratedToNewFileKey(context, true);
+    DataFileGroupInternal.Builder fileGroupBuilder =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder();
+    DataFileGroupInternal dataFileGroup =
+        fileGroupBuilder
+            .setFile(
+                0,
+                fileGroupBuilder.getFile(0).toBuilder()
+                    .setDownloadTransforms(
+                        Transforms.newBuilder()
+                            .addTransform(
+                                Transform.newBuilder()
+                                    .setZip(
+                                        ZipTransform.newBuilder().setTarget("abc.txt").build())))
+                    .setDownloadedFileChecksum("DOWNLOADEDFILECHECKSUM"))
+            .build();
+    assertThat(DataFileGroupValidator.isValidGroup(dataFileGroup, context, flags)).isFalse();
+  }
+
+  @Test
+  public void testAddGroupForDownload_zip_moreThanOneTransforms() {
+    flags.enableZipFolder = Optional.of(true);
+    Migrations.setCurrentVersion(context, FileKeyVersion.USE_CHECKSUM_ONLY);
+    Migrations.setMigratedToNewFileKey(context, true);
+    DataFileGroupInternal.Builder fileGroupBuilder =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder();
+    DataFileGroupInternal dataFileGroup =
+        fileGroupBuilder
+            .setFile(
+                0,
+                fileGroupBuilder.getFile(0).toBuilder()
+                    // Set valid download transforms so it won't fail transforms validation
+                    .setDownloadTransforms(
+                        Transforms.newBuilder()
+                            .addTransform(
+                                Transform.newBuilder()
+                                    .setZip(ZipTransform.newBuilder().setTarget("*").build()))
+                            .addTransform(
+                                Transform.newBuilder()
+                                    .setCompress(CompressTransform.getDefaultInstance()))))
+            .build();
+    assertThat(DataFileGroupValidator.isValidGroup(dataFileGroup, context, flags)).isFalse();
+  }
+
+  @Test
+  public void testAddGroupForDownload_readTransform() throws Exception {
+    Migrations.setCurrentVersion(context, FileKeyVersion.USE_CHECKSUM_ONLY);
+    Migrations.setMigratedToNewFileKey(context, true);
+    DataFileGroupInternal.Builder fileGroupBuilder =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder();
+    DataFileGroupInternal dataFileGroup =
+        fileGroupBuilder
+            .setFile(
+                0,
+                fileGroupBuilder.getFile(0).toBuilder()
+                    .setReadTransforms(
+                        Transforms.newBuilder()
+                            .addTransform(
+                                Transform.newBuilder()
+                                    .setIntegrity(IntegrityTransform.getDefaultInstance()))))
+            .build();
+
+    assertThat(DataFileGroupValidator.isValidGroup(dataFileGroup, context, flags)).isTrue();
+  }
+
+  @Test
+  public void testAddGroupForDownload_readTransform_invalid() throws Exception {
+    Migrations.setCurrentVersion(context, FileKeyVersion.USE_CHECKSUM_ONLY);
+    Migrations.setMigratedToNewFileKey(context, true);
+    DataFileGroupInternal.Builder fileGroupBuilder =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder();
+    DataFileGroupInternal dataFileGroup =
+        fileGroupBuilder
+            .setFile(
+                0,
+                fileGroupBuilder.getFile(0).toBuilder()
+                    .setReadTransforms(
+                        Transforms.newBuilder().addTransform(Transform.getDefaultInstance())))
+            .build();
+
+    assertThat(DataFileGroupValidator.isValidGroup(dataFileGroup, context, flags)).isFalse();
+  }
+
+  @Test
+  public void testAddGroupForDownload_isValidGroup() throws Exception {
+    // Group with empty name.
+    DataFileGroupInternal dataFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder().setGroupName("").build();
+    assertThat(DataFileGroupValidator.isValidGroup(dataFileGroup, context, flags)).isFalse();
+
+    // Group with SPLIT_CHAR in the name.
+    dataFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder()
+            .setGroupName("group|name")
+            .build();
+    assertThat(DataFileGroupValidator.isValidGroup(dataFileGroup, context, flags)).isFalse();
+
+    // Group with empty file id.
+    dataFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2).toBuilder()
+            .setFile(0, dataFileGroup.getFile(0).toBuilder().clearFileId())
+            .build();
+    assertThat(DataFileGroupValidator.isValidGroup(dataFileGroup, context, flags)).isFalse();
+
+    // Group with SPLIT_CHAR in the file id.
+    dataFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2).toBuilder()
+            .setFile(0, dataFileGroup.getFile(0).toBuilder().setFileId("file|id"))
+            .build();
+    assertThat(DataFileGroupValidator.isValidGroup(dataFileGroup, context, flags)).isFalse();
+
+    // Group with empty url.
+    dataFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2).toBuilder()
+            .setFile(0, dataFileGroup.getFile(0).toBuilder().clearUrlToDownload())
+            .build();
+    assertThat(DataFileGroupValidator.isValidGroup(dataFileGroup, context, flags)).isFalse();
+
+    // Group with file size 0.
+    dataFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2).toBuilder()
+            .setFile(1, dataFileGroup.getFile(1).toBuilder().clearByteSize())
+            .build();
+    assertThat(DataFileGroupValidator.isValidGroup(dataFileGroup, context, flags)).isTrue();
+
+    // Group with empty checksum and ChecksumType = DEFAULT is invalid.
+    dataFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2).toBuilder()
+            .setFile(0, dataFileGroup.getFile(0).toBuilder().setChecksum(""))
+            .build();
+    assertThat(DataFileGroupValidator.isValidGroup(dataFileGroup, context, flags)).isFalse();
+
+    // Group with empty checksum and ChecksumType = NONE is valid.
+    dataFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2).toBuilder()
+            .setFile(
+                0,
+                dataFileGroup.getFile(0).toBuilder()
+                    .setChecksum("")
+                    .setChecksumType(ChecksumType.NONE))
+            .build();
+    assertThat(DataFileGroupValidator.isValidGroup(dataFileGroup, context, flags)).isTrue();
+
+    // Group with download transforms but without downloaded file checksum, and ChecksumType =
+    // DEFAULT is invalid.
+    dataFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2).toBuilder()
+            .setFile(
+                0,
+                dataFileGroup.getFile(0).toBuilder()
+                    .setChecksumType(ChecksumType.DEFAULT)
+                    .setDownloadTransforms(
+                        Transforms.newBuilder()
+                            .addTransform(
+                                Transform.newBuilder()
+                                    .setCompress(CompressTransform.getDefaultInstance()))))
+            .build();
+    assertThat(DataFileGroupValidator.isValidGroup(dataFileGroup, context, flags)).isFalse();
+
+    // Group with download transforms but without downloaded file checksum, and ChecksumType = NONE
+    // is valid.
+    dataFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2).toBuilder()
+            .setFile(
+                0,
+                dataFileGroup.getFile(0).toBuilder()
+                    .setChecksum("")
+                    .setChecksumType(ChecksumType.NONE)
+                    .setDownloadTransforms(
+                        Transforms.newBuilder()
+                            .addTransform(
+                                Transform.newBuilder()
+                                    .setCompress(CompressTransform.getDefaultInstance()))))
+            .build();
+    assertThat(DataFileGroupValidator.isValidGroup(dataFileGroup, context, flags)).isTrue();
+
+    // Group with SPLIT_CHAR in the checksum.
+    dataFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2).toBuilder()
+            .setFile(
+                0,
+                dataFileGroup.getFile(0).toBuilder()
+                    .setChecksumType(ChecksumType.DEFAULT)
+                    .setChecksum("check|sum"))
+            .build();
+    assertThat(DataFileGroupValidator.isValidGroup(dataFileGroup, context, flags)).isFalse();
+
+    // Group with duplicate file ids.
+    dataFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2).toBuilder()
+            .setFile(
+                0,
+                dataFileGroup.getFile(0).toBuilder()
+                    .setFileId(dataFileGroup.getFile(1).getFileId()))
+            .build();
+    assertThat(DataFileGroupValidator.isValidGroup(dataFileGroup, context, flags)).isFalse();
+
+    // For DeviceNetworkPolicy.DOWNLOAD_FIRST_ON_WIFI_THEN_ON_ANY_NETWORK
+    // download_first_on_wifi_period_secs must be > 0.
+    DownloadConditions downloadConditions =
+        DownloadConditions.newBuilder()
+            .setDeviceNetworkPolicy(DeviceNetworkPolicy.DOWNLOAD_FIRST_ON_WIFI_THEN_ON_ANY_NETWORK)
+            .setDownloadFirstOnWifiPeriodSecs(0)
+            .build();
+    dataFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder()
+            .setDownloadConditions(downloadConditions)
+            .build();
+    assertThat(DataFileGroupValidator.isValidGroup(dataFileGroup, context, flags)).isFalse();
+
+    // Group with shared and not-shared files.
+    dataFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2).toBuilder()
+            .setFile(
+                0,
+                dataFileGroup.getFile(0).toBuilder()
+                    .setAndroidSharingType(AndroidSharingType.ANDROID_BLOB_WHEN_AVAILABLE)
+                    .setAndroidSharingChecksumType(AndroidSharingChecksumType.SHA2_256)
+                    .setAndroidSharingChecksum("sha256_file0"))
+            .build();
+    assertThat(DataFileGroupValidator.isValidGroup(dataFileGroup, context, flags)).isTrue();
+  }
+
+  @Test
+  public void testInvalidAndroidSharedFile() {
+    DataFileGroupInternal dataFileGroup =
+        MddTestUtil.createSharedDataFileGroupInternal(TEST_GROUP, 1);
+    DataFileGroupInternal invalidGroup =
+        dataFileGroup.toBuilder()
+            .setFile(0, dataFileGroup.getFile(0).toBuilder().setAndroidSharingChecksum(""))
+            .build();
+    assertThat(DataFileGroupValidator.isValidGroup(invalidGroup, context, flags)).isFalse();
+  }
+
+  @Test
+  public void testValidDeltaFile() {
+    Migrations.setCurrentVersion(context, FileKeyVersion.USE_CHECKSUM_ONLY);
+    DataFileGroupInternal dataFileGroup =
+        MddTestUtil.createFileGroupInternalWithDeltaFile(TEST_GROUP);
+    assertThat(DataFileGroupValidator.isValidGroup(dataFileGroup, context, flags)).isTrue();
+  }
+
+  @Test
+  public void testInvalidDeltaFile_noDownloadUrl() {
+    Migrations.setCurrentVersion(context, FileKeyVersion.USE_CHECKSUM_ONLY);
+    // create with delta file with NO download url
+    DataFileGroupInternal.Builder dataFileGroup =
+        MddTestUtil.createFileGroupInternalWithDeltaFile(TEST_GROUP).toBuilder();
+    DeltaFile deltaFile = MddTestUtil.createDeltaFile().toBuilder().clearUrlToDownload().build();
+    DataFile dataFile =
+        dataFileGroup.getFile(0).toBuilder().clearDeltaFile().addDeltaFile(deltaFile).build();
+    dataFileGroup = dataFileGroup.setFile(0, dataFile);
+
+    assertThat(DataFileGroupValidator.isValidGroup(dataFileGroup.build(), context, flags))
+        .isFalse();
+  }
+
+  @Test
+  public void testInvalidDeltaFile_noDiffDecoder() {
+    Migrations.setCurrentVersion(context, FileKeyVersion.USE_CHECKSUM_ONLY);
+    // create with delta file with NO diff decoder
+    DataFileGroupInternal.Builder dataFileGroup =
+        MddTestUtil.createFileGroupInternalWithDeltaFile(TEST_GROUP).toBuilder();
+    DeltaFile deltaFile = MddTestUtil.createDeltaFile().toBuilder().clearDiffDecoder().build();
+    DataFile dataFile =
+        dataFileGroup.getFile(0).toBuilder().clearDeltaFile().addDeltaFile(deltaFile).build();
+    dataFileGroup = dataFileGroup.setFile(0, dataFile);
+
+    assertThat(DataFileGroupValidator.isValidGroup(dataFileGroup.build(), context, flags))
+        .isFalse();
+  }
+
+  @Test
+  public void testInvalidDeltaFile_unspecifiedDiffDecoder() {
+    Migrations.setCurrentVersion(context, FileKeyVersion.USE_CHECKSUM_ONLY);
+    // create with delta file with UNSPECIFIED diff decoder
+    DataFileGroupInternal.Builder dataFileGroup =
+        MddTestUtil.createFileGroupInternalWithDeltaFile(TEST_GROUP).toBuilder();
+    DeltaFile deltaFile =
+        MddTestUtil.createDeltaFile().toBuilder().setDiffDecoder(DiffDecoder.UNSPECIFIED).build();
+    DataFile dataFile =
+        dataFileGroup.getFile(0).toBuilder().clearDeltaFile().addDeltaFile(deltaFile).build();
+    dataFileGroup = dataFileGroup.setFile(0, dataFile);
+
+    assertThat(DataFileGroupValidator.isValidGroup(dataFileGroup.build(), context, flags))
+        .isFalse();
+  }
+
+  @Test
+  public void testInvalidDeltaFile_noChecksum() {
+    Migrations.setCurrentVersion(context, FileKeyVersion.USE_CHECKSUM_ONLY);
+    // create with delta file with NO checksum
+    DataFileGroupInternal.Builder dataFileGroup =
+        MddTestUtil.createFileGroupInternalWithDeltaFile(TEST_GROUP).toBuilder();
+    DeltaFile deltaFile = MddTestUtil.createDeltaFile().toBuilder().clearChecksum().build();
+    DataFile dataFile =
+        dataFileGroup.getFile(0).toBuilder().clearDeltaFile().addDeltaFile(deltaFile).build();
+    dataFileGroup = dataFileGroup.setFile(0, dataFile);
+
+    assertThat(DataFileGroupValidator.isValidGroup(dataFileGroup.build(), context, flags))
+        .isFalse();
+  }
+
+  @Test
+  public void testInvalidDeltaFile_noByteSize() {
+    Migrations.setCurrentVersion(context, FileKeyVersion.USE_CHECKSUM_ONLY);
+    // create with delta file with NO byte size
+    DataFileGroupInternal.Builder dataFileGroup =
+        MddTestUtil.createFileGroupInternalWithDeltaFile(TEST_GROUP).toBuilder();
+    DeltaFile deltaFile = MddTestUtil.createDeltaFile().toBuilder().clearByteSize().build();
+    DataFile dataFile =
+        dataFileGroup.getFile(0).toBuilder().clearDeltaFile().addDeltaFile(deltaFile).build();
+    dataFileGroup = dataFileGroup.setFile(0, dataFile);
+
+    assertThat(DataFileGroupValidator.isValidGroup(dataFileGroup.build(), context, flags))
+        .isFalse();
+  }
+
+  @Test
+  public void testInvalidDeltaFile_noBaseFileChecksum() {
+    Migrations.setCurrentVersion(context, FileKeyVersion.USE_CHECKSUM_ONLY);
+    DataFileGroupInternal.Builder dataFileGroup =
+        MddTestUtil.createFileGroupInternalWithDeltaFile(TEST_GROUP).toBuilder();
+    DeltaFile deltaFile =
+        MddTestUtil.createDeltaFile().toBuilder()
+            .setBaseFile(BaseFile.getDefaultInstance())
+            .build();
+    DataFile dataFile =
+        dataFileGroup.getFile(0).toBuilder().clearDeltaFile().addDeltaFile(deltaFile).build();
+    dataFileGroup = dataFileGroup.setFile(0, dataFile);
+    assertThat(DataFileGroupValidator.isValidGroup(dataFileGroup.build(), context, flags))
+        .isFalse();
+  }
+
+  @Test
+  public void testInvalidDeltaFile_baseFile_invalidChecksum() {
+    Migrations.setCurrentVersion(context, FileKeyVersion.USE_CHECKSUM_ONLY);
+    DataFileGroupInternal.Builder dataFileGroup =
+        MddTestUtil.createFileGroupInternalWithDeltaFile(TEST_GROUP).toBuilder();
+    DeltaFile deltaFile =
+        MddTestUtil.createDeltaFile().toBuilder()
+            .setBaseFile(BaseFile.newBuilder().setChecksum("abc" + "|" + "def"))
+            .build();
+    DataFile dataFile =
+        dataFileGroup.getFile(0).toBuilder().clearDeltaFile().addDeltaFile(deltaFile).build();
+    dataFileGroup = dataFileGroup.setFile(0, dataFile);
+    assertThat(DataFileGroupValidator.isValidGroup(dataFileGroup.build(), context, flags))
+        .isFalse();
+  }
+
+  @Test
+  public void testInvalidDeltaFile_noBaseFile() {
+    Migrations.setCurrentVersion(context, FileKeyVersion.USE_CHECKSUM_ONLY);
+    DataFileGroupInternal.Builder dataFileGroup =
+        MddTestUtil.createFileGroupInternalWithDeltaFile(TEST_GROUP).toBuilder();
+    DeltaFile deltaFile = MddTestUtil.createDeltaFile().toBuilder().clearBaseFile().build();
+    DataFile dataFile =
+        dataFileGroup.getFile(0).toBuilder().clearDeltaFile().addDeltaFile(deltaFile).build();
+    dataFileGroup = dataFileGroup.setFile(0, dataFile);
+    assertThat(DataFileGroupValidator.isValidGroup(dataFileGroup.build(), context, flags))
+        .isFalse();
+  }
+
+  @Test
+  public void testSideloadedFile_validWhenSideloadingEnabled() {
+    // Create sideloaded group
+    DataFileGroupInternal sideloadedGroup =
+        DataFileGroupInternal.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .addFile(
+                DataFile.newBuilder()
+                    .setFileId("sideloaded_file")
+                    .setUrlToDownload("file:/test")
+                    .setChecksumType(DataFile.ChecksumType.NONE)
+                    .build())
+            .build();
+
+    {
+      // Force sideloading off
+      flags.enableSideloading = Optional.of(false);
+
+      assertThat(DataFileGroupValidator.isValidGroup(sideloadedGroup, context, flags)).isFalse();
+    }
+
+    {
+      // Force sideloading on
+      flags.enableSideloading = Optional.of(true);
+
+      assertThat(DataFileGroupValidator.isValidGroup(sideloadedGroup, context, flags)).isTrue();
+    }
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/internal/ExpirationHandlerTest.java b/javatests/com/google/android/libraries/mobiledatadownload/internal/ExpirationHandlerTest.java
new file mode 100644
index 0000000..863c39e
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/internal/ExpirationHandlerTest.java
@@ -0,0 +1,1765 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+import android.content.Context;
+import android.net.Uri;
+import android.util.Pair;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.android.libraries.mobiledatadownload.SilentFeedback;
+import com.google.android.libraries.mobiledatadownload.delta.DeltaDecoder;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.spi.Backend;
+import com.google.android.libraries.mobiledatadownload.internal.Migrations.FileKeyVersion;
+import com.google.android.libraries.mobiledatadownload.internal.downloader.MddFileDownloader;
+import com.google.android.libraries.mobiledatadownload.internal.logging.EventLogger;
+import com.google.android.libraries.mobiledatadownload.internal.util.DirectoryUtil;
+import com.google.android.libraries.mobiledatadownload.monitor.DownloadProgressMonitor;
+import com.google.android.libraries.mobiledatadownload.testing.FakeTimeSource;
+import com.google.android.libraries.mobiledatadownload.testing.TestFlags;
+import com.google.common.base.Optional;
+import com.google.common.collect.ImmutableList;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFile;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupBookkeeping;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal.AllowedReaders;
+import com.google.mobiledatadownload.internal.MetadataProto.FileStatus;
+import com.google.mobiledatadownload.internal.MetadataProto.GroupKey;
+import com.google.mobiledatadownload.internal.MetadataProto.NewFileKey;
+import com.google.mobiledatadownload.internal.MetadataProto.SharedFile;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Calendar;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public final class ExpirationHandlerTest {
+
+  @Mock SharedFileManager mockSharedFileManager;
+  @Mock SharedFilesMetadata mockSharedFilesMetadata;
+  @Mock FileGroupsMetadata mockFileGroupsMetadata;
+  @Mock EventLogger mockEventLogger;
+  @Mock SilentFeedback mockSilentFeedback;
+
+  @Mock Backend mockBackend;
+  @Mock Backend mockBlobStoreBackend;
+  @Mock MddFileDownloader mockDownloader;
+  @Mock DownloadProgressMonitor mockDownloadMonitor;
+
+  // Allows mockFileGroupsMetadata to correctly respond to writeStaleGroups and getAllStaleGroups.
+  AtomicReference<ImmutableList<DataFileGroupInternal>> fileGroupsMetadataStaleGroups =
+      new AtomicReference<>(ImmutableList.of());
+
+  private SynchronousFileStorage fileStorage;
+  private Context context;
+  private ExpirationHandler expirationHandler;
+  private ExpirationHandler expirationHandlerNoMocks;
+  private FakeTimeSource testClock;
+  private Uri baseDownloadDirectoryUri;
+  private Uri baseDownloadSymlinkDirectoryUri;
+  private FileGroupsMetadata fileGroupsMetadata;
+  private SharedFilesMetadata sharedFilesMetadata;
+  private SharedFileManager sharedFileManager;
+
+  private static final String TEST_GROUP_1 = "test-group-1";
+  private static final GroupKey TEST_KEY_1 = GroupKey.getDefaultInstance();
+
+  private static final String TEST_GROUP_2 = "test-group-2";
+  private static final GroupKey TEST_KEY_2 = GroupKey.getDefaultInstance();
+
+  private final Uri testUri1 =
+      Uri.parse(
+          "android://com.google.android.libraries.mobiledatadownload.internal/files/datadownload/shared/public/file_1");
+
+  private final Uri testUri2 =
+      Uri.parse(
+          "android://com.google.android.libraries.mobiledatadownload.internal/files/datadownload/shared/public/file_2");
+
+  private final Uri tempTestUri2 =
+      Uri.parse(
+          "android://com.google.android.libraries.mobiledatadownload.internal/files/datadownload/shared/public/file_2_temp");
+
+  private final Uri testUri3 =
+      Uri.parse(
+          "android://com.google.android.libraries.mobiledatadownload.internal/files/datadownload/shared/public/file_3");
+
+  private final Uri testUri4 =
+      Uri.parse(
+          "android://com.google.android.libraries.mobiledatadownload.internal/files/datadownload/shared/public/file_4");
+
+  // MDD file URI could be a folder which is unzipped from zip folder download transform
+  private final Uri testDirUri1 =
+      Uri.parse(
+          "android://com.google.android.libraries.mobiledatadownload.internal/files/datadownload/shared/public/dir_1");
+
+  private final Uri testDirFileUri1 =
+      Uri.parse(
+          "android://com.google.android.libraries.mobiledatadownload.internal/files/datadownload/shared/public/dir_1/file_1");
+
+  private final Uri testDirFileUri2 =
+      Uri.parse(
+          "android://com.google.android.libraries.mobiledatadownload.internal/files/datadownload/shared/public/dir_1/file_2");
+
+  private final Uri dirForAll =
+      Uri.parse(
+          "android://com.google.android.libraries.mobiledatadownload.internal/files/datadownload/shared/public_3p");
+  private final Uri dirFor1p =
+      Uri.parse(
+          "android://com.google.android.libraries.mobiledatadownload.internal/files/datadownload/shared/public");
+  private final Uri dirFor0p =
+      Uri.parse(
+          "android://com.google.android.libraries.mobiledatadownload.internal/files/datadownload/shared/private");
+
+  private final Uri symlinkDirForGroup1 =
+      Uri.parse(
+          "android://com.google.android.libraries.mobiledatadownload.internal/files/datadownload/shared/links/public/test-group-1");
+  private final Uri symlinkForUri1 =
+      Uri.parse(
+          "android://com.google.android.libraries.mobiledatadownload.internal/files/datadownload/shared/links/public/test-group-1/test-group-1_0");
+
+  private final Uri symlinkDirForGroup2 =
+      Uri.parse(
+          "android://com.google.android.libraries.mobiledatadownload.internal/files/datadownload/shared/links/public/test-group-2");
+  private final Uri symlinkForUri2 =
+      Uri.parse(
+          "android://com.google.android.libraries.mobiledatadownload.internal/files/datadownload/shared/links/public/test-group-2/test-group-2_0");
+
+  private final TestFlags flags = new TestFlags();
+  @Rule public final MockitoRule mocks = MockitoJUnit.rule();
+
+  @Before
+  public void setUp() throws Exception {
+
+    context = ApplicationProvider.getApplicationContext();
+
+    testClock = new FakeTimeSource();
+
+    baseDownloadDirectoryUri = DirectoryUtil.getBaseDownloadDirectory(context, Optional.absent());
+    baseDownloadSymlinkDirectoryUri =
+        DirectoryUtil.getBaseDownloadSymlinkDirectory(context, Optional.absent());
+    when(mockBackend.name()).thenReturn("android");
+    when(mockBlobStoreBackend.name()).thenReturn("blobstore");
+    setUpDirectoryMock(baseDownloadDirectoryUri, Arrays.asList(dirForAll, dirFor1p, dirFor0p));
+    setUpDirectoryMock(dirForAll, ImmutableList.of());
+    setUpDirectoryMock(dirFor0p, ImmutableList.of());
+    setUpDirectoryMock(dirFor1p, ImmutableList.of());
+    setUpDirectoryMock(testDirUri1, ImmutableList.of());
+    fileStorage = new SynchronousFileStorage(Arrays.asList(mockBackend, mockBlobStoreBackend));
+
+    expirationHandler =
+        new ExpirationHandler(
+            context,
+            mockFileGroupsMetadata,
+            mockSharedFileManager,
+            mockSharedFilesMetadata,
+            mockEventLogger,
+            testClock,
+            fileStorage,
+            Optional.absent(),
+            mockSilentFeedback,
+            MoreExecutors.directExecutor(),
+            flags);
+
+    // By default, mocks will return empty lists
+    when(mockFileGroupsMetadata.getAllFreshGroups())
+        .thenReturn(Futures.immediateFuture(ImmutableList.of()));
+    when(mockFileGroupsMetadata.removeAllGroupsWithKeys(any()))
+        .thenReturn(Futures.immediateFuture(true));
+    when(mockFileGroupsMetadata.removeAllStaleGroups()).thenReturn(Futures.immediateVoidFuture());
+    when(mockSharedFileManager.removeFileEntry(any())).thenReturn(Futures.immediateFuture(true));
+    when(mockSharedFilesMetadata.read(any()))
+        .thenReturn(Futures.immediateFuture(SharedFile.getDefaultInstance()));
+
+    // Calls to mockFileGroupsMetadata.writeStaleGroups() are reflected by getAllStaleGroups().
+    when(mockFileGroupsMetadata.getAllStaleGroups())
+        .thenAnswer(invocation -> Futures.immediateFuture(fileGroupsMetadataStaleGroups.get()));
+    when(mockFileGroupsMetadata.writeStaleGroups(any()))
+        .thenAnswer(
+            (InvocationOnMock invocation) -> {
+              List<DataFileGroupInternal> request = invocation.getArgument(0);
+              fileGroupsMetadataStaleGroups.set(ImmutableList.copyOf(request));
+              return Futures.immediateFuture(true);
+            });
+  }
+
+  private void setupForAndroidShared() {
+    // Construct an expiration handler without mocking the main classes
+    fileGroupsMetadata =
+        new SharedPreferencesFileGroupsMetadata(
+            context,
+            testClock,
+            mockSilentFeedback,
+            Optional.absent(),
+            MoreExecutors.directExecutor());
+    Optional<DeltaDecoder> deltaDecoder = Optional.absent();
+    sharedFilesMetadata =
+        new SharedPreferencesSharedFilesMetadata(
+            context, mockSilentFeedback, Optional.absent(), flags);
+    sharedFileManager =
+        new SharedFileManager(
+            context,
+            mockSilentFeedback,
+            sharedFilesMetadata,
+            fileStorage,
+            mockDownloader,
+            deltaDecoder,
+            Optional.of(mockDownloadMonitor),
+            mockEventLogger,
+            flags,
+            fileGroupsMetadata,
+            Optional.absent(),
+            MoreExecutors.directExecutor());
+
+    expirationHandlerNoMocks =
+        new ExpirationHandler(
+            context,
+            fileGroupsMetadata,
+            sharedFileManager,
+            sharedFilesMetadata,
+            mockEventLogger,
+            testClock,
+            fileStorage,
+            Optional.absent(),
+            mockSilentFeedback,
+            MoreExecutors.directExecutor(),
+            flags);
+  }
+
+  @Test
+  public void updateExpiration_noGroups() throws Exception {
+    when(mockFileGroupsMetadata.getAllFreshGroups())
+        .thenReturn(Futures.immediateFuture(ImmutableList.of()));
+    when(mockSharedFilesMetadata.getAllFileKeys())
+        .thenReturn(Futures.immediateFuture(ImmutableList.of()));
+
+    expirationHandler.updateExpiration().get();
+
+    verify(mockFileGroupsMetadata, times(3)).getAllFreshGroups();
+    verify(mockFileGroupsMetadata, times(2)).getAllStaleGroups();
+    verify(mockFileGroupsMetadata).removeAllGroupsWithKeys(ImmutableList.of());
+    verify(mockFileGroupsMetadata).removeAllStaleGroups();
+    verify(mockFileGroupsMetadata).writeStaleGroups(ImmutableList.of());
+    verifyNoMoreInteractions(mockFileGroupsMetadata);
+    verify(mockSharedFilesMetadata).getAllFileKeys();
+    verify(mockBackend).exists(baseDownloadDirectoryUri);
+    verify(mockBackend).children(baseDownloadDirectoryUri);
+    verify(mockBackend, never()).deleteFile(any());
+    verifyNoMoreInteractions(mockSharedFileManager);
+  }
+
+  @Test
+  public void updateExpiration_noExpiredGroups_noExpirationDates() throws Exception {
+    DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP_1, 2);
+    NewFileKey[] fileKeys = MddTestUtil.createFileKeysForDataFileGroupInternal(dataFileGroup);
+
+    List<Pair<GroupKey, DataFileGroupInternal>> groups =
+        Arrays.asList(Pair.create(TEST_KEY_1, dataFileGroup));
+    when(mockFileGroupsMetadata.getAllFreshGroups()).thenReturn(Futures.immediateFuture(groups));
+    when(mockSharedFileManager.getFileStatus(fileKeys[0]))
+        .thenReturn(Futures.immediateFuture(FileStatus.DOWNLOAD_COMPLETE));
+    when(mockSharedFileManager.getOnDeviceUri(fileKeys[0]))
+        .thenReturn(Futures.immediateFuture(testUri1));
+    when(mockSharedFileManager.getFileStatus(fileKeys[1]))
+        .thenReturn(Futures.immediateFuture(FileStatus.DOWNLOAD_COMPLETE));
+    when(mockSharedFileManager.getOnDeviceUri(fileKeys[1]))
+        .thenReturn(Futures.immediateFuture(testUri2));
+
+    when(mockSharedFilesMetadata.getAllFileKeys())
+        .thenReturn(Futures.immediateFuture(Arrays.asList(fileKeys)));
+    when(mockBackend.children(dirFor0p)).thenReturn(Arrays.asList(testUri1, testUri2));
+
+    expirationHandler.updateExpiration().get();
+
+    verify(mockFileGroupsMetadata, times(3)).getAllFreshGroups();
+    verify(mockFileGroupsMetadata, times(2)).getAllStaleGroups();
+    verify(mockFileGroupsMetadata).removeAllGroupsWithKeys(ImmutableList.of());
+    verify(mockFileGroupsMetadata).removeAllStaleGroups();
+    verify(mockFileGroupsMetadata).writeStaleGroups(ImmutableList.of());
+    verifyNoMoreInteractions(mockFileGroupsMetadata);
+    verify(mockSharedFilesMetadata).getAllFileKeys();
+    verify(mockSharedFileManager).getOnDeviceUri(fileKeys[0]);
+    verify(mockSharedFileManager).getOnDeviceUri(fileKeys[1]);
+    verify(mockBackend).exists(baseDownloadDirectoryUri);
+    verify(mockBackend).children(baseDownloadDirectoryUri);
+    verify(mockBackend).isDirectory(dirForAll);
+    verify(mockBackend).isDirectory(dirFor1p);
+    verify(mockBackend, never()).deleteFile(any());
+    verifyNoMoreInteractions(mockSharedFileManager);
+  }
+
+  @Test
+  public void updateExpiration_noExpiredGroups_expirationDates() throws Exception {
+    Calendar now = new Calendar.Builder().setDate(2018, Calendar.MARCH, 20).build();
+    testClock.set(now.getTimeInMillis());
+
+    DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP_1, 2);
+    Calendar later = new Calendar.Builder().setDate(2018, Calendar.APRIL, 20).build();
+    long laterTimeSecs = later.getTimeInMillis() / 1000;
+    dataFileGroup = dataFileGroup.toBuilder().setExpirationDateSecs(laterTimeSecs).build();
+
+    NewFileKey[] fileKeys = MddTestUtil.createFileKeysForDataFileGroupInternal(dataFileGroup);
+
+    List<Pair<GroupKey, DataFileGroupInternal>> groups =
+        Arrays.asList(Pair.create(TEST_KEY_1, dataFileGroup));
+    when(mockFileGroupsMetadata.getAllFreshGroups()).thenReturn(Futures.immediateFuture(groups));
+    when(mockSharedFileManager.getFileStatus(fileKeys[0]))
+        .thenReturn(Futures.immediateFuture(FileStatus.DOWNLOAD_COMPLETE));
+    when(mockSharedFileManager.getOnDeviceUri(fileKeys[0]))
+        .thenReturn(Futures.immediateFuture(testUri1));
+    when(mockSharedFileManager.getFileStatus(fileKeys[1]))
+        .thenReturn(Futures.immediateFuture(FileStatus.DOWNLOAD_COMPLETE));
+    when(mockSharedFileManager.getOnDeviceUri(fileKeys[1]))
+        .thenReturn(Futures.immediateFuture(testUri2));
+
+    when(mockSharedFilesMetadata.getAllFileKeys())
+        .thenReturn(Futures.immediateFuture(Arrays.asList(fileKeys)));
+    when(mockBackend.children(dirFor0p)).thenReturn(Arrays.asList(testUri1, testUri2));
+
+    expirationHandler.updateExpiration().get();
+
+    verify(mockFileGroupsMetadata, times(3)).getAllFreshGroups();
+    verify(mockFileGroupsMetadata, times(2)).getAllStaleGroups();
+    verify(mockFileGroupsMetadata).removeAllGroupsWithKeys(ImmutableList.of());
+    verify(mockFileGroupsMetadata).removeAllStaleGroups();
+    verify(mockFileGroupsMetadata).writeStaleGroups(ImmutableList.of());
+    verifyNoMoreInteractions(mockFileGroupsMetadata);
+    verify(mockSharedFilesMetadata).getAllFileKeys();
+    verify(mockSharedFileManager).getOnDeviceUri(fileKeys[0]);
+    verify(mockSharedFileManager).getOnDeviceUri(fileKeys[1]);
+    verify(mockBackend).exists(baseDownloadDirectoryUri);
+    verify(mockBackend).children(baseDownloadDirectoryUri);
+    verify(mockBackend).isDirectory(dirForAll);
+    verify(mockBackend).isDirectory(dirFor1p);
+    verify(mockBackend, never()).deleteFile(any());
+    verifyNoMoreInteractions(mockSharedFileManager);
+  }
+
+  @Test
+  public void updateExpiration_expiredGroups() throws Exception {
+    // Current time
+    Calendar now = new Calendar.Builder().setDate(2020, Calendar.MARCH, 20).build();
+    testClock.set(now.getTimeInMillis());
+
+    DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP_1, 5);
+    // Time when the group expires
+    Calendar earlier = new Calendar.Builder().setDate(2018, Calendar.MARCH, 20).build();
+    long earlierTimeSecs = earlier.getTimeInMillis() / 1000;
+    dataFileGroup = dataFileGroup.toBuilder().setExpirationDateSecs(earlierTimeSecs).build();
+
+    NewFileKey[] fileKeys = MddTestUtil.createFileKeysForDataFileGroupInternal(dataFileGroup);
+
+    List<Pair<GroupKey, DataFileGroupInternal>> groups =
+        Arrays.asList(Pair.create(TEST_KEY_1, dataFileGroup));
+    when(mockFileGroupsMetadata.getAllFreshGroups())
+        .thenReturn(Futures.immediateFuture(groups))
+        .thenReturn(Futures.immediateFuture(new ArrayList<>()));
+    when(mockSharedFileManager.getFileStatus(fileKeys[0]))
+        .thenReturn(Futures.immediateFuture(FileStatus.DOWNLOAD_COMPLETE));
+    when(mockSharedFileManager.getOnDeviceUri(fileKeys[0]))
+        .thenReturn(Futures.immediateFuture(testUri1));
+    when(mockSharedFileManager.getFileStatus(fileKeys[1]))
+        .thenReturn(Futures.immediateFuture(FileStatus.DOWNLOAD_FAILED));
+    when(mockSharedFileManager.getOnDeviceUri(fileKeys[1]))
+        .thenReturn(Futures.immediateFuture(testUri2));
+    when(mockSharedFileManager.getFileStatus(fileKeys[2]))
+        .thenReturn(Futures.immediateFuture(FileStatus.DOWNLOAD_IN_PROGRESS));
+    when(mockSharedFileManager.getOnDeviceUri(fileKeys[2]))
+        .thenReturn(Futures.immediateFuture(testUri3));
+    when(mockSharedFileManager.getFileStatus(fileKeys[3]))
+        .thenReturn(Futures.immediateFuture(FileStatus.SUBSCRIBED));
+    when(mockSharedFileManager.getOnDeviceUri(fileKeys[3]))
+        .thenReturn(Futures.immediateFuture(testUri4));
+    when(mockSharedFileManager.getFileStatus(fileKeys[4]))
+        .thenReturn(Futures.immediateFuture(FileStatus.NONE));
+
+    when(mockSharedFilesMetadata.getAllFileKeys())
+        .thenReturn(Futures.immediateFuture(Arrays.asList(fileKeys)));
+    when(mockBackend.children(dirFor0p))
+        .thenReturn(Arrays.asList(testUri1, testUri2, testUri3, testUri4));
+    expirationHandler.updateExpiration().get();
+
+    verify(mockFileGroupsMetadata, times(3)).getAllFreshGroups();
+    verify(mockFileGroupsMetadata, times(2)).getAllStaleGroups();
+    verify(mockFileGroupsMetadata).removeAllGroupsWithKeys(Arrays.asList(TEST_KEY_1));
+    verify(mockFileGroupsMetadata).removeAllStaleGroups();
+    verify(mockFileGroupsMetadata).writeStaleGroups(ImmutableList.of());
+    verifyNoMoreInteractions(mockFileGroupsMetadata);
+    verify(mockSharedFilesMetadata).getAllFileKeys();
+    verify(mockSharedFileManager).removeFileEntry(fileKeys[0]);
+    verify(mockSharedFileManager).removeFileEntry(fileKeys[1]);
+    verify(mockSharedFileManager).removeFileEntry(fileKeys[2]);
+    verify(mockSharedFileManager).removeFileEntry(fileKeys[3]);
+    verify(mockSharedFileManager).removeFileEntry(fileKeys[4]);
+    verify(mockBackend).exists(baseDownloadDirectoryUri);
+    verify(mockBackend).children(baseDownloadDirectoryUri);
+    verify(mockBackend).isDirectory(testUri1);
+    verify(mockBackend).isDirectory(testUri2);
+    verify(mockBackend).isDirectory(testUri3);
+    verify(mockBackend).isDirectory(testUri4);
+    verify(mockBackend).deleteFile(testUri1);
+    verify(mockBackend).deleteFile(testUri2);
+    verify(mockBackend).deleteFile(testUri3);
+    verify(mockBackend).deleteFile(testUri4);
+    verifyNoMoreInteractions(mockSharedFileManager);
+  }
+
+  @Test
+  public void updateExpiration_noExpiredGroups_pendingGroup() throws Exception {
+    Calendar now = new Calendar.Builder().setDate(2018, Calendar.MARCH, 20).build();
+    testClock.set(now.getTimeInMillis());
+
+    DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP_1, 2);
+    Calendar later = new Calendar.Builder().setDate(2018, Calendar.APRIL, 20).build();
+    long laterTimeSecs = later.getTimeInMillis() / 1000;
+    dataFileGroup = dataFileGroup.toBuilder().setExpirationDateSecs(laterTimeSecs).build();
+
+    NewFileKey[] fileKeys = MddTestUtil.createFileKeysForDataFileGroupInternal(dataFileGroup);
+
+    List<Pair<GroupKey, DataFileGroupInternal>> groups =
+        Arrays.asList(Pair.create(TEST_KEY_1, dataFileGroup));
+    when(mockFileGroupsMetadata.getAllFreshGroups()).thenReturn(Futures.immediateFuture(groups));
+    when(mockSharedFileManager.getFileStatus(fileKeys[0]))
+        .thenReturn(Futures.immediateFuture(FileStatus.DOWNLOAD_COMPLETE));
+    when(mockSharedFileManager.getOnDeviceUri(fileKeys[0]))
+        .thenReturn(Futures.immediateFuture(testUri1));
+    // The second file has not been downloaded.
+    when(mockSharedFileManager.getFileStatus(fileKeys[1]))
+        .thenReturn(Futures.immediateFuture(FileStatus.NONE));
+    when(mockSharedFileManager.getOnDeviceUri(fileKeys[1]))
+        .thenReturn(Futures.immediateFuture(testUri2));
+    when(mockSharedFilesMetadata.getAllFileKeys())
+        .thenReturn(Futures.immediateFuture(Arrays.asList(fileKeys)));
+    when(mockBackend.children(dirFor0p)).thenReturn(Arrays.asList(testUri1, testUri2));
+
+    expirationHandler.updateExpiration().get();
+
+    verify(mockFileGroupsMetadata, times(3)).getAllFreshGroups();
+    verify(mockFileGroupsMetadata, times(2)).getAllStaleGroups();
+    verify(mockFileGroupsMetadata).removeAllGroupsWithKeys(ImmutableList.of());
+    verify(mockFileGroupsMetadata).removeAllStaleGroups();
+    verify(mockFileGroupsMetadata).writeStaleGroups(ImmutableList.of());
+    verifyNoMoreInteractions(mockFileGroupsMetadata);
+    verify(mockSharedFilesMetadata).getAllFileKeys();
+    verify(mockSharedFileManager).getOnDeviceUri(fileKeys[0]);
+    verify(mockSharedFileManager).getOnDeviceUri(fileKeys[1]);
+    verify(mockBackend).exists(baseDownloadDirectoryUri);
+    verify(mockBackend).children(baseDownloadDirectoryUri);
+    verify(mockBackend).isDirectory(dirForAll);
+    verify(mockBackend).isDirectory(dirFor1p);
+    verify(mockBackend, never()).deleteFile(any());
+    verifyNoMoreInteractions(mockSharedFileManager);
+  }
+
+  @Test
+  public void updateExpiration_notDeleteInternalFiles() throws Exception {
+    Migrations.setCurrentVersion(context, FileKeyVersion.USE_CHECKSUM_ONLY);
+    Calendar now = new Calendar.Builder().setDate(2018, Calendar.MARCH, 20).build();
+    testClock.set(now.getTimeInMillis());
+
+    DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP_1, 2);
+    Calendar later = new Calendar.Builder().setDate(2018, Calendar.APRIL, 20).build();
+    long laterTimeSecs = later.getTimeInMillis() / 1000;
+    dataFileGroup = dataFileGroup.toBuilder().setExpirationDateSecs(laterTimeSecs).build();
+
+    NewFileKey[] fileKeys = createFileKeysUseChecksumOnly(dataFileGroup);
+
+    List<Pair<GroupKey, DataFileGroupInternal>> groups =
+        Arrays.asList(Pair.create(TEST_KEY_1, dataFileGroup));
+    when(mockFileGroupsMetadata.getAllFreshGroups()).thenReturn(Futures.immediateFuture(groups));
+    when(mockSharedFileManager.getFileStatus(fileKeys[0]))
+        .thenReturn(Futures.immediateFuture(FileStatus.DOWNLOAD_COMPLETE));
+    when(mockSharedFileManager.getOnDeviceUri(fileKeys[0]))
+        .thenReturn(Futures.immediateFuture(testUri1));
+    // The second file has not been downloaded.
+    when(mockSharedFileManager.getFileStatus(fileKeys[1]))
+        .thenReturn(Futures.immediateFuture(FileStatus.NONE));
+    when(mockSharedFileManager.getOnDeviceUri(fileKeys[1]))
+        .thenReturn(Futures.immediateFuture(testUri2));
+    when(mockSharedFilesMetadata.getAllFileKeys())
+        .thenReturn(Futures.immediateFuture(Arrays.asList(fileKeys)));
+    when(mockBackend.children(dirFor0p))
+        .thenReturn(Arrays.asList(testUri1, testUri2, tempTestUri2));
+
+    expirationHandler.updateExpiration().get();
+
+    verify(mockFileGroupsMetadata, times(3)).getAllFreshGroups();
+    verify(mockFileGroupsMetadata, times(2)).getAllStaleGroups();
+    verify(mockFileGroupsMetadata).removeAllGroupsWithKeys(ImmutableList.of());
+    verify(mockFileGroupsMetadata).removeAllStaleGroups();
+    verify(mockFileGroupsMetadata).writeStaleGroups(ImmutableList.of());
+    verifyNoMoreInteractions(mockFileGroupsMetadata);
+    verify(mockSharedFilesMetadata).getAllFileKeys();
+    verify(mockSharedFileManager).getOnDeviceUri(fileKeys[0]);
+    verify(mockSharedFileManager).getOnDeviceUri(fileKeys[1]);
+    verify(mockBackend).exists(baseDownloadDirectoryUri);
+    verify(mockBackend).children(baseDownloadDirectoryUri);
+    verify(mockBackend).isDirectory(dirForAll);
+    verify(mockBackend).isDirectory(dirFor1p);
+    verify(mockBackend).isDirectory(dirFor0p);
+    verify(mockBackend, never()).deleteFile(any());
+    verifyNoMoreInteractions(mockSharedFileManager);
+  }
+
+  @Test
+  public void updateExpiration_deleteInternalFilesWithExipiredAccountedFile() throws Exception {
+    Migrations.setCurrentVersion(context, FileKeyVersion.USE_CHECKSUM_ONLY);
+    Calendar now = new Calendar.Builder().setDate(2018, Calendar.MARCH, 20).build();
+    testClock.set(now.getTimeInMillis());
+
+    DataFileGroupInternal dataFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP_1, 1).toBuilder()
+            .setBuildId(10)
+            .setVariantId("testVariant")
+            .build();
+    long nowTimeSecs = now.getTimeInMillis() / 1000;
+    dataFileGroup = dataFileGroup.toBuilder().setExpirationDateSecs(nowTimeSecs).build();
+
+    NewFileKey[] fileKeys = createFileKeysUseChecksumOnly(dataFileGroup);
+
+    List<Pair<GroupKey, DataFileGroupInternal>> groups =
+        Arrays.asList(Pair.create(TEST_KEY_1, dataFileGroup));
+    when(mockFileGroupsMetadata.getAllFreshGroups())
+        .thenReturn(Futures.immediateFuture(groups))
+        .thenReturn(Futures.immediateFuture(new ArrayList<>()));
+    when(mockSharedFileManager.getFileStatus(fileKeys[0]))
+        .thenReturn(Futures.immediateFuture(FileStatus.DOWNLOAD_COMPLETE));
+    when(mockSharedFileManager.getOnDeviceUri(fileKeys[0]))
+        .thenReturn(Futures.immediateFuture(testUri2));
+
+    when(mockSharedFilesMetadata.getAllFileKeys())
+        .thenReturn(Futures.immediateFuture(Arrays.asList(fileKeys)));
+    when(mockBackend.children(dirFor0p)).thenReturn(Arrays.asList(testUri2, tempTestUri2));
+    expirationHandler.updateExpiration().get();
+
+    verify(mockFileGroupsMetadata, times(3)).getAllFreshGroups();
+    verify(mockFileGroupsMetadata, times(2)).getAllStaleGroups();
+    verify(mockFileGroupsMetadata).removeAllGroupsWithKeys(Arrays.asList(TEST_KEY_1));
+    verify(mockFileGroupsMetadata).removeAllStaleGroups();
+    verify(mockFileGroupsMetadata).writeStaleGroups(ImmutableList.of());
+    verifyNoMoreInteractions(mockFileGroupsMetadata);
+    verify(mockSharedFilesMetadata).getAllFileKeys();
+    verify(mockSharedFileManager).removeFileEntry(fileKeys[0]);
+
+    verify(mockBackend).exists(baseDownloadDirectoryUri);
+    verify(mockBackend).children(baseDownloadDirectoryUri);
+    verify(mockBackend).isDirectory(testUri2);
+    verify(mockBackend).isDirectory(tempTestUri2);
+    verify(mockBackend).deleteFile(testUri2);
+    verify(mockBackend).deleteFile(tempTestUri2);
+    verifyNoMoreInteractions(mockSharedFileManager);
+  }
+
+  @Test
+  public void updateExpiration_sharedFiles_noExpiration() throws Exception {
+    Calendar now = new Calendar.Builder().setDate(2018, Calendar.MARCH, 20).build();
+    testClock.set(now.getTimeInMillis());
+
+    DataFile dataFile = MddTestUtil.createDataFile("file", 0);
+    NewFileKey fileKey =
+        SharedFilesMetadata.createKeyFromDataFile(
+            dataFile, AllowedReaders.ONLY_GOOGLE_PLAY_SERVICES);
+
+    // The first group expires 30 days from now.
+    Calendar later = new Calendar.Builder().setDate(2018, Calendar.APRIL, 20).build();
+    long laterTimeSecs = later.getTimeInMillis() / 1000;
+    DataFileGroupInternal firstGroup =
+        DataFileGroupInternal.newBuilder()
+            .setGroupName(TEST_GROUP_1)
+            .addFile(dataFile)
+            .setAllowedReadersEnum(AllowedReaders.ONLY_GOOGLE_PLAY_SERVICES)
+            .setExpirationDateSecs(laterTimeSecs)
+            .build();
+
+    // The second group never expires
+    DataFileGroupInternal secondGroup =
+        DataFileGroupInternal.newBuilder()
+            .setGroupName(TEST_GROUP_2)
+            .addFile(dataFile)
+            .setAllowedReadersEnum(AllowedReaders.ONLY_GOOGLE_PLAY_SERVICES)
+            .build();
+
+    List<Pair<GroupKey, DataFileGroupInternal>> groups =
+        Arrays.asList(Pair.create(TEST_KEY_1, firstGroup), Pair.create(TEST_KEY_2, secondGroup));
+    when(mockFileGroupsMetadata.getAllFreshGroups()).thenReturn(Futures.immediateFuture(groups));
+    when(mockSharedFileManager.getFileStatus(fileKey))
+        .thenReturn(Futures.immediateFuture(FileStatus.DOWNLOAD_COMPLETE));
+    when(mockSharedFileManager.getOnDeviceUri(fileKey))
+        .thenReturn(Futures.immediateFuture(testUri1));
+    when(mockSharedFilesMetadata.getAllFileKeys())
+        .thenReturn(Futures.immediateFuture(Collections.singletonList(fileKey)));
+    when(mockBackend.children(dirFor0p)).thenReturn(Arrays.asList(testUri1));
+
+    expirationHandler.updateExpiration().get();
+
+    verify(mockFileGroupsMetadata, times(3)).getAllFreshGroups();
+    verify(mockFileGroupsMetadata, times(2)).getAllStaleGroups();
+    verify(mockFileGroupsMetadata).removeAllGroupsWithKeys(ImmutableList.of());
+    verify(mockFileGroupsMetadata).removeAllStaleGroups();
+    verify(mockFileGroupsMetadata).writeStaleGroups(ImmutableList.of());
+    verifyNoMoreInteractions(mockFileGroupsMetadata);
+    verify(mockSharedFilesMetadata).getAllFileKeys();
+    verify(mockSharedFileManager).getOnDeviceUri(fileKey);
+    verify(mockBackend).exists(baseDownloadDirectoryUri);
+    verify(mockBackend).children(baseDownloadDirectoryUri);
+    verify(mockBackend).isDirectory(dirForAll);
+    verify(mockBackend, never()).deleteFile(any());
+    verifyNoMoreInteractions(mockSharedFileManager);
+  }
+
+  @Test
+  public void updateExpiration_sharedFiles_expiration() throws Exception {
+    Calendar now = new Calendar.Builder().setDate(2018, Calendar.MARCH, 20).build();
+    testClock.set(now.getTimeInMillis());
+
+    DataFile dataFile = MddTestUtil.createDataFile("file", 0);
+    NewFileKey fileKey =
+        SharedFilesMetadata.createKeyFromDataFile(
+            dataFile, AllowedReaders.ONLY_GOOGLE_PLAY_SERVICES);
+
+    // The first group expires 30 days from now.
+    Calendar later = new Calendar.Builder().setDate(2018, Calendar.APRIL, 20).build();
+    long laterTimeSecs = later.getTimeInMillis() / 1000;
+    DataFileGroupInternal firstGroup =
+        DataFileGroupInternal.newBuilder()
+            .setGroupName(TEST_GROUP_1)
+            .addFile(dataFile)
+            .setAllowedReadersEnum(AllowedReaders.ONLY_GOOGLE_PLAY_SERVICES)
+            .setExpirationDateSecs(laterTimeSecs)
+            .build();
+
+    // The second group expires 15 days
+    Calendar sooner = new Calendar.Builder().setDate(2018, Calendar.APRIL, 5).build();
+    DataFileGroupInternal secondGroup =
+        DataFileGroupInternal.newBuilder()
+            .setGroupName(TEST_GROUP_2)
+            .addFile(dataFile)
+            .setAllowedReadersEnum(AllowedReaders.ONLY_GOOGLE_PLAY_SERVICES)
+            .setExpirationDateSecs(sooner.getTimeInMillis() / 1000)
+            .build();
+
+    List<Pair<GroupKey, DataFileGroupInternal>> groups =
+        Arrays.asList(Pair.create(TEST_KEY_1, firstGroup), Pair.create(TEST_KEY_2, secondGroup));
+    when(mockFileGroupsMetadata.getAllFreshGroups()).thenReturn(Futures.immediateFuture(groups));
+    when(mockSharedFileManager.getFileStatus(fileKey))
+        .thenReturn(Futures.immediateFuture(FileStatus.DOWNLOAD_COMPLETE));
+    when(mockSharedFileManager.getOnDeviceUri(fileKey))
+        .thenReturn(Futures.immediateFuture(testUri1));
+    when(mockSharedFilesMetadata.getAllFileKeys())
+        .thenReturn(Futures.immediateFuture(Collections.singletonList(fileKey)));
+    when(mockBackend.children(dirFor0p)).thenReturn(Arrays.asList(testUri1));
+
+    expirationHandler.updateExpiration().get();
+
+    verify(mockFileGroupsMetadata, times(3)).getAllFreshGroups();
+    verify(mockFileGroupsMetadata, times(2)).getAllStaleGroups();
+    verify(mockFileGroupsMetadata).removeAllGroupsWithKeys(ImmutableList.of());
+    verify(mockFileGroupsMetadata).removeAllStaleGroups();
+    verify(mockFileGroupsMetadata).writeStaleGroups(ImmutableList.of());
+    verifyNoMoreInteractions(mockFileGroupsMetadata);
+    verify(mockSharedFilesMetadata).getAllFileKeys();
+    verify(mockSharedFileManager).getOnDeviceUri(fileKey);
+    verify(mockBackend).exists(baseDownloadDirectoryUri);
+    verify(mockBackend).children(baseDownloadDirectoryUri);
+    verify(mockBackend).isDirectory(dirForAll);
+    verify(mockBackend, never()).deleteFile(any());
+    verifyNoMoreInteractions(mockSharedFileManager);
+  }
+
+  @Test
+  public void updateExpiration_noExpiredStaleGroups() throws Exception {
+    Calendar now = new Calendar.Builder().setDate(2018, Calendar.MARCH, 20).build();
+    testClock.set(now.getTimeInMillis());
+
+    Calendar later = new Calendar.Builder().setDate(2018, Calendar.MARCH, 22).build();
+    Long laterTimeSecs = later.getTimeInMillis() / 1000;
+    ;
+    DataFileGroupInternal dataFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP_1, 2).toBuilder()
+            .setStaleLifetimeSecs(laterTimeSecs - (now.getTimeInMillis() / 1000))
+            .setBookkeeping(
+                DataFileGroupBookkeeping.newBuilder().setStaleExpirationDate(laterTimeSecs).build())
+            .build();
+    NewFileKey[] fileKeys = MddTestUtil.createFileKeysForDataFileGroupInternal(dataFileGroup);
+
+    fileGroupsMetadataStaleGroups.set(ImmutableList.of(dataFileGroup));
+    when(mockSharedFileManager.getFileStatus(fileKeys[0]))
+        .thenReturn(Futures.immediateFuture(FileStatus.DOWNLOAD_COMPLETE));
+    when(mockSharedFileManager.getOnDeviceUri(fileKeys[0]))
+        .thenReturn(Futures.immediateFuture(testUri1));
+    when(mockSharedFileManager.getFileStatus(fileKeys[1]))
+        .thenReturn(Futures.immediateFuture(FileStatus.DOWNLOAD_COMPLETE));
+    when(mockSharedFileManager.getOnDeviceUri(fileKeys[1]))
+        .thenReturn(Futures.immediateFuture(testUri2));
+
+    when(mockSharedFilesMetadata.getAllFileKeys())
+        .thenReturn(Futures.immediateFuture(Arrays.asList(fileKeys)));
+    when(mockBackend.children(dirFor0p)).thenReturn(Arrays.asList(testUri1, testUri2));
+    expirationHandler.updateExpiration().get();
+
+    verify(mockFileGroupsMetadata, times(3)).getAllFreshGroups();
+    verify(mockFileGroupsMetadata, times(2)).getAllStaleGroups();
+    verify(mockFileGroupsMetadata).removeAllGroupsWithKeys(ImmutableList.of());
+    verify(mockFileGroupsMetadata).removeAllStaleGroups();
+    verify(mockFileGroupsMetadata).writeStaleGroups(ImmutableList.of(dataFileGroup));
+    verifyNoMoreInteractions(mockFileGroupsMetadata);
+    verify(mockSharedFilesMetadata).getAllFileKeys();
+    verify(mockSharedFileManager).getOnDeviceUri(fileKeys[0]);
+    verify(mockSharedFileManager).getOnDeviceUri(fileKeys[1]);
+    verify(mockBackend).exists(baseDownloadDirectoryUri);
+    verify(mockBackend).children(baseDownloadDirectoryUri);
+    verify(mockBackend).isDirectory(dirForAll);
+    verify(mockBackend).isDirectory(dirFor1p);
+    verify(mockBackend, never()).deleteFile(any());
+    verifyNoMoreInteractions(mockSharedFileManager);
+  }
+
+  @Test
+  public void updateExpiration_noExpiredStaleGroups_notDeleteDir() throws Exception {
+    Calendar now = new Calendar.Builder().setDate(2018, Calendar.MARCH, 20).build();
+    testClock.set(now.getTimeInMillis());
+
+    Calendar later = new Calendar.Builder().setDate(2018, Calendar.MARCH, 22).build();
+    long laterTimeSecs = later.getTimeInMillis() / 1000;
+    DataFileGroupInternal dataFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP_1, 2).toBuilder()
+            .setStaleLifetimeSecs(laterTimeSecs - (now.getTimeInMillis() / 1000))
+            .setBookkeeping(
+                DataFileGroupBookkeeping.newBuilder().setStaleExpirationDate(laterTimeSecs).build())
+            .build();
+    NewFileKey[] fileKeys = MddTestUtil.createFileKeysForDataFileGroupInternal(dataFileGroup);
+
+    fileGroupsMetadataStaleGroups.set(ImmutableList.of(dataFileGroup));
+    when(mockSharedFileManager.getFileStatus(fileKeys[0]))
+        .thenReturn(Futures.immediateFuture(FileStatus.DOWNLOAD_COMPLETE));
+    when(mockSharedFileManager.getOnDeviceUri(fileKeys[0]))
+        .thenReturn(Futures.immediateFuture(testDirUri1));
+    when(mockBackend.children(testDirUri1))
+        .thenReturn(Arrays.asList(testDirFileUri1, testDirFileUri2));
+    when(mockSharedFileManager.getFileStatus(fileKeys[1]))
+        .thenReturn(Futures.immediateFuture(FileStatus.DOWNLOAD_COMPLETE));
+    when(mockSharedFileManager.getOnDeviceUri(fileKeys[1]))
+        .thenReturn(Futures.immediateFuture(testUri2));
+
+    when(mockSharedFilesMetadata.getAllFileKeys())
+        .thenReturn(Futures.immediateFuture(Arrays.asList(fileKeys)));
+    when(mockBackend.children(dirFor0p)).thenReturn(Arrays.asList(testDirUri1, testUri2));
+    expirationHandler.updateExpiration().get();
+
+    verify(mockFileGroupsMetadata, times(3)).getAllFreshGroups();
+    verify(mockFileGroupsMetadata, times(2)).getAllStaleGroups();
+    verify(mockFileGroupsMetadata).removeAllGroupsWithKeys(ImmutableList.of());
+    verify(mockFileGroupsMetadata).removeAllStaleGroups();
+    verify(mockFileGroupsMetadata).writeStaleGroups(ImmutableList.of(dataFileGroup));
+    verifyNoMoreInteractions(mockFileGroupsMetadata);
+    verify(mockSharedFilesMetadata).getAllFileKeys();
+    verify(mockSharedFileManager).getOnDeviceUri(fileKeys[0]);
+    verify(mockSharedFileManager).getOnDeviceUri(fileKeys[1]);
+    verify(mockBackend).exists(baseDownloadDirectoryUri);
+    verify(mockBackend).children(baseDownloadDirectoryUri);
+    verify(mockBackend).isDirectory(dirForAll);
+    verify(mockBackend).isDirectory(dirFor1p);
+    verify(mockBackend, never()).deleteFile(any());
+    verifyNoMoreInteractions(mockSharedFileManager);
+  }
+
+  @Test
+  public void updateExpiration_expiredStaleGroups_shorterStaleExpirationDate() throws Exception {
+    Calendar now = new Calendar.Builder().setDate(2018, Calendar.MARCH, 20).build();
+    Calendar later = new Calendar.Builder().setDate(2018, Calendar.MARCH, 22).build();
+    testClock.set(now.getTimeInMillis());
+
+    Long nowTimeSecs = now.getTimeInMillis() / 1000;
+    DataFileGroupInternal dataFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP_1, 2).toBuilder()
+            .setStaleLifetimeSecs(0)
+            .setBookkeeping(
+                DataFileGroupBookkeeping.newBuilder().setStaleExpirationDate(nowTimeSecs).build())
+            .setExpirationDateSecs(later.getTimeInMillis() / 1000)
+            .setBuildId(10)
+            .setVariantId("testVariant")
+            .build();
+    NewFileKey[] fileKeys = MddTestUtil.createFileKeysForDataFileGroupInternal(dataFileGroup);
+
+    fileGroupsMetadataStaleGroups.set(ImmutableList.of(dataFileGroup));
+    when(mockSharedFileManager.getFileStatus(fileKeys[0]))
+        .thenReturn(Futures.immediateFuture(FileStatus.DOWNLOAD_COMPLETE));
+    when(mockSharedFileManager.getOnDeviceUri(fileKeys[0]))
+        .thenReturn(Futures.immediateFuture(testUri1));
+    when(mockSharedFileManager.getFileStatus(fileKeys[1]))
+        .thenReturn(Futures.immediateFuture(FileStatus.DOWNLOAD_COMPLETE));
+    when(mockSharedFileManager.getOnDeviceUri(fileKeys[1]))
+        .thenReturn(Futures.immediateFuture(testUri2));
+    when(mockSharedFilesMetadata.getAllFileKeys())
+        .thenReturn(Futures.immediateFuture(Arrays.asList(fileKeys)));
+    when(mockBackend.children(dirFor0p)).thenReturn(Arrays.asList(testUri1, testUri2));
+
+    expirationHandler.updateExpiration().get();
+
+    verify(mockFileGroupsMetadata, times(3)).getAllFreshGroups();
+    verify(mockFileGroupsMetadata, times(2)).getAllStaleGroups();
+    verify(mockFileGroupsMetadata).removeAllGroupsWithKeys(ImmutableList.of());
+    verify(mockFileGroupsMetadata).removeAllStaleGroups();
+    verify(mockFileGroupsMetadata).writeStaleGroups(ImmutableList.of());
+    verifyNoMoreInteractions(mockFileGroupsMetadata);
+    verify(mockSharedFilesMetadata).getAllFileKeys();
+    verify(mockSharedFileManager).removeFileEntry(fileKeys[0]);
+    verify(mockSharedFileManager).removeFileEntry(fileKeys[1]);
+    verifyNoMoreInteractions(mockSharedFileManager);
+    verify(mockBackend).exists(baseDownloadDirectoryUri);
+    verify(mockBackend).children(baseDownloadDirectoryUri);
+    verify(mockBackend).isDirectory(testUri1);
+    verify(mockBackend).isDirectory(testUri2);
+    verify(mockBackend).deleteFile(testUri1);
+    verify(mockBackend).deleteFile(testUri2);
+  }
+
+  @Test
+  public void updateExpiration_expiredStaleGroups_shorterExpirationDate() throws Exception {
+    Calendar now = new Calendar.Builder().setDate(2018, Calendar.MARCH, 20).build();
+    Calendar later = new Calendar.Builder().setDate(2018, Calendar.MARCH, 22).build();
+    testClock.set(now.getTimeInMillis());
+
+    Long nowTimeSecs = now.getTimeInMillis() / 1000;
+    DataFileGroupInternal dataFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP_1, 2).toBuilder()
+            .setBookkeeping(
+                DataFileGroupBookkeeping.newBuilder()
+                    .setStaleExpirationDate(later.getTimeInMillis() / 1000)
+                    .build())
+            .setExpirationDateSecs(nowTimeSecs)
+            .setBuildId(10)
+            .setVariantId("testVariant")
+            .build();
+    NewFileKey[] fileKeys = MddTestUtil.createFileKeysForDataFileGroupInternal(dataFileGroup);
+
+    fileGroupsMetadataStaleGroups.set(ImmutableList.of(dataFileGroup));
+
+    when(mockSharedFileManager.getFileStatus(fileKeys[0]))
+        .thenReturn(Futures.immediateFuture(FileStatus.DOWNLOAD_COMPLETE));
+    when(mockSharedFileManager.getOnDeviceUri(fileKeys[0]))
+        .thenReturn(Futures.immediateFuture(testUri1));
+    when(mockSharedFileManager.getFileStatus(fileKeys[1]))
+        .thenReturn(Futures.immediateFuture(FileStatus.DOWNLOAD_COMPLETE));
+    when(mockSharedFileManager.getOnDeviceUri(fileKeys[1]))
+        .thenReturn(Futures.immediateFuture(testUri2));
+    when(mockSharedFilesMetadata.getAllFileKeys())
+        .thenReturn(Futures.immediateFuture(Arrays.asList(fileKeys)));
+    when(mockBackend.children(dirFor0p)).thenReturn(Arrays.asList(testUri1, testUri2));
+
+    expirationHandler.updateExpiration().get();
+
+    verify(mockFileGroupsMetadata, times(3)).getAllFreshGroups();
+    verify(mockFileGroupsMetadata, times(2)).getAllStaleGroups();
+    verify(mockFileGroupsMetadata).removeAllGroupsWithKeys(ImmutableList.of());
+    verify(mockFileGroupsMetadata).removeAllStaleGroups();
+    verify(mockFileGroupsMetadata).writeStaleGroups(ImmutableList.of());
+    verifyNoMoreInteractions(mockFileGroupsMetadata);
+    verify(mockSharedFilesMetadata).getAllFileKeys();
+    verify(mockSharedFileManager).removeFileEntry(fileKeys[0]);
+    verify(mockSharedFileManager).removeFileEntry(fileKeys[1]);
+    verifyNoMoreInteractions(mockSharedFileManager);
+    verify(mockBackend).exists(baseDownloadDirectoryUri);
+    verify(mockBackend).children(baseDownloadDirectoryUri);
+    verify(mockBackend).isDirectory(testUri1);
+    verify(mockBackend).isDirectory(testUri2);
+    verify(mockBackend).deleteFile(testUri1);
+    verify(mockBackend).deleteFile(testUri2);
+  }
+
+  @Test
+  public void updateExpiration_expiredStaleGroups_deleteExpiredDir() throws Exception {
+    Calendar now = new Calendar.Builder().setDate(2018, Calendar.MARCH, 20).build();
+    Calendar later = new Calendar.Builder().setDate(2018, Calendar.MARCH, 22).build();
+    testClock.set(now.getTimeInMillis());
+    long nowTimeSecs = now.getTimeInMillis() / 1000;
+    DataFileGroupInternal dataFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP_1, 2).toBuilder()
+            .setBookkeeping(
+                DataFileGroupBookkeeping.newBuilder()
+                    .setStaleExpirationDate(later.getTimeInMillis() / 1000)
+                    .build())
+            .setExpirationDateSecs(nowTimeSecs)
+            .build();
+    NewFileKey[] fileKeys = MddTestUtil.createFileKeysForDataFileGroupInternal(dataFileGroup);
+
+    fileGroupsMetadataStaleGroups.set(ImmutableList.of(dataFileGroup));
+
+    when(mockSharedFileManager.getFileStatus(fileKeys[0]))
+        .thenReturn(Futures.immediateFuture(FileStatus.DOWNLOAD_COMPLETE));
+    when(mockSharedFileManager.getOnDeviceUri(fileKeys[0]))
+        .thenReturn(Futures.immediateFuture(testDirUri1));
+    when(mockSharedFileManager.getFileStatus(fileKeys[1]))
+        .thenReturn(Futures.immediateFuture(FileStatus.DOWNLOAD_COMPLETE));
+    when(mockSharedFileManager.getOnDeviceUri(fileKeys[1]))
+        .thenReturn(Futures.immediateFuture(testUri2));
+    when(mockSharedFilesMetadata.getAllFileKeys())
+        .thenReturn(Futures.immediateFuture(Arrays.asList(fileKeys)));
+    when(mockBackend.children(dirFor1p)).thenReturn(Arrays.asList(testDirUri1, testUri2));
+    when(mockBackend.children(testDirUri1))
+        .thenReturn(Arrays.asList(testDirFileUri1, testDirFileUri2));
+
+    expirationHandler.updateExpiration().get();
+
+    verify(mockFileGroupsMetadata, times(3)).getAllFreshGroups();
+    verify(mockFileGroupsMetadata, times(2)).getAllStaleGroups();
+    verify(mockFileGroupsMetadata).removeAllGroupsWithKeys(ImmutableList.of());
+    verify(mockFileGroupsMetadata).removeAllStaleGroups();
+    verify(mockFileGroupsMetadata).writeStaleGroups(ImmutableList.of());
+    verifyNoMoreInteractions(mockFileGroupsMetadata);
+    verify(mockSharedFilesMetadata).getAllFileKeys();
+    verify(mockSharedFileManager).removeFileEntry(fileKeys[0]);
+    verify(mockSharedFileManager).removeFileEntry(fileKeys[1]);
+    verifyNoMoreInteractions(mockSharedFileManager);
+    verify(mockBackend).exists(baseDownloadDirectoryUri);
+    verify(mockBackend).children(baseDownloadDirectoryUri);
+    verify(mockBackend).isDirectory(testDirUri1);
+    verify(mockBackend).isDirectory(testDirFileUri1);
+    verify(mockBackend).isDirectory(testDirFileUri2);
+    verify(mockBackend).isDirectory(testUri2);
+    verify(mockBackend).deleteFile(testDirFileUri1);
+    verify(mockBackend).deleteFile(testDirFileUri2);
+    verify(mockBackend).deleteFile(testUri2);
+  }
+
+  @Test
+  public void updateExpiration_sharedFiles_staleGroupSoonerExpiration_activeGroupLaterExpiration()
+      throws Exception {
+    Calendar now = new Calendar.Builder().setDate(2018, Calendar.MARCH, 20).build();
+    testClock.set(now.getTimeInMillis());
+
+    DataFile dataFile = MddTestUtil.createDataFile("file", 0);
+    NewFileKey fileKey =
+        SharedFilesMetadata.createKeyFromDataFile(
+            dataFile, AllowedReaders.ONLY_GOOGLE_PLAY_SERVICES);
+
+    // The active group expires 30 days from now.
+    Calendar later = new Calendar.Builder().setDate(2018, Calendar.APRIL, 20).build();
+    long laterTimeSecs = later.getTimeInMillis() / 1000;
+    DataFileGroupInternal activeGroup =
+        DataFileGroupInternal.newBuilder()
+            .setGroupName(TEST_GROUP_1)
+            .addFile(dataFile)
+            .setAllowedReadersEnum(AllowedReaders.ONLY_GOOGLE_PLAY_SERVICES)
+            .setExpirationDateSecs(laterTimeSecs)
+            .build();
+
+    // The stale group expires 2 days from now.
+    Calendar sooner = new Calendar.Builder().setDate(2018, Calendar.MARCH, 22).build();
+    DataFileGroupInternal staleGroup =
+        DataFileGroupInternal.newBuilder()
+            .setGroupName(TEST_GROUP_2)
+            .addFile(dataFile)
+            .setAllowedReadersEnum(AllowedReaders.ONLY_GOOGLE_PLAY_SERVICES)
+            .setBookkeeping(
+                DataFileGroupBookkeeping.newBuilder()
+                    .setStaleExpirationDate(sooner.getTimeInMillis() / 1000)
+                    .build())
+            .setStaleLifetimeSecs((sooner.getTimeInMillis() - now.getTimeInMillis()) / 1000)
+            .build();
+
+    List<Pair<GroupKey, DataFileGroupInternal>> groups =
+        Arrays.asList(Pair.create(TEST_KEY_1, activeGroup));
+    when(mockFileGroupsMetadata.getAllFreshGroups()).thenReturn(Futures.immediateFuture(groups));
+
+    fileGroupsMetadataStaleGroups.set(ImmutableList.of(staleGroup));
+
+    when(mockSharedFileManager.getFileStatus(fileKey))
+        .thenReturn(Futures.immediateFuture(FileStatus.DOWNLOAD_COMPLETE));
+    when(mockSharedFileManager.getOnDeviceUri(fileKey))
+        .thenReturn(Futures.immediateFuture(testUri1));
+    when(mockSharedFilesMetadata.getAllFileKeys())
+        .thenReturn(Futures.immediateFuture(Collections.singletonList(fileKey)));
+    setUpDirectoryMock(dirFor1p, Arrays.asList(testUri1));
+    setUpFileMock(testUri1, 100);
+
+    expirationHandler.updateExpiration().get();
+
+    verify(mockFileGroupsMetadata, times(3)).getAllFreshGroups();
+    verify(mockFileGroupsMetadata, times(2)).getAllStaleGroups();
+    verify(mockFileGroupsMetadata).removeAllGroupsWithKeys(ImmutableList.of());
+    verify(mockFileGroupsMetadata).removeAllStaleGroups();
+    verify(mockFileGroupsMetadata).writeStaleGroups(ImmutableList.of(staleGroup));
+    verifyNoMoreInteractions(mockFileGroupsMetadata);
+    verify(mockSharedFilesMetadata).getAllFileKeys();
+    verify(mockSharedFileManager).getOnDeviceUri(fileKey);
+    verifyNoMoreInteractions(mockSharedFileManager);
+    verify(mockBackend).exists(baseDownloadDirectoryUri);
+    verify(mockBackend).children(baseDownloadDirectoryUri);
+    verify(mockBackend).isDirectory(dirForAll);
+    verify(mockBackend, never()).deleteFile(any());
+  }
+
+  @Test
+  public void updateExpiration_sharedFiles_staleGroupLaterExpiration_activeGroupSoonerExpiration()
+      throws Exception {
+    Calendar now = new Calendar.Builder().setDate(2018, Calendar.MARCH, 20).build();
+    testClock.set(now.getTimeInMillis());
+
+    DataFile dataFile = MddTestUtil.createDataFile("file", 0);
+    NewFileKey fileKey =
+        SharedFilesMetadata.createKeyFromDataFile(
+            dataFile, AllowedReaders.ONLY_GOOGLE_PLAY_SERVICES);
+
+    // The active group expires 1 day from now.
+    Calendar sooner = new Calendar.Builder().setDate(2018, Calendar.MARCH, 21).build();
+    DataFileGroupInternal activeGroup =
+        DataFileGroupInternal.newBuilder()
+            .setGroupName(TEST_GROUP_1)
+            .addFile(dataFile)
+            .setAllowedReadersEnum(AllowedReaders.ONLY_GOOGLE_PLAY_SERVICES)
+            .setExpirationDateSecs(sooner.getTimeInMillis() / 1000)
+            .build();
+
+    // The stale group expires 2 days from now.
+    Calendar later = new Calendar.Builder().setDate(2018, Calendar.MARCH, 22).build();
+    long laterTimeSecs = later.getTimeInMillis() / 1000;
+    DataFileGroupInternal staleGroup =
+        DataFileGroupInternal.newBuilder()
+            .setGroupName(TEST_GROUP_2)
+            .addFile(dataFile)
+            .setAllowedReadersEnum(AllowedReaders.ONLY_GOOGLE_PLAY_SERVICES)
+            .setBookkeeping(
+                DataFileGroupBookkeeping.newBuilder().setStaleExpirationDate(laterTimeSecs).build())
+            .setStaleLifetimeSecs(laterTimeSecs - now.getTimeInMillis() / 1000)
+            .build();
+
+    List<Pair<GroupKey, DataFileGroupInternal>> groups =
+        Arrays.asList(Pair.create(TEST_KEY_1, activeGroup));
+    when(mockFileGroupsMetadata.getAllFreshGroups()).thenReturn(Futures.immediateFuture(groups));
+
+    fileGroupsMetadataStaleGroups.set(ImmutableList.of(staleGroup));
+
+    when(mockSharedFileManager.getFileStatus(fileKey))
+        .thenReturn(Futures.immediateFuture(FileStatus.DOWNLOAD_COMPLETE));
+    when(mockSharedFileManager.getOnDeviceUri(fileKey))
+        .thenReturn(Futures.immediateFuture(testUri1));
+    when(mockSharedFilesMetadata.getAllFileKeys())
+        .thenReturn(Futures.immediateFuture(Collections.singletonList(fileKey)));
+    when(mockBackend.children(dirFor0p)).thenReturn(Arrays.asList(testUri1));
+
+    expirationHandler.updateExpiration().get();
+
+    verify(mockFileGroupsMetadata, times(3)).getAllFreshGroups();
+    verify(mockFileGroupsMetadata, times(2)).getAllStaleGroups();
+    verify(mockFileGroupsMetadata).removeAllGroupsWithKeys(ImmutableList.of());
+    verify(mockFileGroupsMetadata).removeAllStaleGroups();
+    verify(mockFileGroupsMetadata).writeStaleGroups(ImmutableList.of(staleGroup));
+    verifyNoMoreInteractions(mockFileGroupsMetadata);
+    verify(mockSharedFilesMetadata).getAllFileKeys();
+    verify(mockSharedFileManager).getOnDeviceUri(fileKey);
+    verifyNoMoreInteractions(mockSharedFileManager);
+    verify(mockBackend).exists(baseDownloadDirectoryUri);
+    verify(mockBackend).children(baseDownloadDirectoryUri);
+    verify(mockBackend).isDirectory(dirForAll);
+    verify(mockBackend, never()).deleteFile(any());
+  }
+
+  @Test
+  public void updateExpiration_sharedFiles_staleGroup_activeGroupNoExpiration() throws Exception {
+    Calendar now = new Calendar.Builder().setDate(2018, Calendar.MARCH, 20).build();
+    testClock.set(now.getTimeInMillis());
+
+    DataFile dataFile = MddTestUtil.createDataFile("file", 0);
+    NewFileKey fileKey =
+        SharedFilesMetadata.createKeyFromDataFile(
+            dataFile, AllowedReaders.ONLY_GOOGLE_PLAY_SERVICES);
+
+    // The active group never expires.
+    DataFileGroupInternal activeGroup =
+        DataFileGroupInternal.newBuilder()
+            .setGroupName(TEST_GROUP_1)
+            .addFile(dataFile)
+            .setAllowedReadersEnum(AllowedReaders.ONLY_GOOGLE_PLAY_SERVICES)
+            .build();
+
+    // The stale group expires 2 days from now.
+    Calendar sooner = new Calendar.Builder().setDate(2018, Calendar.MARCH, 22).build();
+    DataFileGroupInternal staleGroup =
+        DataFileGroupInternal.newBuilder()
+            .setGroupName(TEST_GROUP_2)
+            .addFile(dataFile)
+            .setAllowedReadersEnum(AllowedReaders.ONLY_GOOGLE_PLAY_SERVICES)
+            .setBookkeeping(
+                DataFileGroupBookkeeping.newBuilder()
+                    .setStaleExpirationDate(sooner.getTimeInMillis() / 1000)
+                    .build())
+            .setStaleLifetimeSecs((sooner.getTimeInMillis() - now.getTimeInMillis()) / 1000)
+            .build();
+
+    List<Pair<GroupKey, DataFileGroupInternal>> groups =
+        Arrays.asList(Pair.create(TEST_KEY_1, activeGroup));
+    when(mockFileGroupsMetadata.getAllFreshGroups()).thenReturn(Futures.immediateFuture(groups));
+
+    fileGroupsMetadataStaleGroups.set(ImmutableList.of(staleGroup));
+
+    when(mockSharedFileManager.getFileStatus(fileKey))
+        .thenReturn(Futures.immediateFuture(FileStatus.DOWNLOAD_COMPLETE));
+    when(mockSharedFileManager.getOnDeviceUri(fileKey))
+        .thenReturn(Futures.immediateFuture(testUri1));
+    when(mockSharedFilesMetadata.getAllFileKeys())
+        .thenReturn(Futures.immediateFuture(Collections.singletonList(fileKey)));
+    when(mockBackend.children(dirFor0p)).thenReturn(Arrays.asList(testUri1));
+
+    expirationHandler.updateExpiration().get();
+
+    verify(mockFileGroupsMetadata, times(3)).getAllFreshGroups();
+    verify(mockFileGroupsMetadata, times(2)).getAllStaleGroups();
+    verify(mockFileGroupsMetadata).removeAllGroupsWithKeys(ImmutableList.of());
+    verify(mockFileGroupsMetadata).removeAllStaleGroups();
+    verify(mockFileGroupsMetadata).writeStaleGroups(ImmutableList.of(staleGroup));
+    verifyNoMoreInteractions(mockFileGroupsMetadata);
+    verify(mockSharedFilesMetadata).getAllFileKeys();
+    verify(mockSharedFileManager).getOnDeviceUri(fileKey);
+    verifyNoMoreInteractions(mockSharedFileManager);
+    verify(mockBackend).exists(baseDownloadDirectoryUri);
+    verify(mockBackend).children(baseDownloadDirectoryUri);
+    verify(mockBackend).isDirectory(dirForAll);
+    verify(mockBackend, never()).deleteFile(any());
+  }
+
+  @Test
+  public void updateExpiration_sharedFiles_staleGroupNonExpired_activeGroupExpired()
+      throws Exception {
+    Calendar now = new Calendar.Builder().setDate(2018, Calendar.MARCH, 20).build();
+    testClock.set(now.getTimeInMillis());
+
+    DataFile dataFile = MddTestUtil.createDataFile("file", 0);
+    NewFileKey fileKey =
+        SharedFilesMetadata.createKeyFromDataFile(
+            dataFile, AllowedReaders.ONLY_GOOGLE_PLAY_SERVICES);
+
+    // The active group is expired.
+    Calendar sooner = new Calendar.Builder().setDate(2018, Calendar.MARCH, 19).build();
+    DataFileGroupInternal activeGroup =
+        DataFileGroupInternal.newBuilder()
+            .setGroupName(TEST_GROUP_1)
+            .addFile(dataFile)
+            .setAllowedReadersEnum(AllowedReaders.ONLY_GOOGLE_PLAY_SERVICES)
+            .setExpirationDateSecs(sooner.getTimeInMillis() / 1000)
+            .build();
+
+    // The stale group expires 2 days from now.
+    Calendar later = new Calendar.Builder().setDate(2018, Calendar.MARCH, 22).build();
+    long laterTimeSecs = later.getTimeInMillis() / 1000;
+    DataFileGroupInternal staleGroup =
+        DataFileGroupInternal.newBuilder()
+            .setGroupName(TEST_GROUP_2)
+            .addFile(dataFile)
+            .setAllowedReadersEnum(AllowedReaders.ONLY_GOOGLE_PLAY_SERVICES)
+            .setBookkeeping(
+                DataFileGroupBookkeeping.newBuilder().setStaleExpirationDate(laterTimeSecs).build())
+            .setStaleLifetimeSecs(laterTimeSecs - now.getTimeInMillis() / 1000)
+            .build();
+
+    List<Pair<GroupKey, DataFileGroupInternal>> groups =
+        Arrays.asList(Pair.create(TEST_KEY_1, activeGroup));
+    when(mockFileGroupsMetadata.getAllFreshGroups()).thenReturn(Futures.immediateFuture(groups));
+
+    fileGroupsMetadataStaleGroups.set(ImmutableList.of(staleGroup));
+
+    when(mockSharedFileManager.getFileStatus(fileKey))
+        .thenReturn(Futures.immediateFuture(FileStatus.DOWNLOAD_COMPLETE));
+    when(mockSharedFileManager.getOnDeviceUri(fileKey))
+        .thenReturn(Futures.immediateFuture(testUri1));
+    when(mockSharedFilesMetadata.getAllFileKeys())
+        .thenReturn(Futures.immediateFuture(Collections.singletonList(fileKey)));
+    when(mockBackend.children(dirFor0p)).thenReturn(Arrays.asList(testUri1));
+
+    expirationHandler.updateExpiration().get();
+
+    verify(mockFileGroupsMetadata, times(3)).getAllFreshGroups();
+    verify(mockFileGroupsMetadata, times(2)).getAllStaleGroups();
+    verify(mockFileGroupsMetadata).removeAllGroupsWithKeys(Arrays.asList(TEST_KEY_1));
+    verify(mockFileGroupsMetadata).removeAllStaleGroups();
+    verify(mockFileGroupsMetadata).writeStaleGroups(ImmutableList.of(staleGroup));
+    verifyNoMoreInteractions(mockFileGroupsMetadata);
+    verify(mockSharedFilesMetadata).getAllFileKeys();
+    verify(mockSharedFileManager).getOnDeviceUri(fileKey);
+    verifyNoMoreInteractions(mockSharedFileManager);
+    verify(mockBackend).exists(baseDownloadDirectoryUri);
+    verify(mockBackend).children(baseDownloadDirectoryUri);
+    verify(mockBackend).isDirectory(dirForAll);
+    verify(mockBackend, never()).deleteFile(any());
+  }
+
+  @Test
+  public void updateExpiration_multipleExpiredGroups() throws Exception {
+    Calendar now = new Calendar.Builder().setDate(2018, Calendar.MARCH, 20).build();
+    testClock.set(now.getTimeInMillis());
+
+    DataFile dataFile = MddTestUtil.createDataFile("file", 0);
+    NewFileKey fileKey =
+        SharedFilesMetadata.createKeyFromDataFile(
+            dataFile, AllowedReaders.ONLY_GOOGLE_PLAY_SERVICES);
+
+    // DAY 0 : The firstGroup is active and will expire in 30 days.
+    Calendar earlier = new Calendar.Builder().setDate(2018, Calendar.MARCH, 19).build();
+    Long earlierSecs = earlier.getTimeInMillis() / 1000;
+    DataFileGroupInternal firstGroup =
+        DataFileGroupInternal.newBuilder()
+            .setGroupName(TEST_GROUP_1)
+            .addFile(dataFile)
+            .setAllowedReadersEnum(AllowedReaders.ONLY_GOOGLE_PLAY_SERVICES)
+            .setExpirationDateSecs(earlierSecs)
+            .build();
+
+    Calendar earliest = new Calendar.Builder().setDate(2018, Calendar.MARCH, 18).build();
+    Long earliestSecs = earliest.getTimeInMillis() / 1000;
+    DataFileGroupInternal secondGroup =
+        DataFileGroupInternal.newBuilder()
+            .setGroupName(TEST_GROUP_2)
+            .addFile(dataFile)
+            .setAllowedReadersEnum(AllowedReaders.ONLY_GOOGLE_PLAY_SERVICES)
+            .setExpirationDateSecs(earliestSecs)
+            .build();
+
+    List<Pair<GroupKey, DataFileGroupInternal>> groups =
+        Arrays.asList(Pair.create(TEST_KEY_1, firstGroup));
+    when(mockFileGroupsMetadata.getAllFreshGroups())
+        .thenReturn(Futures.immediateFuture(groups))
+        .thenReturn(Futures.immediateFuture(ImmutableList.of()));
+
+    fileGroupsMetadataStaleGroups.set(ImmutableList.of(secondGroup));
+
+    when(mockSharedFileManager.getFileStatus(fileKey))
+        .thenReturn(Futures.immediateFuture(FileStatus.DOWNLOAD_COMPLETE));
+    when(mockSharedFileManager.getOnDeviceUri(fileKey))
+        .thenReturn(Futures.immediateFuture(testUri1));
+    when(mockSharedFilesMetadata.getAllFileKeys())
+        .thenReturn(Futures.immediateFuture(Collections.singletonList(fileKey)));
+    when(mockBackend.children(dirFor0p))
+        .thenReturn(Arrays.asList(testUri1, testUri3 /*an old file left on device somehow*/));
+
+    expirationHandler.updateExpiration().get();
+
+    verify(mockFileGroupsMetadata, times(3)).getAllFreshGroups();
+    verify(mockFileGroupsMetadata, times(2)).getAllStaleGroups();
+    verify(mockFileGroupsMetadata).removeAllGroupsWithKeys(Arrays.asList(TEST_KEY_1));
+    verify(mockFileGroupsMetadata).removeAllStaleGroups();
+    verify(mockFileGroupsMetadata).writeStaleGroups(ImmutableList.of());
+    verifyNoMoreInteractions(mockFileGroupsMetadata);
+    verify(mockSharedFilesMetadata).getAllFileKeys();
+    // unsubscribe should only be called once even though two groups referencing fileKey have both
+    // expired.
+    verify(mockSharedFileManager).removeFileEntry(fileKey);
+    verifyNoMoreInteractions(mockSharedFileManager);
+    verify(mockBackend).exists(baseDownloadDirectoryUri);
+    verify(mockBackend).children(baseDownloadDirectoryUri);
+    verify(mockBackend).isDirectory(testUri1);
+    verify(mockBackend).deleteFile(testUri1);
+    verify(mockBackend).isDirectory(testUri3);
+    verify(mockBackend).deleteFile(testUri3);
+  }
+
+  @Test
+  public void updateExpiration_multipleTimes_withGroupTransitions() throws Exception {
+    Calendar now = new Calendar.Builder().setDate(2018, Calendar.MARCH, 20).build();
+    testClock.set(now.getTimeInMillis());
+
+    DataFile dataFile = MddTestUtil.createDataFile("file", 0);
+    NewFileKey fileKey =
+        SharedFilesMetadata.createKeyFromDataFile(
+            dataFile, AllowedReaders.ONLY_GOOGLE_PLAY_SERVICES);
+
+    // DAY 0 : The firstGroup is active and will expire in 30 days.
+    Calendar firstExpiration = new Calendar.Builder().setDate(2018, Calendar.APRIL, 20).build();
+    Long firstExpirationSecs = firstExpiration.getTimeInMillis() / 1000;
+    DataFileGroupInternal.Builder firstGroup =
+        DataFileGroupInternal.newBuilder()
+            .setGroupName(TEST_GROUP_1)
+            .addFile(dataFile)
+            .setAllowedReadersEnum(AllowedReaders.ONLY_GOOGLE_PLAY_SERVICES)
+            .setExpirationDateSecs(firstExpirationSecs);
+
+    List<Pair<GroupKey, DataFileGroupInternal>> groups =
+        Arrays.asList(Pair.create(TEST_KEY_1, firstGroup.build()));
+    when(mockFileGroupsMetadata.getAllFreshGroups()).thenReturn(Futures.immediateFuture(groups));
+
+    when(mockSharedFileManager.getFileStatus(fileKey))
+        .thenReturn(Futures.immediateFuture(FileStatus.DOWNLOAD_COMPLETE));
+    when(mockSharedFileManager.getOnDeviceUri(fileKey))
+        .thenReturn(Futures.immediateFuture(testUri1));
+    when(mockSharedFilesMetadata.getAllFileKeys())
+        .thenReturn(Futures.immediateFuture(Collections.singletonList(fileKey)));
+    when(mockBackend.children(dirFor0p)).thenReturn(Arrays.asList(testUri1));
+
+    expirationHandler.updateExpiration().get();
+
+    verify(mockFileGroupsMetadata, times(3)).getAllFreshGroups();
+    verify(mockFileGroupsMetadata, times(2)).getAllStaleGroups();
+    verify(mockFileGroupsMetadata).removeAllGroupsWithKeys(ImmutableList.of());
+    verify(mockFileGroupsMetadata).removeAllStaleGroups();
+    verify(mockFileGroupsMetadata).writeStaleGroups(ImmutableList.of());
+    verifyNoMoreInteractions(mockFileGroupsMetadata);
+    verify(mockSharedFilesMetadata).getAllFileKeys();
+    verify(mockSharedFileManager).getOnDeviceUri(fileKey);
+    verify(mockBackend).exists(baseDownloadDirectoryUri);
+    verify(mockBackend).children(baseDownloadDirectoryUri);
+    verify(mockBackend).isDirectory(dirForAll);
+    verify(mockBackend, never()).deleteFile(any());
+    verifyNoMoreInteractions(mockSharedFileManager);
+
+    // DAY 1 : firstGroup becomes stale and should expire in 2 days.
+    now = new Calendar.Builder().setDate(2018, Calendar.MARCH, 21).build();
+    testClock.set(now.getTimeInMillis());
+
+    Calendar firstStaleExpiration =
+        new Calendar.Builder().setDate(2018, Calendar.MARCH, 23).build();
+    long firstStaleExpirationSecs = firstStaleExpiration.getTimeInMillis() / 1000;
+    firstGroup
+        .setBookkeeping(
+            DataFileGroupBookkeeping.newBuilder()
+                .setStaleExpirationDate(firstStaleExpirationSecs)
+                .build())
+        .setStaleLifetimeSecs(firstStaleExpirationSecs - (now.getTimeInMillis() / 1000));
+
+    when(mockFileGroupsMetadata.getAllFreshGroups())
+        .thenReturn(Futures.immediateFuture(ImmutableList.of()));
+
+    fileGroupsMetadataStaleGroups.set(ImmutableList.of(firstGroup.build()));
+
+    expirationHandler.updateExpiration().get();
+
+    verify(mockFileGroupsMetadata, times(6)).getAllFreshGroups();
+    verify(mockFileGroupsMetadata, times(4)).getAllStaleGroups();
+    verify(mockFileGroupsMetadata, times(2)).removeAllGroupsWithKeys(ImmutableList.of());
+    verify(mockFileGroupsMetadata, times(2)).removeAllStaleGroups();
+    verify(mockFileGroupsMetadata).writeStaleGroups(ImmutableList.of(firstGroup.build()));
+    verifyNoMoreInteractions(mockFileGroupsMetadata);
+    verify(mockSharedFileManager, times(2)).getOnDeviceUri(fileKey);
+    verify(mockSharedFilesMetadata, times(2)).getAllFileKeys();
+    verifyNoMoreInteractions(mockSharedFileManager);
+    verify(mockBackend, times(2)).exists(baseDownloadDirectoryUri);
+    verify(mockBackend, times(2)).children(baseDownloadDirectoryUri);
+    verify(mockBackend, times(2)).isDirectory(dirFor1p);
+    verify(mockBackend, never()).deleteFile(any());
+
+    // DAY 2 : secondGroup arrives and requests the shared file.
+    now = new Calendar.Builder().setDate(2018, Calendar.MARCH, 22).build();
+    testClock.set(now.getTimeInMillis());
+
+    Calendar secondExpiration = new Calendar.Builder().setDate(2018, Calendar.APRIL, 22).build();
+    long secondExpirationSecs = secondExpiration.getTimeInMillis() / 1000;
+    DataFileGroupInternal secondGroup =
+        DataFileGroupInternal.newBuilder()
+            .setGroupName(TEST_GROUP_2)
+            .addFile(dataFile)
+            .setAllowedReadersEnum(AllowedReaders.ONLY_GOOGLE_PLAY_SERVICES)
+            .setExpirationDateSecs(secondExpirationSecs)
+            .build();
+
+    groups = Arrays.asList(Pair.create(TEST_KEY_2, secondGroup));
+    when(mockFileGroupsMetadata.getAllFreshGroups()).thenReturn(Futures.immediateFuture(groups));
+
+    expirationHandler.updateExpiration().get();
+
+    verify(mockFileGroupsMetadata, times(9)).getAllFreshGroups();
+    verify(mockFileGroupsMetadata, times(6)).getAllStaleGroups();
+    verify(mockFileGroupsMetadata, times(3)).removeAllGroupsWithKeys(ImmutableList.of());
+    verify(mockFileGroupsMetadata, times(3)).removeAllStaleGroups();
+    verify(mockFileGroupsMetadata, times(2)).writeStaleGroups(ImmutableList.of(firstGroup.build()));
+    verifyNoMoreInteractions(mockFileGroupsMetadata);
+    verify(mockSharedFileManager, times(3)).getOnDeviceUri(fileKey);
+    verify(mockSharedFilesMetadata, times(3)).getAllFileKeys();
+    verifyNoMoreInteractions(mockSharedFileManager);
+    verify(mockBackend, times(3)).exists(baseDownloadDirectoryUri);
+    verify(mockBackend, times(3)).children(baseDownloadDirectoryUri);
+    verify(mockBackend, times(3)).isDirectory(dirFor0p);
+    verify(mockBackend, never()).deleteFile(any());
+
+    // DAY 3 : the firstGroup expires
+    now = new Calendar.Builder().setDate(2018, Calendar.MARCH, 22).build();
+    testClock.set(now.getTimeInMillis());
+
+    expirationHandler.updateExpiration().get();
+
+    verify(mockFileGroupsMetadata, times(12)).getAllFreshGroups();
+    verify(mockFileGroupsMetadata, times(8)).getAllStaleGroups();
+    verify(mockFileGroupsMetadata, times(4)).removeAllGroupsWithKeys(ImmutableList.of());
+    verify(mockFileGroupsMetadata, times(4)).removeAllStaleGroups();
+    verify(mockFileGroupsMetadata, times(3)).writeStaleGroups(ImmutableList.of(firstGroup.build()));
+    verifyNoMoreInteractions(mockFileGroupsMetadata);
+    verify(mockSharedFileManager, times(4)).getOnDeviceUri(fileKey);
+    verify(mockSharedFilesMetadata, times(4)).getAllFileKeys();
+    verifyNoMoreInteractions(mockSharedFileManager);
+    verify(mockBackend, times(4)).exists(baseDownloadDirectoryUri);
+    verify(mockBackend, times(4)).children(baseDownloadDirectoryUri);
+    verify(mockBackend, times(4)).isDirectory(dirForAll);
+    verify(mockBackend, never()).deleteFile(any());
+  }
+
+  @Test
+  public void updateExpiration_expiredGroups_withAndroidSharedFile() throws Exception {
+    setupForAndroidShared();
+    // Current time
+    Calendar now = new Calendar.Builder().setDate(2020, Calendar.MARCH, 20).build();
+    testClock.set(now.getTimeInMillis());
+
+    DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP_1, 1);
+    // Time when the group expires
+    Calendar earlier = new Calendar.Builder().setDate(2018, Calendar.MARCH, 20).build();
+    long nowTimeSecs = earlier.getTimeInMillis() / 1000;
+    dataFileGroup = dataFileGroup.toBuilder().setExpirationDateSecs(nowTimeSecs).build();
+    NewFileKey[] fileKeys = MddTestUtil.createFileKeysForDataFileGroupInternal(dataFileGroup);
+    String androidSharingChecksum = "sha256_" + dataFileGroup.getFile(0).getChecksum();
+    Uri blobUri = DirectoryUtil.getBlobUri(context, androidSharingChecksum);
+
+    assertThat(sharedFileManager.reserveFileEntry(fileKeys[0]).get()).isTrue();
+    assertThat(
+            sharedFileManager
+                .setAndroidSharedDownloadedFileEntry(
+                    fileKeys[0], androidSharingChecksum, nowTimeSecs)
+                .get())
+        .isTrue();
+    assertThat(fileGroupsMetadata.write(TEST_KEY_1, dataFileGroup).get()).isTrue();
+
+    expirationHandlerNoMocks.updateExpiration().get();
+
+    verify(mockBlobStoreBackend).deleteFile(blobUri);
+    assertThat(sharedFilesMetadata.read(fileKeys[0]).get()).isNull();
+    assertThat(fileGroupsMetadata.read(TEST_KEY_1).get()).isNull();
+  }
+
+  @Test
+  public void updateExpiration_expiredGroups_withAndroidSharedFile_releaseLeaseFails()
+      throws Exception {
+    setupForAndroidShared();
+    // Current time
+    Calendar now = new Calendar.Builder().setDate(2020, Calendar.MARCH, 20).build();
+    testClock.set(now.getTimeInMillis());
+
+    DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP_1, 1);
+    // Time when the group expires
+    Calendar earlier = new Calendar.Builder().setDate(2018, Calendar.MARCH, 20).build();
+    long nowTimeSecs = earlier.getTimeInMillis() / 1000;
+    dataFileGroup = dataFileGroup.toBuilder().setExpirationDateSecs(nowTimeSecs).build();
+    NewFileKey[] fileKeys = MddTestUtil.createFileKeysForDataFileGroupInternal(dataFileGroup);
+    String androidSharingChecksum = "sha256_" + dataFileGroup.getFile(0).getChecksum();
+    Uri blobUri = DirectoryUtil.getBlobUri(context, androidSharingChecksum);
+
+    doThrow(new IOException()).when(mockBlobStoreBackend).deleteFile(blobUri);
+
+    assertThat(sharedFileManager.reserveFileEntry(fileKeys[0]).get()).isTrue();
+    assertThat(
+            sharedFileManager
+                .setAndroidSharedDownloadedFileEntry(
+                    fileKeys[0], androidSharingChecksum, nowTimeSecs)
+                .get())
+        .isTrue();
+    assertThat(fileGroupsMetadata.write(TEST_KEY_1, dataFileGroup).get()).isTrue();
+
+    expirationHandlerNoMocks.updateExpiration().get();
+
+    verify(mockBlobStoreBackend).deleteFile(blobUri);
+    assertThat(sharedFilesMetadata.read(fileKeys[0]).get()).isNull();
+    assertThat(fileGroupsMetadata.read(TEST_KEY_1).get()).isNull();
+  }
+
+  @Test
+  public void updateExpiration_expiredGroups_withAndroidSharedAndNotAndroidSharedFiles()
+      throws Exception {
+    setupForAndroidShared();
+    Calendar now = new Calendar.Builder().setDate(2018, Calendar.MARCH, 20).build();
+    testClock.set(now.getTimeInMillis());
+
+    DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP_1, 2);
+    long nowTimeSecs = now.getTimeInMillis() / 1000;
+    dataFileGroup = dataFileGroup.toBuilder().setExpirationDateSecs(nowTimeSecs).build();
+    NewFileKey[] fileKeys = MddTestUtil.createFileKeysForDataFileGroupInternal(dataFileGroup);
+    String androidSharingChecksum = "sha256_" + dataFileGroup.getFile(0).getChecksum();
+    Uri blobUri = DirectoryUtil.getBlobUri(context, androidSharingChecksum);
+
+    assertThat(sharedFileManager.reserveFileEntry(fileKeys[0]).get()).isTrue();
+    assertThat(sharedFileManager.reserveFileEntry(fileKeys[1]).get()).isTrue();
+    assertThat(
+            sharedFileManager
+                .setAndroidSharedDownloadedFileEntry(
+                    fileKeys[0], androidSharingChecksum, nowTimeSecs)
+                .get())
+        .isTrue();
+    assertThat(fileGroupsMetadata.write(TEST_KEY_1, dataFileGroup).get()).isTrue();
+
+    when(mockBackend.children(dirFor0p)).thenReturn(Arrays.asList(testUri2));
+
+    expirationHandlerNoMocks.updateExpiration().get();
+
+    verify(mockBackend).deleteFile(testUri2);
+    verify(mockBlobStoreBackend).deleteFile(blobUri);
+    assertThat(sharedFilesMetadata.read(fileKeys[0]).get()).isNull();
+    assertThat(sharedFilesMetadata.read(fileKeys[1]).get()).isNull();
+    assertThat(fileGroupsMetadata.read(TEST_KEY_1).get()).isNull();
+  }
+
+  @Test
+  public void updateExpiration_noExpiredAndroidSharedGroup_withUnaccountedFile() throws Exception {
+    setupForAndroidShared();
+    Calendar now = new Calendar.Builder().setDate(2018, Calendar.MARCH, 20).build();
+    testClock.set(now.getTimeInMillis());
+
+    DataFileGroupInternal dataFileGroup =
+        MddTestUtil.createSharedDataFileGroupInternal(TEST_GROUP_1, 1);
+    // No changes to dataFileGroup
+    NewFileKey[] fileKeys = MddTestUtil.createFileKeysForDataFileGroupInternal(dataFileGroup);
+    String androidSharingChecksum = dataFileGroup.getFile(0).getAndroidSharingChecksum();
+    Uri blobUri = DirectoryUtil.getBlobUri(context, androidSharingChecksum);
+
+    assertThat(sharedFileManager.reserveFileEntry(fileKeys[0]).get()).isTrue();
+    assertThat(
+            sharedFileManager
+                .setAndroidSharedDownloadedFileEntry(fileKeys[0], androidSharingChecksum, 0)
+                .get())
+        .isTrue();
+    assertThat(fileGroupsMetadata.write(TEST_KEY_1, dataFileGroup).get()).isTrue();
+
+    // Unaccounted file
+    when(mockBackend.children(dirFor0p)).thenReturn(Arrays.asList(tempTestUri2));
+
+    expirationHandlerNoMocks.updateExpiration().get();
+
+    assertThat(fileGroupsMetadata.read(TEST_KEY_1).get()).isNotNull();
+    verify(mockBlobStoreBackend, never()).deleteFile(blobUri);
+    verify(mockBackend).deleteFile(tempTestUri2);
+  }
+
+  @Test
+  public void updateExpiration_expiredGroups_withIsolatedStructure() throws Exception {
+    setupIsolatedSymlinkStructure();
+
+    // Current time
+    Calendar now = new Calendar.Builder().setDate(2020, Calendar.MARCH, 20).build();
+    testClock.set(now.getTimeInMillis());
+
+    DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP_1, 1);
+    // Time when the group expires
+    Calendar earlier = new Calendar.Builder().setDate(2018, Calendar.MARCH, 20).build();
+    long earlierTimeSecs = earlier.getTimeInMillis() / 1000;
+    dataFileGroup =
+        dataFileGroup.toBuilder()
+            .setExpirationDateSecs(earlierTimeSecs)
+            .setPreserveFilenamesAndIsolateFiles(true)
+            .build();
+
+    NewFileKey[] fileKeys = MddTestUtil.createFileKeysForDataFileGroupInternal(dataFileGroup);
+
+    List<Pair<GroupKey, DataFileGroupInternal>> groups =
+        Arrays.asList(Pair.create(TEST_KEY_1, dataFileGroup));
+    when(mockFileGroupsMetadata.getAllFreshGroups())
+        .thenReturn(Futures.immediateFuture(groups))
+        .thenReturn(Futures.immediateFuture(new ArrayList<>()));
+    when(mockSharedFileManager.getFileStatus(fileKeys[0]))
+        .thenReturn(Futures.immediateFuture(FileStatus.DOWNLOAD_COMPLETE));
+    when(mockSharedFileManager.getOnDeviceUri(fileKeys[0]))
+        .thenReturn(Futures.immediateFuture(testUri1));
+
+    when(mockSharedFilesMetadata.getAllFileKeys())
+        .thenReturn(Futures.immediateFuture(Arrays.asList(fileKeys)));
+    when(mockBackend.children(dirFor0p)).thenReturn(Arrays.asList(testUri1));
+    expirationHandler.updateExpiration().get();
+
+    verify(mockFileGroupsMetadata, times(3)).getAllFreshGroups();
+    verify(mockFileGroupsMetadata, times(2)).getAllStaleGroups();
+    verify(mockFileGroupsMetadata).removeAllGroupsWithKeys(Arrays.asList(TEST_KEY_1));
+    verify(mockFileGroupsMetadata).removeAllStaleGroups();
+    verify(mockFileGroupsMetadata).writeStaleGroups(ImmutableList.of());
+    verifyNoMoreInteractions(mockFileGroupsMetadata);
+    verify(mockSharedFilesMetadata).getAllFileKeys();
+    verify(mockSharedFileManager).removeFileEntry(fileKeys[0]);
+    verify(mockBackend).exists(baseDownloadDirectoryUri);
+    verify(mockBackend).children(baseDownloadDirectoryUri);
+    verify(mockBackend).isDirectory(testUri1);
+    verify(mockBackend).deleteFile(testUri1);
+    verify(mockBackend, times(2)).exists(symlinkDirForGroup1);
+    verify(mockBackend, times(2)).isDirectory(symlinkDirForGroup1);
+    verify(mockBackend).deleteDirectory(symlinkDirForGroup1);
+    verifyNoMoreInteractions(mockSharedFileManager);
+  }
+
+  @Test
+  public void updateExpiration_noExpiredGroups_doesNotRemoveIsolatedStructure() throws Exception {
+    // Create group that has isolated structure
+    DataFileGroupInternal dataFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP_1, 1).toBuilder()
+            .setPreserveFilenamesAndIsolateFiles(true)
+            .build();
+
+    NewFileKey[] fileKeys = MddTestUtil.createFileKeysForDataFileGroupInternal(dataFileGroup);
+
+    // Setup mocks to return our fresh group
+    List<Pair<GroupKey, DataFileGroupInternal>> groups =
+        Arrays.asList(Pair.create(TEST_KEY_1, dataFileGroup));
+    when(mockFileGroupsMetadata.getAllFreshGroups()).thenReturn(Futures.immediateFuture(groups));
+    when(mockSharedFileManager.getFileStatus(fileKeys[0]))
+        .thenReturn(Futures.immediateFuture(FileStatus.DOWNLOAD_COMPLETE));
+    when(mockSharedFileManager.getOnDeviceUri(fileKeys[0]))
+        .thenReturn(Futures.immediateFuture(testUri1));
+    when(mockSharedFilesMetadata.getAllFileKeys())
+        .thenReturn(Futures.immediateFuture(Arrays.asList(fileKeys)));
+
+    expirationHandler.updateExpiration().get();
+
+    // Verify file is not deleted
+    verify(mockBackend, never()).deleteFile(testUri1);
+
+    // Verify symlinks are not considered for deletion:
+    verify(mockBackend, never()).exists(symlinkDirForGroup1);
+    verify(mockBackend, never()).isDirectory(symlinkDirForGroup1);
+    verify(mockBackend, never()).deleteDirectory(symlinkDirForGroup1);
+    verify(mockBackend, never()).exists(symlinkForUri1);
+    verify(mockBackend, never()).isDirectory(symlinkForUri1);
+    verify(mockBackend, never()).deleteFile(symlinkForUri1);
+  }
+
+  @Test
+  public void updateExpiration_expiredStaleGroup_withIsolatedStructure_deletesFiles()
+      throws Exception {
+    setupIsolatedSymlinkStructure();
+
+    Calendar now = new Calendar.Builder().setDate(2018, Calendar.MARCH, 20).build();
+    Calendar later = new Calendar.Builder().setDate(2018, Calendar.MARCH, 22).build();
+    testClock.set(now.getTimeInMillis());
+    long nowTimeSecs = now.getTimeInMillis() / 1000;
+    DataFileGroupInternal dataFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP_1, 1).toBuilder()
+            .setBookkeeping(
+                DataFileGroupBookkeeping.newBuilder()
+                    .setStaleExpirationDate(later.getTimeInMillis() / 1000)
+                    .build())
+            .setExpirationDateSecs(nowTimeSecs)
+            .setPreserveFilenamesAndIsolateFiles(true)
+            .build();
+    NewFileKey[] fileKeys = MddTestUtil.createFileKeysForDataFileGroupInternal(dataFileGroup);
+
+    fileGroupsMetadataStaleGroups.set(ImmutableList.of(dataFileGroup));
+
+    when(mockSharedFileManager.getFileStatus(fileKeys[0]))
+        .thenReturn(Futures.immediateFuture(FileStatus.DOWNLOAD_COMPLETE));
+    when(mockSharedFileManager.getOnDeviceUri(fileKeys[0]))
+        .thenReturn(Futures.immediateFuture(testDirUri1));
+    when(mockSharedFilesMetadata.getAllFileKeys())
+        .thenReturn(Futures.immediateFuture(Arrays.asList(fileKeys)));
+    when(mockBackend.children(dirFor1p)).thenReturn(Arrays.asList(testDirUri1, testUri2));
+    when(mockBackend.children(testDirUri1))
+        .thenReturn(Arrays.asList(testDirFileUri1, testDirFileUri2));
+
+    expirationHandler.updateExpiration().get();
+
+    verify(mockFileGroupsMetadata, times(3)).getAllFreshGroups();
+    verify(mockFileGroupsMetadata, times(2)).getAllStaleGroups();
+    verify(mockFileGroupsMetadata).removeAllGroupsWithKeys(ImmutableList.of());
+    verify(mockFileGroupsMetadata).removeAllStaleGroups();
+    verify(mockFileGroupsMetadata).writeStaleGroups(ImmutableList.of());
+    verifyNoMoreInteractions(mockFileGroupsMetadata);
+    verify(mockSharedFilesMetadata).getAllFileKeys();
+    verify(mockSharedFileManager).removeFileEntry(fileKeys[0]);
+    verifyNoMoreInteractions(mockSharedFileManager);
+    verify(mockBackend).exists(baseDownloadDirectoryUri);
+    verify(mockBackend).children(baseDownloadDirectoryUri);
+    verify(mockBackend).isDirectory(testDirUri1);
+    verify(mockBackend).isDirectory(testDirFileUri1);
+    verify(mockBackend).isDirectory(testDirFileUri2);
+    verify(mockBackend, times(2)).exists(symlinkDirForGroup1);
+    verify(mockBackend, times(2)).isDirectory(symlinkDirForGroup1);
+    verify(mockBackend).deleteDirectory(symlinkDirForGroup1);
+    verifyNoMoreInteractions(mockSharedFileManager);
+  }
+
+  @Test
+  public void updateExpiration_noExpiredGroups_removesUnaccountedIsolatedFileUri()
+      throws Exception {
+    setupIsolatedSymlinkStructure();
+
+    DataFileGroupInternal isolatedGroup1 =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP_1, 1).toBuilder()
+            .setPreserveFilenamesAndIsolateFiles(true)
+            .build();
+    NewFileKey[] fileKeys = MddTestUtil.createFileKeysForDataFileGroupInternal(isolatedGroup1);
+
+    List<Pair<GroupKey, DataFileGroupInternal>> groups =
+        Arrays.asList(Pair.create(TEST_KEY_1, isolatedGroup1));
+    when(mockFileGroupsMetadata.getAllFreshGroups()).thenReturn(Futures.immediateFuture(groups));
+    when(mockSharedFileManager.getFileStatus(fileKeys[0]))
+        .thenReturn(Futures.immediateFuture(FileStatus.DOWNLOAD_COMPLETE));
+    when(mockSharedFileManager.getOnDeviceUri(fileKeys[0]))
+        .thenReturn(Futures.immediateFuture(testUri1));
+
+    when(mockSharedFilesMetadata.getAllFileKeys())
+        .thenReturn(Futures.immediateFuture(Arrays.asList(fileKeys)));
+
+    expirationHandler.updateExpiration().get();
+
+    // Verify only the unaccounted isolated file uri is deleted.
+    verify(mockBackend).deleteFile(symlinkForUri2);
+    verify(mockBackend, never()).deleteFile(symlinkForUri1);
+  }
+
+  // TODO(b/115659980): consider moving this to a public utility class in the File Library
+  private void setUpFileMock(Uri uri, long size) throws Exception {
+    when(mockBackend.exists(uri)).thenReturn(true);
+    when(mockBackend.isDirectory(uri)).thenReturn(false);
+    when(mockBackend.fileSize(uri)).thenReturn(size);
+  }
+
+  // TODO(b/115659980): consider moving this to a public utility class in the File Library
+  private void setUpDirectoryMock(Uri uri, List<Uri> children) throws Exception {
+    when(mockBackend.exists(uri)).thenReturn(true);
+    when(mockBackend.isDirectory(uri)).thenReturn(true);
+    when(mockBackend.children(uri)).thenReturn(children);
+  }
+
+  private NewFileKey[] createFileKeysUseChecksumOnly(DataFileGroupInternal group) {
+    NewFileKey[] newFileKeys = new NewFileKey[group.getFileCount()];
+    for (int i = 0; i < group.getFileCount(); ++i) {
+      newFileKeys[i] =
+          SharedFilesMetadata.createKeyFromDataFileForCurrentVersion(
+              context, group.getFile(i), group.getAllowedReadersEnum(), mockSilentFeedback);
+    }
+    return newFileKeys;
+  }
+
+  private void setupIsolatedSymlinkStructure() throws Exception {
+    setUpDirectoryMock(
+        baseDownloadDirectoryUri,
+        Arrays.asList(dirForAll, dirFor1p, dirFor0p, baseDownloadSymlinkDirectoryUri));
+    setUpDirectoryMock(
+        baseDownloadSymlinkDirectoryUri,
+        ImmutableList.of(symlinkDirForGroup1, symlinkDirForGroup2));
+    setUpDirectoryMock(symlinkDirForGroup1, ImmutableList.of(symlinkForUri1));
+    setUpDirectoryMock(symlinkDirForGroup2, ImmutableList.of(symlinkForUri2));
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/internal/FileGroupManagerTest.java b/javatests/com/google/android/libraries/mobiledatadownload/internal/FileGroupManagerTest.java
new file mode 100644
index 0000000..067bc81
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/internal/FileGroupManagerTest.java
@@ -0,0 +1,6274 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal;
+
+import static com.google.android.libraries.mobiledatadownload.internal.MddTestUtil.writeSharedFiles;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static org.junit.Assert.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isA;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+import android.accounts.Account;
+import android.content.Context;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.net.Uri;
+import android.os.Build;
+import android.util.Pair;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.android.libraries.mobiledatadownload.AccountSource;
+import com.google.android.libraries.mobiledatadownload.AggregateException;
+import com.google.android.libraries.mobiledatadownload.DownloadException;
+import com.google.android.libraries.mobiledatadownload.DownloadException.DownloadResultCode;
+import com.google.android.libraries.mobiledatadownload.FileSource;
+import com.google.android.libraries.mobiledatadownload.SilentFeedback;
+import com.google.android.libraries.mobiledatadownload.account.AccountUtil;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.AndroidFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.common.LimitExceededException;
+import com.google.android.libraries.mobiledatadownload.file.spi.Backend;
+import com.google.android.libraries.mobiledatadownload.internal.FileGroupManager.GroupDownloadStatus;
+import com.google.android.libraries.mobiledatadownload.internal.downloader.DownloaderCallbackImpl;
+import com.google.android.libraries.mobiledatadownload.internal.downloader.MddFileDownloader;
+import com.google.android.libraries.mobiledatadownload.internal.experimentation.DownloadStageManager;
+import com.google.android.libraries.mobiledatadownload.internal.experimentation.NoOpDownloadStageManager;
+import com.google.android.libraries.mobiledatadownload.internal.logging.EventLogger;
+import com.google.android.libraries.mobiledatadownload.internal.util.DirectoryUtil;
+import com.google.android.libraries.mobiledatadownload.internal.util.FileGroupUtil;
+import com.google.android.libraries.mobiledatadownload.monitor.DownloadProgressMonitor;
+import com.google.android.libraries.mobiledatadownload.testing.FakeTimeSource;
+import com.google.android.libraries.mobiledatadownload.testing.TestFlags;
+import com.google.common.base.Optional;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
+import com.google.common.labs.concurrent.LabsFutures;
+import com.google.common.truth.Correspondence;
+import com.google.common.util.concurrent.AsyncFunction;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFile;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupBookkeeping;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal.AllowedReaders;
+import com.google.mobiledatadownload.internal.MetadataProto.DownloadConditions;
+import com.google.mobiledatadownload.internal.MetadataProto.DownloadConditions.ActivatingCondition;
+import com.google.mobiledatadownload.internal.MetadataProto.DownloadConditions.DeviceNetworkPolicy;
+import com.google.mobiledatadownload.internal.MetadataProto.DownloadConditions.DeviceStoragePolicy;
+import com.google.mobiledatadownload.internal.MetadataProto.ExtraHttpHeader;
+import com.google.mobiledatadownload.internal.MetadataProto.FileStatus;
+import com.google.mobiledatadownload.internal.MetadataProto.GroupKey;
+import com.google.mobiledatadownload.internal.MetadataProto.NewFileKey;
+import com.google.mobiledatadownload.internal.MetadataProto.SharedFile;
+import com.google.protobuf.Any;
+import com.google.protobuf.ByteString;
+import com.google.protobuf.ExtensionRegistryLite;
+import com.google.protobuf.StringValue;
+import java.io.Closeable;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Calendar;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.mockito.stubbing.Answer;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.Shadows;
+import org.robolectric.util.ReflectionHelpers;
+
+@RunWith(RobolectricTestRunner.class)
+public class FileGroupManagerTest {
+
+  private static final long CURRENT_TIMESTAMP = 1000;
+
+  private static final int TRAFFIC_TAG = 1000;
+
+  private static final Executor SEQUENTIAL_CONTROL_EXECUTOR =
+      Executors.newSingleThreadScheduledExecutor();
+
+  private static final String TEST_GROUP = "test-group";
+  private static final String TEST_GROUP_2 = "test-group-2";
+  private static final String TEST_GROUP_3 = "test-group-3";
+  private static final String TEST_GROUP_4 = "test-group-4";
+  private static final long FILE_GROUP_EXPIRATION_DATE_SECS = 10;
+  private static final String HOST_APP_LOG_SOURCE = "HOST_APP_LOG_SOURCE";
+  private static final String HOST_APP_PRIMES_LOG_SOURCE = "HOST_APP_PRIMES_LOG_SOURCE";
+
+  private static final Correspondence<GroupKey, String> GROUP_KEY_TO_VARIANT =
+      Correspondence.transforming(GroupKey::getVariantId, "using variant");
+  private static final Correspondence<Pair<GroupKey, DataFileGroupInternal>, Pair<String, String>>
+      KEY_GROUP_PAIR_TO_VARIANT_PAIR =
+          Correspondence.transforming(
+              keyGroupPair ->
+                  Pair.create(
+                      keyGroupPair.first.getVariantId(), keyGroupPair.second.getVariantId()),
+              "using variants from group key and file group");
+
+  private static GroupKey testKey;
+  private static GroupKey testKey2;
+  private static GroupKey testKey3;
+  private static GroupKey testKey4;
+
+  private Context context;
+  private FileGroupManager fileGroupManager;
+  private FileGroupsMetadata fileGroupsMetadata;
+  private SharedFileManager sharedFileManager;
+  private SharedFilesMetadata sharedFilesMetadata;
+  private FakeTimeSource testClock;
+  private SynchronousFileStorage fileStorage;
+  public File publicDirectory;
+  private final TestFlags flags = new TestFlags();
+  @Rule public TemporaryFolder folder = new TemporaryFolder();
+  @Rule public final MockitoRule mocks = MockitoJUnit.rule();
+
+  @Mock EventLogger mockLogger;
+  @Mock SilentFeedback mockSilentFeedback;
+  @Mock MddFileDownloader mockDownloader;
+  @Mock SharedFileManager mockSharedFileManager;
+  @Mock FileGroupsMetadata mockFileGroupsMetadata;
+  @Mock DownloadProgressMonitor mockDownloadMonitor;
+  @Mock AccountSource mockAccountSource;
+  @Mock Backend mockBackend;
+  @Mock Closeable closeable;
+
+  @Captor ArgumentCaptor<FileSource> fileSourceCaptor;
+  @Captor ArgumentCaptor<GroupKey> groupKeyCaptor;
+  @Captor ArgumentCaptor<List<GroupKey>> groupKeysCaptor;
+
+  private DownloadStageManager downloadStageManager;
+
+  @Before
+  public void setUp() throws Exception {
+    context = ApplicationProvider.getApplicationContext();
+
+    when(mockBackend.name()).thenReturn("blobstore");
+    fileStorage =
+        new SynchronousFileStorage(
+            Arrays.asList(AndroidFileBackend.builder(context).build(), mockBackend));
+
+    testClock = new FakeTimeSource().set(CURRENT_TIMESTAMP);
+
+    testKey =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setOwnerPackage(context.getPackageName())
+            .build();
+    testKey2 =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP_2)
+            .setOwnerPackage(context.getPackageName())
+            .build();
+    testKey3 =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP_3)
+            .setOwnerPackage(context.getPackageName())
+            .build();
+    testKey4 =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP_4)
+            .setOwnerPackage(context.getPackageName())
+            .build();
+
+    fileGroupsMetadata =
+        new SharedPreferencesFileGroupsMetadata(
+            context,
+            testClock,
+            mockSilentFeedback,
+            Optional.absent(),
+            MoreExecutors.directExecutor());
+    sharedFilesMetadata =
+        new SharedPreferencesSharedFilesMetadata(
+            context, mockSilentFeedback, Optional.absent(), flags);
+    sharedFileManager =
+        new SharedFileManager(
+            context,
+            mockSilentFeedback,
+            sharedFilesMetadata,
+            fileStorage,
+            mockDownloader,
+            Optional.absent(),
+            Optional.of(mockDownloadMonitor),
+            mockLogger,
+            flags,
+            fileGroupsMetadata,
+            Optional.absent(),
+            MoreExecutors.directExecutor());
+
+    downloadStageManager = new NoOpDownloadStageManager();
+
+    fileGroupManager =
+        new FileGroupManager(
+            context,
+            mockLogger,
+            mockSilentFeedback,
+            fileGroupsMetadata,
+            sharedFileManager,
+            testClock,
+            Optional.of(mockAccountSource),
+            SEQUENTIAL_CONTROL_EXECUTOR,
+            Optional.absent(),
+            fileStorage,
+            downloadStageManager,
+            flags);
+    // TODO(b/117571083): Replace with fileStorage API.
+    File downloadDirectory =
+        new File(context.getFilesDir(), DirectoryUtil.MDD_STORAGE_MODULE + "/" + "shared");
+    publicDirectory = new File(downloadDirectory, DirectoryUtil.MDD_STORAGE_ALL_GOOGLE_APPS);
+    publicDirectory.mkdirs();
+
+    // file sharing is available for SDK R+
+    ReflectionHelpers.setStaticField(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.R);
+  }
+
+  @Test
+  public void testAddGroupForDownload() throws Exception {
+    DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+    NewFileKey[] groupKeys = MddTestUtil.createFileKeysForDataFileGroupInternal(dataFileGroup);
+
+    assertThat(fileGroupManager.addGroupForDownload(testKey, dataFileGroup).get()).isTrue();
+    verifyAddGroupForDownloadWritesMetadata(testKey, dataFileGroup, CURRENT_TIMESTAMP);
+
+    // Check that downloaded file groups doesn't contain this file group.
+    assertThat(readDownloadedFileGroup(testKey)).isNull();
+
+    assertThat(sharedFileManager.getSharedFile(groupKeys[0]).get()).isNotNull();
+    assertThat(sharedFileManager.getSharedFile(groupKeys[1]).get()).isNotNull();
+  }
+
+  @Test
+  public void testAddGroupForDownload_correctlyPopulatesBuildIdAndVariantId() throws Exception {
+    DataFileGroupInternal dataFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2).toBuilder()
+            .setBuildId(10)
+            .setVariantId("testVariant")
+            .build();
+    NewFileKey[] groupKeys = MddTestUtil.createFileKeysForDataFileGroupInternal(dataFileGroup);
+
+    assertThat(fileGroupManager.addGroupForDownload(testKey, dataFileGroup).get()).isTrue();
+    verifyAddGroupForDownloadWritesMetadata(testKey, dataFileGroup, CURRENT_TIMESTAMP);
+
+    // Check that downloaded file groups doesn't contain this file group.
+    assertThat(readDownloadedFileGroup(testKey)).isNull();
+
+    assertThat(sharedFileManager.getSharedFile(groupKeys[0]).get()).isNotNull();
+    assertThat(sharedFileManager.getSharedFile(groupKeys[1]).get()).isNotNull();
+  }
+
+  @Test
+  public void testAddGroupForDownload_groupUpdated() throws Exception {
+    DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+    assertThat(fileGroupManager.addGroupForDownload(testKey, dataFileGroup).get()).isTrue();
+    verifyAddGroupForDownloadWritesMetadata(testKey, dataFileGroup, CURRENT_TIMESTAMP);
+
+    // Update the file id and see that the group gets updated in the pending groups list.
+    dataFileGroup =
+        dataFileGroup.toBuilder()
+            .setFile(0, dataFileGroup.getFile(0).toBuilder().setFileId("file2"))
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .build();
+    assertThat(fileGroupManager.addGroupForDownload(testKey, dataFileGroup).get()).isTrue();
+    verifyAddGroupForDownloadWritesMetadata(testKey, dataFileGroup, CURRENT_TIMESTAMP);
+
+    // Update other parameters and check that we successfully add the group.
+    dataFileGroup = dataFileGroup.toBuilder().setFileGroupVersionNumber(2).build();
+    assertThat(fileGroupManager.addGroupForDownload(testKey, dataFileGroup).get()).isTrue();
+    verifyAddGroupForDownloadWritesMetadata(testKey, dataFileGroup, CURRENT_TIMESTAMP);
+
+    dataFileGroup = dataFileGroup.toBuilder().setStaleLifetimeSecs(50).build();
+    assertThat(fileGroupManager.addGroupForDownload(testKey, dataFileGroup).get()).isTrue();
+    verifyAddGroupForDownloadWritesMetadata(testKey, dataFileGroup, CURRENT_TIMESTAMP);
+
+    dataFileGroup =
+        dataFileGroup.toBuilder()
+            .setDownloadConditions(
+                DownloadConditions.newBuilder()
+                    .setDeviceNetworkPolicy(
+                        DeviceNetworkPolicy.DOWNLOAD_FIRST_ON_WIFI_THEN_ON_ANY_NETWORK))
+            .build();
+    assertThat(fileGroupManager.addGroupForDownload(testKey, dataFileGroup).get()).isTrue();
+    verifyAddGroupForDownloadWritesMetadata(testKey, dataFileGroup, CURRENT_TIMESTAMP);
+
+    DownloadConditions downloadConditions =
+        DownloadConditions.newBuilder()
+            .setDeviceStoragePolicy(DeviceStoragePolicy.BLOCK_DOWNLOAD_LOWER_THRESHOLD)
+            .build();
+    dataFileGroup = dataFileGroup.toBuilder().setDownloadConditions(downloadConditions).build();
+    assertThat(fileGroupManager.addGroupForDownload(testKey, dataFileGroup).get()).isTrue();
+    verifyAddGroupForDownloadWritesMetadata(testKey, dataFileGroup, CURRENT_TIMESTAMP);
+
+    dataFileGroup =
+        dataFileGroup.toBuilder()
+            .setAllowedReadersEnum(AllowedReaders.ONLY_GOOGLE_PLAY_SERVICES)
+            .build();
+    assertThat(fileGroupManager.addGroupForDownload(testKey, dataFileGroup).get()).isTrue();
+    verifyAddGroupForDownloadWritesMetadata(testKey, dataFileGroup, CURRENT_TIMESTAMP);
+  }
+
+  @Test
+  public void testAddGroupForDownload_groupUpdated_whenBuildChanges() throws Exception {
+    DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+    assertThat(fileGroupManager.addGroupForDownload(testKey, dataFileGroup).get()).isTrue();
+    verifyAddGroupForDownloadWritesMetadata(testKey, dataFileGroup, CURRENT_TIMESTAMP);
+
+    // Update the file id and see that the group gets updated in the pending groups list.
+    dataFileGroup = dataFileGroup.toBuilder().setBuildId(123456789L).build();
+    assertThat(fileGroupManager.addGroupForDownload(testKey, dataFileGroup).get()).isTrue();
+    verifyAddGroupForDownloadWritesMetadata(testKey, dataFileGroup, CURRENT_TIMESTAMP);
+  }
+
+  @Test
+  public void testAddGroupForDownload_groupUpdated_whenVariantChanges() throws Exception {
+    DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+    assertThat(fileGroupManager.addGroupForDownload(testKey, dataFileGroup).get()).isTrue();
+    verifyAddGroupForDownloadWritesMetadata(testKey, dataFileGroup, CURRENT_TIMESTAMP);
+
+    // Update the file id and see that the group gets updated in the pending groups list.
+    dataFileGroup = dataFileGroup.toBuilder().setVariantId("some-different-variant").build();
+    assertThat(fileGroupManager.addGroupForDownload(testKey, dataFileGroup).get()).isTrue();
+    verifyAddGroupForDownloadWritesMetadata(testKey, dataFileGroup, CURRENT_TIMESTAMP);
+  }
+
+  @Test
+  public void testAddGroupForDownloadWithSyncId_failedToUpdateMetadataNoScheduleViaSpe()
+      throws Exception {
+    // Mock FileGroupsMetadata and SharedFileManager to test failure scenario.
+    resetFileGroupManager(mockFileGroupsMetadata, mockSharedFileManager);
+    DataFileGroupInternal dataFileGroup =
+        MddTestUtil.createDataFileGroupInternalWithDownloadId(TEST_GROUP, 2);
+    NewFileKey[] groupKeys = MddTestUtil.createFileKeysForDataFileGroupInternal(dataFileGroup);
+
+    when(mockSharedFileManager.reserveFileEntry(any(NewFileKey.class)))
+        .thenReturn(Futures.immediateFuture(true));
+
+    // Failed to write to Metadata, no task will be scheduled via SPE.
+    when(mockFileGroupsMetadata.write(any(GroupKey.class), any(DataFileGroupInternal.class)))
+        .thenReturn(Futures.immediateFuture(false));
+    when(mockFileGroupsMetadata.read(any(GroupKey.class)))
+        .thenReturn(Futures.immediateFuture(null));
+
+    ListenableFuture<Boolean> addGroupFuture =
+        fileGroupManager.addGroupForDownload(testKey, dataFileGroup);
+    assertThrows(ExecutionException.class, addGroupFuture::get);
+    IOException e = LabsFutures.getFailureCauseAs(addGroupFuture, IOException.class);
+    assertThat(e).hasMessageThat().contains("Failed to commit new group metadata to disk.");
+
+    // Check that downloaded file groups doesn't contain this file group.
+    GroupKey downloadedkey = testKey.toBuilder().setDownloaded(true).build();
+    assertWithMessage(String.format("Expected that key %s should not exist.", downloadedkey))
+        .that(mockFileGroupsMetadata.read(downloadedkey).get())
+        .isNull();
+    // Check that the get method doesn't return this file group.
+    assertThat(fileGroupManager.getFileGroup(testKey, true).get()).isNull();
+
+    verify(mockSharedFileManager).reserveFileEntry(groupKeys[0]);
+    verify(mockSharedFileManager).reserveFileEntry(groupKeys[1]);
+  }
+
+  @Test
+  public void testAddGroupForDownload_duplicatePendingGroup() throws Exception {
+    DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+
+    assertThat(fileGroupManager.addGroupForDownload(testKey, dataFileGroup).get()).isTrue();
+    verifyAddGroupForDownloadWritesMetadata(testKey, dataFileGroup, CURRENT_TIMESTAMP);
+
+    // Send the exact same group again, and check that it is considered duplicate.
+    assertThat(fileGroupManager.addGroupForDownload(testKey, dataFileGroup).get()).isFalse();
+  }
+
+  @Test
+  public void testAddGroupForDownload_duplicateDownloadedGroup() throws Exception {
+    DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+    writeDownloadedFileGroup(testKey, dataFileGroup);
+
+    // Send the exact same group as the downloaded group, and check that it is considered duplicate.
+    assertThat(fileGroupManager.addGroupForDownload(testKey, dataFileGroup).get()).isFalse();
+  }
+
+  @Test
+  public void testAddGroupForDownload_filePropertiesUpdated() throws Exception {
+    DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+    writePendingFileGroup(testKey, dataFileGroup);
+
+    dataFileGroup =
+        dataFileGroup.toBuilder()
+            .setFile(0, dataFileGroup.getFile(0).toBuilder().setUrlToDownload("https://file2"))
+            .build();
+    // Send the same group with different property, and check that it is NOT duplicate.
+    assertThat(fileGroupManager.addGroupForDownload(testKey, dataFileGroup).get()).isTrue();
+
+    dataFileGroup =
+        dataFileGroup.toBuilder()
+            .setFile(0, dataFileGroup.getFile(0).toBuilder().setUrlToDownload("https://file3"))
+            .build();
+    assertThat(fileGroupManager.addGroupForDownload(testKey, dataFileGroup).get()).isTrue();
+  }
+
+  @Test
+  public void testAddGroupForDownload_differentPendingGroup_duplicateDownloadedGroup()
+      throws Exception {
+
+    DataFileGroupInternal firstGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+    assertThat(fileGroupManager.addGroupForDownload(testKey, firstGroup).get()).isTrue();
+    verifyAddGroupForDownloadWritesMetadata(testKey, firstGroup, CURRENT_TIMESTAMP);
+
+    // Create a second group that is identical except for one different file id.
+    DataFileGroupInternal.Builder secondGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2).toBuilder();
+    secondGroup.setFile(0, secondGroup.getFile(0).toBuilder().setFileId("file2"));
+    writeDownloadedFileGroup(testKey, secondGroup.build());
+
+    // Send the same group as downloaded group, and check that it is not considered duplicate.
+    assertThat(fileGroupManager.addGroupForDownload(testKey, secondGroup.build()).get()).isTrue();
+    verifyAddGroupForDownloadWritesMetadata(testKey, secondGroup.build(), CURRENT_TIMESTAMP);
+  }
+
+  @Test
+  public void testAddGroupForDownload_subscribeFailed() throws Exception {
+    // Mock SharedFileManager to test failure scenario.
+    resetFileGroupManager(fileGroupsMetadata, mockSharedFileManager);
+    DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 3);
+    NewFileKey[] groupKeys = MddTestUtil.createFileKeysForDataFileGroupInternal(dataFileGroup);
+
+    ArgumentCaptor<NewFileKey> fileCaptor = ArgumentCaptor.forClass(NewFileKey.class);
+    when(mockSharedFileManager.reserveFileEntry(fileCaptor.capture()))
+        .thenReturn(
+            Futures.immediateFuture(true),
+            Futures.immediateFuture(false),
+            Futures.immediateFuture(true));
+    ExecutionException ex =
+        assertThrows(
+            ExecutionException.class,
+            fileGroupManager.addGroupForDownload(testKey, dataFileGroup)::get);
+    assertThat(ex).hasCauseThat().isInstanceOf(IOException.class);
+
+    // Verify that we tried to subscribe to only the first 2 files.
+    assertThat(fileCaptor.getAllValues()).containsExactly(groupKeys[0], groupKeys[1]);
+  }
+
+  @Test
+  public void testAddGroupForDownload_subscribeFailed_firstFile() throws Exception {
+    // Mock SharedFileManager to test failure scenario.
+    resetFileGroupManager(fileGroupsMetadata, mockSharedFileManager);
+    DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 3);
+    NewFileKey[] groupKeys = MddTestUtil.createFileKeysForDataFileGroupInternal(dataFileGroup);
+
+    ArgumentCaptor<NewFileKey> fileCaptor = ArgumentCaptor.forClass(NewFileKey.class);
+    when(mockSharedFileManager.reserveFileEntry(fileCaptor.capture()))
+        .thenReturn(
+            Futures.immediateFuture(false),
+            Futures.immediateFuture(true),
+            Futures.immediateFuture(true));
+    ExecutionException ex =
+        assertThrows(
+            ExecutionException.class,
+            fileGroupManager.addGroupForDownload(testKey, dataFileGroup)::get);
+    assertThat(ex).hasCauseThat().isInstanceOf(IOException.class);
+
+    // Verify that we tried to subscribe to only the first file.
+    assertThat(fileCaptor.getAllValues()).containsExactly(groupKeys[0]);
+  }
+
+  @Test
+  public void testAddGroupForDownload_alreadyDownloadedGroup() throws Exception {
+    // Write a group to the pending shared prefs.
+    DataFileGroupInternal pendingGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+    writePendingFileGroup(testKey, pendingGroup);
+
+    DataFileGroupInternal oldDownloadedGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1);
+    writeDownloadedFileGroup(testKey, oldDownloadedGroup);
+
+    // Add a newer version of that group
+    DataFileGroupInternal receivedGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 3);
+
+    assertThat(fileGroupManager.addGroupForDownload(testKey, receivedGroup).get()).isTrue();
+
+    // The new added group should be the pending group.
+    verifyAddGroupForDownloadWritesMetadata(testKey, receivedGroup, CURRENT_TIMESTAMP);
+    assertThat(oldDownloadedGroup).isEqualTo(readDownloadedFileGroup(testKey));
+  }
+
+  @Test
+  public void testAddGroupForDownload_addEmptyGroup() throws Exception {
+    // Write a group to the pending shared prefs.
+    DataFileGroupInternal pendingGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+    writePendingFileGroup(testKey, pendingGroup);
+
+    DataFileGroupInternal emptyGroup =
+        DataFileGroupInternal.newBuilder().setGroupName(TEST_GROUP).build();
+    assertThat(fileGroupManager.addGroupForDownload(testKey, emptyGroup).get()).isTrue();
+    verifyAddGroupForDownloadWritesMetadata(testKey, emptyGroup, CURRENT_TIMESTAMP);
+  }
+
+  @Test
+  public void testAddGroupForDownload_addGroupForUninstalledApp() throws Exception {
+    DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+    GroupKey uninstalledAppKey =
+        GroupKey.newBuilder().setGroupName(TEST_GROUP).setOwnerPackage("not.installed.app").build();
+
+    // Send a group with an owner package that is not installed. Ensure that this group is rejected.
+    assertThrows(
+        UninstalledAppException.class,
+        () -> fileGroupManager.addGroupForDownload(uninstalledAppKey, dataFileGroup));
+  }
+
+  @Test
+  public void testAddGroupForDownload_expiredGroup() throws Exception {
+    Calendar date = new Calendar.Builder().setDate(1970, Calendar.JANUARY, 2).build();
+    DataFileGroupInternal dataFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2).toBuilder()
+            .setExpirationDateSecs(date.getTimeInMillis() / 1000)
+            .build();
+
+    testClock.set(System.currentTimeMillis());
+
+    // Send a group with an expiration date that has already passed.
+    assertThrows(
+        ExpiredFileGroupException.class,
+        () -> fileGroupManager.addGroupForDownload(testKey, dataFileGroup));
+  }
+
+  @Test
+  public void testAddGroupForDownload_justExpiredGroup() throws Exception {
+    long oneHourAgo = (System.currentTimeMillis() - TimeUnit.HOURS.toMillis(1)) / 1000;
+    DataFileGroupInternal dataFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2).toBuilder()
+            .setExpirationDateSecs(oneHourAgo)
+            .build();
+
+    testClock.set(System.currentTimeMillis());
+
+    // Send a group with an expiration date that has already passed.
+    assertThrows(
+        ExpiredFileGroupException.class,
+        () -> fileGroupManager.addGroupForDownload(testKey, dataFileGroup));
+  }
+
+  @Test
+  public void testAddGroupForDownload_nonexpiredGroup() throws Exception {
+    DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+    NewFileKey[] groupKeys = MddTestUtil.createFileKeysForDataFileGroupInternal(dataFileGroup);
+
+    long tenDaysFromNow = (System.currentTimeMillis() + TimeUnit.DAYS.toMillis(10)) / 1000;
+    dataFileGroup = dataFileGroup.toBuilder().setExpirationDateSecs(tenDaysFromNow).build();
+
+    testClock.set(System.currentTimeMillis());
+
+    assertThat(fileGroupManager.addGroupForDownload(testKey, dataFileGroup).get()).isTrue();
+    verifyAddGroupForDownloadWritesMetadata(testKey, dataFileGroup, testClock.currentTimeMillis());
+
+    // Check that downloaded file groups doesn't contain this file group.
+    assertThat(readDownloadedFileGroup(testKey)).isNull();
+    // Check that the get method doesn't return this file group.
+    assertThat(fileGroupManager.getFileGroup(testKey, true).get()).isNull();
+
+    assertThat(sharedFileManager.getSharedFile(groupKeys[0]).get()).isNotNull();
+    assertThat(sharedFileManager.getSharedFile(groupKeys[1]).get()).isNotNull();
+  }
+
+  @Test
+  public void testAddGroupForDownload_nonexpiredGroupNoExpiration() throws Exception {
+    DataFileGroupInternal.Builder dataFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2).toBuilder();
+    NewFileKey[] groupKeys =
+        MddTestUtil.createFileKeysForDataFileGroupInternal(dataFileGroup.build());
+
+    dataFileGroup.setExpirationDateSecs(0); // 0 means don't expire
+
+    testClock.set(System.currentTimeMillis());
+
+    assertThat(fileGroupManager.addGroupForDownload(testKey, dataFileGroup.build()).get()).isTrue();
+    verifyAddGroupForDownloadWritesMetadata(
+        testKey, dataFileGroup.build(), testClock.currentTimeMillis());
+
+    // Check that downloaded file groups doesn't contain this file group.
+    assertThat(readDownloadedFileGroup(testKey)).isNull();
+    // Check that the get method doesn't return this file group.
+    assertThat(fileGroupManager.getFileGroup(testKey, true).get()).isNull();
+
+    assertThat(sharedFileManager.getSharedFile(groupKeys[0]).get()).isNotNull();
+    assertThat(sharedFileManager.getSharedFile(groupKeys[1]).get()).isNotNull();
+  }
+
+  @Test
+  public void testAddGroupForDownload_extendExpiration() throws Exception {
+    DataFileGroupInternal.Builder dataFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2).toBuilder();
+    long tenDaysFromNow = (System.currentTimeMillis() + TimeUnit.DAYS.toMillis(10)) / 1000;
+    dataFileGroup.setExpirationDateSecs(tenDaysFromNow);
+
+    assertThat(fileGroupManager.addGroupForDownload(testKey, dataFileGroup.build()).get()).isTrue();
+    verifyAddGroupForDownloadWritesMetadata(testKey, dataFileGroup.build(), CURRENT_TIMESTAMP);
+
+    // Now send the group again with a longer expiration.
+    long twentyDaysFromNow = tenDaysFromNow + TimeUnit.DAYS.toSeconds(10);
+    dataFileGroup = dataFileGroup.setExpirationDateSecs(twentyDaysFromNow);
+
+    assertThat(fileGroupManager.addGroupForDownload(testKey, dataFileGroup.build()).get()).isTrue();
+    verifyAddGroupForDownloadWritesMetadata(testKey, dataFileGroup.build(), CURRENT_TIMESTAMP);
+  }
+
+  @Test
+  public void testAddGroupForDownload_reduceExpiration() throws Exception {
+    long tenDaysFromNow = (System.currentTimeMillis() + TimeUnit.DAYS.toMillis(10)) / 1000;
+    DataFileGroupInternal dataFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2).toBuilder()
+            .setExpirationDateSecs(tenDaysFromNow)
+            .build();
+
+    assertThat(fileGroupManager.addGroupForDownload(testKey, dataFileGroup).get()).isTrue();
+    verifyAddGroupForDownloadWritesMetadata(testKey, dataFileGroup, CURRENT_TIMESTAMP);
+
+    // Now send the group again with a longer expiration.
+    long fiveDaysFromNow = tenDaysFromNow - TimeUnit.DAYS.toSeconds(5);
+    dataFileGroup = dataFileGroup.toBuilder().setExpirationDateSecs(fiveDaysFromNow).build();
+
+    assertThat(fileGroupManager.addGroupForDownload(testKey, dataFileGroup).get()).isTrue();
+    verifyAddGroupForDownloadWritesMetadata(testKey, dataFileGroup, CURRENT_TIMESTAMP);
+  }
+
+  @Test
+  public void testAddGroupForDownload_delayedDownload() throws Exception {
+    flags.enableDelayedDownload = Optional.of(true);
+    // Create 2 groups, one of which requires device side activation.
+    DataFileGroupInternal fileGroup1 =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2).toBuilder()
+            .setDownloadConditions(
+                DownloadConditions.newBuilder()
+                    .setActivatingCondition(ActivatingCondition.DEVICE_ACTIVATED))
+            .build();
+
+    DataFileGroupInternal fileGroup2 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP_2, 3);
+
+    // Assert that adding the first group throws an exception.
+    ExecutionException ex =
+        assertThrows(
+            ExecutionException.class,
+            fileGroupManager.addGroupForDownload(testKey, fileGroup1)::get);
+    assertThat(ex).hasCauseThat().isInstanceOf(ActivationRequiredForGroupException.class);
+    assertThat(fileGroupManager.addGroupForDownload(testKey2, fileGroup2).get()).isTrue();
+
+    // Now activate the group and verify that we are able to add the first group.
+    assertThat(fileGroupManager.setGroupActivation(testKey, true).get()).isTrue();
+    assertThat(fileGroupManager.addGroupForDownload(testKey, fileGroup1).get()).isTrue();
+
+    // Deactivate the group again and verify that we should no longer be able to add it.
+    assertThat(fileGroupManager.setGroupActivation(testKey, false).get()).isTrue();
+    ex =
+        assertThrows(
+            ExecutionException.class,
+            fileGroupManager.addGroupForDownload(testKey, fileGroup1)::get);
+    assertThat(ex).hasCauseThat().isInstanceOf(ActivationRequiredForGroupException.class);
+  }
+
+  @Test
+  public void testAddGroupForDownload_onWifiFirst() throws Exception {
+    int elapsedTime = 1000;
+    DataFileGroupInternal.Builder dataFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2).toBuilder();
+
+    {
+      testClock.set(elapsedTime);
+      assertThat(fileGroupManager.addGroupForDownload(testKey, dataFileGroup.build()).get())
+          .isTrue();
+      // The wifi only download timestamp is set correctly.
+      verifyAddGroupForDownloadWritesMetadata(testKey, dataFileGroup.build(), elapsedTime);
+    }
+
+    {
+      // Update metadata does not change the wifi only download timestamp.
+      long tenDaysFromNow = (System.currentTimeMillis() + TimeUnit.DAYS.toMillis(10)) / 1000;
+      dataFileGroup.setExpirationDateSecs(tenDaysFromNow);
+      assertThat(fileGroupManager.addGroupForDownload(testKey, dataFileGroup.build()).get())
+          .isTrue();
+      verifyAddGroupForDownloadWritesMetadata(testKey, dataFileGroup.build(), elapsedTime);
+    }
+
+    {
+      // Change another metadata field does not change the wifi only download timestamp.
+      dataFileGroup.setFileGroupVersionNumber(2);
+      assertThat(fileGroupManager.addGroupForDownload(testKey, dataFileGroup.build()).get())
+          .isTrue();
+      // The wifi only download timestamp does not change.
+      verifyAddGroupForDownloadWritesMetadata(testKey, dataFileGroup.build(), elapsedTime);
+    }
+
+    {
+      // Update the file's urlToDownload will reset the wifi only download timestamp.
+      elapsedTime = 2000;
+      testClock.set(elapsedTime);
+      dataFileGroup.setFile(
+          0, dataFileGroup.getFile(0).toBuilder().setUrlToDownload("https://new_url"));
+      assertThat(fileGroupManager.addGroupForDownload(testKey, dataFileGroup.build()).get())
+          .isTrue();
+      // The wifi only download timestamp change since we change the urlToDownload
+      verifyAddGroupForDownloadWritesMetadata(testKey, dataFileGroup.build(), elapsedTime);
+    }
+
+    {
+      // Update the file's byteSize will reset the wifi only download timestamp.
+      elapsedTime = 3000;
+      testClock.set(elapsedTime);
+      dataFileGroup.setFile(1, dataFileGroup.getFile(1).toBuilder().setByteSize(5001));
+      assertThat(fileGroupManager.addGroupForDownload(testKey, dataFileGroup.build()).get())
+          .isTrue();
+      // The wifi only download timestamp change since we change the urlToDownload
+      verifyAddGroupForDownloadWritesMetadata(testKey, dataFileGroup.build(), elapsedTime);
+    }
+
+    {
+      // Update the file's checksum will reset the wifi only download timestamp.
+      elapsedTime = 4000;
+      testClock.set(elapsedTime);
+      dataFileGroup.setFile(1, dataFileGroup.getFile(1).toBuilder().setChecksum("new check sum"));
+      assertThat(fileGroupManager.addGroupForDownload(testKey, dataFileGroup.build()).get())
+          .isTrue();
+      // The wifi only download timestamp change since we change the urlToDownload
+      verifyAddGroupForDownloadWritesMetadata(testKey, dataFileGroup.build(), elapsedTime);
+    }
+  }
+
+  @Test
+  public void testAddGroupForDownload_addsSideloadedGroup() throws Exception {
+    // Create sideloaded group
+    DataFileGroupInternal sideloadedGroup =
+        DataFileGroupInternal.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .addFile(
+                DataFile.newBuilder()
+                    .setFileId("sideloaded_file")
+                    .setUrlToDownload("file:/test")
+                    .setChecksumType(DataFile.ChecksumType.NONE)
+                    .build())
+            .build();
+
+    assertThat(fileGroupManager.addGroupForDownload(testKey, sideloadedGroup).get()).isTrue();
+
+    verifyAddGroupForDownloadWritesMetadata(testKey, sideloadedGroup, 1000L);
+  }
+
+  @Test
+  public void testAddGroupForDownload_multipleVariants() throws Exception {
+    // Create 3 group keys of the same group, but with different variants
+    GroupKey defaultGroupKey =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setOwnerPackage(context.getPackageName())
+            .build();
+    GroupKey enGroupKey = defaultGroupKey.toBuilder().setVariantId("en").build();
+    GroupKey frGroupKey = defaultGroupKey.toBuilder().setVariantId("fr").build();
+
+    DataFileGroupInternal defaultFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1);
+    DataFileGroupInternal enFileGroup = defaultFileGroup.toBuilder().setVariantId("en").build();
+    DataFileGroupInternal frFileGroup = defaultFileGroup.toBuilder().setVariantId("fr").build();
+
+    assertThat(fileGroupManager.addGroupForDownload(defaultGroupKey, defaultFileGroup).get())
+        .isTrue();
+    assertThat(fileGroupManager.addGroupForDownload(enGroupKey, enFileGroup).get()).isTrue();
+    assertThat(fileGroupManager.addGroupForDownload(frGroupKey, frFileGroup).get()).isTrue();
+
+    assertThat(fileGroupsMetadata.getAllGroupKeys().get())
+        .comparingElementsUsing(GROUP_KEY_TO_VARIANT)
+        .containsExactly("", "en", "fr");
+  }
+
+  @Test
+  public void removeFileGroup_noVersionExists() throws Exception {
+    // No record for both pending key and downloaded key.
+    GroupKey groupKey =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setOwnerPackage(context.getPackageName())
+            .build();
+
+    fileGroupManager.removeFileGroup(groupKey, /* pendingOnly= */ false).get();
+
+    assertThat(fileGroupsMetadata.getAllStaleGroups().get()).isEmpty();
+    assertThat(fileGroupsMetadata.getAllGroupKeys().get()).isEmpty();
+  }
+
+  @Test
+  public void removeFileGroup_pendingVersionExists() throws Exception {
+    DataFile dataFile1 = DataFile.newBuilder().setFileId("file1").setChecksum("checksum1").build();
+    DataFile dataFile2 = DataFile.newBuilder().setFileId("file2").setChecksum("checksum2").build();
+
+    NewFileKey newFileKey1 =
+        SharedFilesMetadata.createKeyFromDataFile(dataFile1, AllowedReaders.ALL_GOOGLE_APPS);
+    NewFileKey newFileKey2 =
+        SharedFilesMetadata.createKeyFromDataFile(dataFile2, AllowedReaders.ALL_GOOGLE_APPS);
+
+    DataFileGroupInternal pendingFileGroup =
+        DataFileGroupInternal.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setFileGroupVersionNumber(1)
+            .setAllowedReadersEnum(AllowedReaders.ALL_GOOGLE_APPS)
+            .addAllFile(Lists.newArrayList(dataFile1, dataFile2))
+            .build();
+
+    GroupKey groupKey =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setOwnerPackage(context.getPackageName())
+            .build();
+    GroupKey pendingGroupKey = groupKey.toBuilder().setDownloaded(false).build();
+    GroupKey downloadedGroupKey = groupKey.toBuilder().setDownloaded(true).build();
+
+    writePendingFileGroup(pendingGroupKey, pendingFileGroup);
+    writeSharedFiles(
+        sharedFilesMetadata,
+        pendingFileGroup,
+        ImmutableList.of(FileStatus.DOWNLOAD_IN_PROGRESS, FileStatus.DOWNLOAD_IN_PROGRESS));
+
+    fileGroupManager.removeFileGroup(groupKey, /* pendingOnly= */ false).get();
+
+    assertThat(readPendingFileGroup(pendingGroupKey)).isNull();
+    assertThat(readPendingFileGroup(downloadedGroupKey)).isNull();
+    assertThat(fileGroupsMetadata.getAllGroupKeys().get()).isEmpty();
+
+    Uri pendingFileUri1 =
+        DirectoryUtil.getOnDeviceUri(
+            context,
+            newFileKey1.getAllowedReaders(),
+            dataFile1.getFileId(),
+            newFileKey1.getChecksum(),
+            mockSilentFeedback,
+            /* instanceId= */ Optional.absent(),
+            /* androidShared = */ false);
+    Uri pendingFileUri2 =
+        DirectoryUtil.getOnDeviceUri(
+            context,
+            newFileKey2.getAllowedReaders(),
+            dataFile2.getFileId(),
+            newFileKey2.getChecksum(),
+            mockSilentFeedback,
+            /* instanceId= */ Optional.absent(),
+            /* androidShared = */ false);
+
+    verify(mockDownloader).stopDownloading(pendingFileUri1);
+    verify(mockDownloader).stopDownloading(pendingFileUri2);
+  }
+
+  @Test
+  public void removeFileGroup_downloadedVersionExists() throws Exception {
+    DataFile dataFile1 = DataFile.newBuilder().setFileId("file1").setChecksum("checksum1").build();
+    DataFile dataFile2 = DataFile.newBuilder().setFileId("file2").setChecksum("checksum2").build();
+
+    DataFileGroupInternal downloadedFileGroup =
+        DataFileGroupInternal.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setFileGroupVersionNumber(0)
+            .setBuildId(0)
+            .setVariantId("")
+            .setAllowedReadersEnum(AllowedReaders.ALL_GOOGLE_APPS)
+            .addAllFile(Lists.newArrayList(dataFile1, dataFile2))
+            .build();
+
+    GroupKey groupKey =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setOwnerPackage(context.getPackageName())
+            .build();
+    GroupKey pendingGroupKey = groupKey.toBuilder().setDownloaded(false).build();
+    GroupKey downloadedGroupKey = groupKey.toBuilder().setDownloaded(true).build();
+
+    writeDownloadedFileGroup(downloadedGroupKey, downloadedFileGroup);
+    writeSharedFiles(
+        sharedFilesMetadata,
+        downloadedFileGroup,
+        ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE, FileStatus.DOWNLOAD_COMPLETE));
+
+    fileGroupManager.removeFileGroup(groupKey, /* pendingOnly= */ false).get();
+
+    assertThat(readPendingFileGroup(pendingGroupKey)).isNull();
+    assertThat(readPendingFileGroup(downloadedGroupKey)).isNull();
+    assertThat(fileGroupsMetadata.getAllStaleGroups().get())
+        .containsExactly(
+            downloadedFileGroup.toBuilder()
+                .setBookkeeping(
+                    downloadedFileGroup.getBookkeeping().toBuilder()
+                        .setStaleExpirationDate(1)
+                        .build())
+                .build());
+
+    verify(mockDownloader, never()).stopDownloading(any(Uri.class));
+  }
+
+  @Test
+  public void removeFileGroup_bothVersionsExist() throws Exception {
+    DataFile registeredFile =
+        DataFile.newBuilder().setFileId("file").setChecksum("registered").build();
+    DataFile downloadedFile =
+        DataFile.newBuilder().setFileId("file").setChecksum("downloaded").build();
+
+    NewFileKey registeredFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(registeredFile, AllowedReaders.ALL_GOOGLE_APPS);
+
+    DataFileGroupInternal pendingFileGroup =
+        DataFileGroupInternal.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setFileGroupVersionNumber(1)
+            .setAllowedReadersEnum(AllowedReaders.ALL_GOOGLE_APPS)
+            .addFile(registeredFile)
+            .build();
+    DataFileGroupInternal downloadedFileGroup =
+        DataFileGroupInternal.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setFileGroupVersionNumber(0)
+            .setBuildId(0)
+            .setVariantId("")
+            .setAllowedReadersEnum(AllowedReaders.ALL_GOOGLE_APPS)
+            .addFile(downloadedFile)
+            .build();
+
+    GroupKey groupKey =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setOwnerPackage(context.getPackageName())
+            .build();
+    GroupKey pendingGroupKey = groupKey.toBuilder().setDownloaded(false).build();
+    GroupKey downloadedGroupKey = groupKey.toBuilder().setDownloaded(true).build();
+
+    writePendingFileGroup(pendingGroupKey, pendingFileGroup);
+    writeDownloadedFileGroup(downloadedGroupKey, downloadedFileGroup);
+    writeSharedFiles(
+        sharedFilesMetadata, pendingFileGroup, ImmutableList.of(FileStatus.DOWNLOAD_IN_PROGRESS));
+    writeSharedFiles(
+        sharedFilesMetadata, downloadedFileGroup, ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE));
+
+    fileGroupManager.removeFileGroup(groupKey, /* pendingOnly= */ false).get();
+
+    assertThat(readPendingFileGroup(pendingGroupKey)).isNull();
+    assertThat(readPendingFileGroup(downloadedGroupKey)).isNull();
+    assertThat(fileGroupsMetadata.getAllStaleGroups().get())
+        .containsExactly(
+            downloadedFileGroup.toBuilder()
+                .setBookkeeping(
+                    downloadedFileGroup.getBookkeeping().toBuilder()
+                        .setStaleExpirationDate(1)
+                        .build())
+                .build());
+
+    Uri pendingFileUri =
+        DirectoryUtil.getOnDeviceUri(
+            context,
+            registeredFileKey.getAllowedReaders(),
+            registeredFile.getFileId(),
+            registeredFileKey.getChecksum(),
+            mockSilentFeedback,
+            /* instanceId= */ Optional.absent(),
+            /* androidShared = */ false);
+
+    // Only called once to stop download of pending file.
+    verify(mockDownloader).stopDownloading(pendingFileUri);
+  }
+
+  @Test
+  public void removeFileGroup_bothVersionsExist_onlyRemovePending() throws Exception {
+    DataFile registeredFile =
+        DataFile.newBuilder().setFileId("file").setChecksum("registered").build();
+    DataFile downloadedFile =
+        DataFile.newBuilder().setFileId("file").setChecksum("downloaded").build();
+
+    NewFileKey registeredFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(registeredFile, AllowedReaders.ALL_GOOGLE_APPS);
+
+    DataFileGroupInternal pendingFileGroup =
+        DataFileGroupInternal.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setFileGroupVersionNumber(1)
+            .setAllowedReadersEnum(AllowedReaders.ALL_GOOGLE_APPS)
+            .addFile(registeredFile)
+            .build();
+    DataFileGroupInternal downloadedFileGroup =
+        DataFileGroupInternal.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setFileGroupVersionNumber(0)
+            .setBuildId(0)
+            .setVariantId("")
+            .setAllowedReadersEnum(AllowedReaders.ALL_GOOGLE_APPS)
+            .addFile(downloadedFile)
+            .build();
+
+    GroupKey groupKey =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setOwnerPackage(context.getPackageName())
+            .build();
+    GroupKey pendingGroupKey = groupKey.toBuilder().setDownloaded(false).build();
+    GroupKey downloadedGroupKey = groupKey.toBuilder().setDownloaded(true).build();
+
+    writePendingFileGroup(pendingGroupKey, pendingFileGroup);
+    writeDownloadedFileGroup(downloadedGroupKey, downloadedFileGroup);
+    writeSharedFiles(
+        sharedFilesMetadata, pendingFileGroup, ImmutableList.of(FileStatus.DOWNLOAD_IN_PROGRESS));
+    writeSharedFiles(
+        sharedFilesMetadata, downloadedFileGroup, ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE));
+
+    fileGroupManager.removeFileGroup(groupKey, /* pendingOnly= */ true).get();
+
+    assertThat(readPendingFileGroup(pendingGroupKey)).isNull();
+    assertThat(readPendingFileGroup(downloadedGroupKey)).isNull();
+
+    // Pending group was just removed, and downloaded was not added to stale groups.
+    assertThat(fileGroupsMetadata.getAllStaleGroups().get()).isEmpty();
+    // Downloaded group is still available.
+    assertThat(fileGroupsMetadata.getAllFreshGroups().get())
+        .containsExactly(Pair.create(downloadedGroupKey, downloadedFileGroup));
+
+    Uri pendingFileUri =
+        DirectoryUtil.getOnDeviceUri(
+            context,
+            registeredFileKey.getAllowedReaders(),
+            registeredFile.getFileId(),
+            registeredFileKey.getChecksum(),
+            mockSilentFeedback,
+            /* instanceId= */ Optional.absent(),
+            /* androidShared = */ false);
+
+    // Only called once to stop download of pending file.
+    verify(mockDownloader).stopDownloading(pendingFileUri);
+  }
+
+  @Test
+  public void removeFileGroup_fileReferencedByOtherFileGroup_willNotCancelDownload()
+      throws Exception {
+    DataFile dataFile1 = DataFile.newBuilder().setFileId("file1").setChecksum("checksum1").build();
+    DataFile dataFile2 = DataFile.newBuilder().setFileId("file2").setChecksum("checksum2").build();
+
+    DataFileGroupInternal pendingFileGroup =
+        DataFileGroupInternal.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setFileGroupVersionNumber(1)
+            .setAllowedReadersEnum(AllowedReaders.ALL_GOOGLE_APPS)
+            .addAllFile(Lists.newArrayList(dataFile1, dataFile2))
+            .build();
+
+    DataFileGroupInternal pendingFileGroup2 =
+        DataFileGroupInternal.newBuilder()
+            .setGroupName(TEST_GROUP_2)
+            .setFileGroupVersionNumber(1)
+            .setAllowedReadersEnum(AllowedReaders.ALL_GOOGLE_APPS)
+            .addAllFile(Lists.newArrayList(dataFile1, dataFile2))
+            .build();
+
+    GroupKey groupKey =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setOwnerPackage(context.getPackageName())
+            .build();
+    GroupKey pendingGroupKey = groupKey.toBuilder().setDownloaded(false).build();
+    GroupKey downloadedGroupKey = groupKey.toBuilder().setDownloaded(true).build();
+
+    GroupKey groupKey2 =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP_2)
+            .setOwnerPackage(context.getPackageName())
+            .build();
+    GroupKey pendingGroupKey2 = groupKey2.toBuilder().setDownloaded(false).build();
+
+    writePendingFileGroup(pendingGroupKey, pendingFileGroup);
+    writePendingFileGroup(pendingGroupKey2, pendingFileGroup2);
+    writeSharedFiles(
+        sharedFilesMetadata,
+        pendingFileGroup,
+        ImmutableList.of(FileStatus.DOWNLOAD_IN_PROGRESS, FileStatus.DOWNLOAD_IN_PROGRESS));
+    writeSharedFiles(
+        sharedFilesMetadata,
+        pendingFileGroup2,
+        ImmutableList.of(FileStatus.DOWNLOAD_IN_PROGRESS, FileStatus.DOWNLOAD_IN_PROGRESS));
+    assertThat(fileGroupsMetadata.getAllFreshGroups().get())
+        .containsExactly(
+            new Pair<GroupKey, DataFileGroupInternal>(pendingGroupKey, pendingFileGroup),
+            new Pair<GroupKey, DataFileGroupInternal>(pendingGroupKey2, pendingFileGroup2));
+
+    fileGroupManager.removeFileGroup(groupKey, /* pendingOnly= */ false).get();
+
+    assertThat(readPendingFileGroup(pendingGroupKey)).isNull();
+    assertThat(readPendingFileGroup(pendingGroupKey2)).isNotNull();
+    assertThat(readPendingFileGroup(downloadedGroupKey)).isNull();
+    assertThat(fileGroupsMetadata.getAllStaleGroups().get()).isEmpty();
+    assertThat(fileGroupsMetadata.getAllFreshGroups().get())
+        .containsExactly(
+            new Pair<GroupKey, DataFileGroupInternal>(pendingGroupKey2, pendingFileGroup2));
+
+    verify(mockDownloader, never()).stopDownloading(any(Uri.class));
+  }
+
+  @Test
+  public void removeFileGroup_onFailure() throws Exception {
+    // Mock FileGroupsMetadata to test failure scenario.
+    resetFileGroupManager(mockFileGroupsMetadata, sharedFileManager);
+    DataFileGroupInternal pendingFileGroup =
+        DataFileGroupInternal.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setFileGroupVersionNumber(1)
+            .build();
+    DataFileGroupInternal downloadedFileGroup =
+        DataFileGroupInternal.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setFileGroupVersionNumber(0)
+            .setBuildId(0)
+            .setVariantId("")
+            .build();
+
+    GroupKey groupKey =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setOwnerPackage(context.getPackageName())
+            .build();
+    GroupKey pendingGroupKey = groupKey.toBuilder().setDownloaded(false).build();
+    GroupKey downloadedGroupKey = groupKey.toBuilder().setDownloaded(true).build();
+
+    when(mockFileGroupsMetadata.read(pendingGroupKey))
+        .thenReturn(Futures.immediateFuture(pendingFileGroup));
+    when(mockFileGroupsMetadata.read(downloadedGroupKey))
+        .thenReturn(Futures.immediateFuture(downloadedFileGroup));
+    when(mockFileGroupsMetadata.remove(pendingGroupKey)).thenReturn(Futures.immediateFuture(true));
+    when(mockFileGroupsMetadata.remove(downloadedGroupKey))
+        .thenReturn(Futures.immediateFuture(false));
+
+    // Exception should be thrown when fileGroupManager attempts to remove downloadedGroupKey.
+    ExecutionException expected =
+        assertThrows(
+            ExecutionException.class,
+            () -> fileGroupManager.removeFileGroup(groupKey, /* pendingOnly= */ false).get());
+    assertThat(expected).hasCauseThat().isInstanceOf(IOException.class);
+
+    verify(mockFileGroupsMetadata).remove(pendingGroupKey);
+    verify(mockFileGroupsMetadata).remove(downloadedGroupKey);
+    verify(mockFileGroupsMetadata, never()).addStaleGroup(any(DataFileGroupInternal.class));
+  }
+
+  @Test
+  public void removeFileGroup_removesSideloadedGroup() throws Exception {
+    // Create sideloaded group
+    DataFileGroupInternal sideloadedGroup =
+        DataFileGroupInternal.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .addFile(
+                DataFile.newBuilder()
+                    .setFileId("sideloaded_file")
+                    .setUrlToDownload("file:/test")
+                    .setChecksumType(DataFile.ChecksumType.NONE)
+                    .build())
+            .build();
+
+    writePendingFileGroup(testKey, sideloadedGroup);
+    writeDownloadedFileGroup(testKey, sideloadedGroup);
+
+    fileGroupManager.removeFileGroup(testKey, /* pendingOnly = */ false).get();
+
+    assertThat(readPendingFileGroup(testKey)).isNull();
+    assertThat(readDownloadedFileGroup(testKey)).isNull();
+  }
+
+  @Test
+  public void
+      removeFileGroup_whenMultipleVariantsExist_whenNoVariantSpecified_removesEmptyVariantGroup()
+          throws Exception {
+    // Create 3 variants of a group (default (no variant), en, fr) and have them all added. When
+    // removeFileGroups is called and the group key given does not include a variant, ensure that
+    // the default group is removed.
+
+    GroupKey defaultGroupKey =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setOwnerPackage(context.getPackageName())
+            .build();
+    GroupKey enGroupKey = defaultGroupKey.toBuilder().setVariantId("en").build();
+    GroupKey frGroupKey = defaultGroupKey.toBuilder().setVariantId("fr").build();
+
+    DataFileGroupInternal defaultFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1);
+    DataFileGroupInternal enFileGroup = defaultFileGroup.toBuilder().setVariantId("en").build();
+    DataFileGroupInternal frFileGroup = defaultFileGroup.toBuilder().setVariantId("fr").build();
+
+    writePendingFileGroup(getPendingKey(defaultGroupKey), defaultFileGroup);
+    writePendingFileGroup(getPendingKey(enGroupKey), enFileGroup);
+    writePendingFileGroup(getPendingKey(frGroupKey), frFileGroup);
+
+    writeSharedFiles(
+        sharedFilesMetadata, defaultFileGroup, ImmutableList.of(FileStatus.DOWNLOAD_IN_PROGRESS));
+    writeSharedFiles(
+        sharedFilesMetadata, enFileGroup, ImmutableList.of(FileStatus.DOWNLOAD_IN_PROGRESS));
+    writeSharedFiles(
+        sharedFilesMetadata, frFileGroup, ImmutableList.of(FileStatus.DOWNLOAD_IN_PROGRESS));
+
+    // Assert that all file groups share the same file even through the variants are different
+    assertThat(sharedFilesMetadata.getAllFileKeys().get()).hasSize(1);
+
+    {
+      // Perfrom removal once and check that the default group gets removed
+      fileGroupManager.removeFileGroup(defaultGroupKey, /* pendingOnly = */ false).get();
+
+      assertThat(fileGroupsMetadata.getAllGroupKeys().get())
+          .comparingElementsUsing(GROUP_KEY_TO_VARIANT)
+          .containsExactly("en", "fr");
+      assertThat(fileGroupsMetadata.getAllFreshGroups().get())
+          .comparingElementsUsing(KEY_GROUP_PAIR_TO_VARIANT_PAIR)
+          .containsExactly(Pair.create("en", "en"), Pair.create("fr", "fr"));
+
+      assertThat(sharedFilesMetadata.getAllFileKeys().get()).hasSize(1);
+    }
+
+    {
+      // Perform remove again and verify that there is no change in state
+      fileGroupManager.removeFileGroup(defaultGroupKey, /* pendingOnly = */ false).get();
+
+      assertThat(fileGroupsMetadata.getAllGroupKeys().get())
+          .comparingElementsUsing(GROUP_KEY_TO_VARIANT)
+          .containsExactly("en", "fr");
+      assertThat(fileGroupsMetadata.getAllFreshGroups().get())
+          .comparingElementsUsing(KEY_GROUP_PAIR_TO_VARIANT_PAIR)
+          .containsExactly(Pair.create("en", "en"), Pair.create("fr", "fr"));
+
+      assertThat(sharedFilesMetadata.getAllFileKeys().get()).hasSize(1);
+    }
+  }
+
+  @Test
+  public void removeFileGroup_whenMultipleVariantsExist_whenVariantSpecified_removesVariantGroup()
+      throws Exception {
+    // Create 3 variants of a group (default (no variant), en, fr) and have them all added. When
+    // removeFileGroups is called and the group key given includes a variant, ensure that only
+    // the group with that variant is removed.
+
+    GroupKey defaultGroupKey =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setOwnerPackage(context.getPackageName())
+            .build();
+    GroupKey enGroupKey = defaultGroupKey.toBuilder().setVariantId("en").build();
+    GroupKey frGroupKey = defaultGroupKey.toBuilder().setVariantId("fr").build();
+
+    DataFileGroupInternal defaultFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1);
+    DataFileGroupInternal enFileGroup = defaultFileGroup.toBuilder().setVariantId("en").build();
+    DataFileGroupInternal frFileGroup = defaultFileGroup.toBuilder().setVariantId("fr").build();
+
+    writePendingFileGroup(getPendingKey(defaultGroupKey), defaultFileGroup);
+    writePendingFileGroup(getPendingKey(enGroupKey), enFileGroup);
+    writePendingFileGroup(getPendingKey(frGroupKey), frFileGroup);
+
+    writeSharedFiles(
+        sharedFilesMetadata, defaultFileGroup, ImmutableList.of(FileStatus.DOWNLOAD_IN_PROGRESS));
+    writeSharedFiles(
+        sharedFilesMetadata, enFileGroup, ImmutableList.of(FileStatus.DOWNLOAD_IN_PROGRESS));
+    writeSharedFiles(
+        sharedFilesMetadata, frFileGroup, ImmutableList.of(FileStatus.DOWNLOAD_IN_PROGRESS));
+
+    // Assert that all file groups share the same file even through the variants are different
+    assertThat(sharedFilesMetadata.getAllFileKeys().get()).hasSize(1);
+
+    {
+      // Perfrom removal once and check that the en group gets removed
+      fileGroupManager.removeFileGroup(enGroupKey, /* pendingOnly = */ false).get();
+
+      assertThat(fileGroupsMetadata.getAllGroupKeys().get())
+          .comparingElementsUsing(GROUP_KEY_TO_VARIANT)
+          .containsExactly("", "fr");
+      assertThat(fileGroupsMetadata.getAllFreshGroups().get())
+          .comparingElementsUsing(KEY_GROUP_PAIR_TO_VARIANT_PAIR)
+          .containsExactly(Pair.create("", ""), Pair.create("fr", "fr"));
+
+      assertThat(sharedFilesMetadata.getAllFileKeys().get()).hasSize(1);
+    }
+
+    {
+      // Perform remove again and verify that there is no change in state
+      fileGroupManager.removeFileGroup(enGroupKey, /* pendingOnly = */ false).get();
+
+      assertThat(fileGroupsMetadata.getAllGroupKeys().get())
+          .comparingElementsUsing(GROUP_KEY_TO_VARIANT)
+          .containsExactly("", "fr");
+      assertThat(fileGroupsMetadata.getAllFreshGroups().get())
+          .comparingElementsUsing(KEY_GROUP_PAIR_TO_VARIANT_PAIR)
+          .containsExactly(Pair.create("", ""), Pair.create("fr", "fr"));
+
+      assertThat(sharedFilesMetadata.getAllFileKeys().get()).hasSize(1);
+    }
+  }
+
+  @Test
+  public void testRemoveFileGroups_whenNoGroupsExist_performsNoRemovals() throws Exception {
+    GroupKey groupKey1 =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setOwnerPackage(context.getPackageName())
+            .build();
+    GroupKey groupKey2 =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP_2)
+            .setOwnerPackage(context.getPackageName())
+            .build();
+
+    fileGroupManager.removeFileGroups(ImmutableList.of(groupKey1, groupKey2)).get();
+
+    assertThat(fileGroupsMetadata.getAllStaleGroups().get()).isEmpty();
+    assertThat(fileGroupsMetadata.getAllGroupKeys().get()).isEmpty();
+  }
+
+  @Test
+  public void testRemoveFileGroups_whenNoMatchingKeysExist_performsNoRemovals() throws Exception {
+    // Create a pending and downloaded version of a file group
+    // Pending group includes 2 files: 1 that is shared with downloaded group and one that will be
+    // marked pending
+    DataFileGroupInternal pendingFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+    DataFileGroupInternal downloadedFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1);
+
+    GroupKey.Builder groupKeyBuilder =
+        GroupKey.newBuilder().setGroupName(TEST_GROUP).setOwnerPackage(context.getPackageName());
+    GroupKey pendingGroupKey = groupKeyBuilder.setDownloaded(false).build();
+    GroupKey downloadedGroupKey = groupKeyBuilder.setDownloaded(true).build();
+    GroupKey nonMatchingGroupKey1 = groupKeyBuilder.setGroupName(TEST_GROUP_2).build();
+    GroupKey nonMatchingGroupKey2 = groupKeyBuilder.setGroupName(TEST_GROUP_3).build();
+
+    // Write file group and shared file metadata
+    // NOTE: pending group contains all files in downloaded group, so we only need to write shared
+    // file state once.
+    writePendingFileGroup(pendingGroupKey, pendingFileGroup);
+    writeDownloadedFileGroup(downloadedGroupKey, downloadedFileGroup);
+    writeSharedFiles(
+        sharedFilesMetadata,
+        pendingFileGroup,
+        ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE, FileStatus.DOWNLOAD_IN_PROGRESS));
+
+    fileGroupManager
+        .removeFileGroups(ImmutableList.of(nonMatchingGroupKey1, nonMatchingGroupKey2))
+        .get();
+
+    assertThat(readPendingFileGroup(pendingGroupKey)).isNotNull();
+    assertThat(readDownloadedFileGroup(downloadedGroupKey)).isNotNull();
+    assertThat(fileGroupsMetadata.getAllFreshGroups().get()).hasSize(2);
+    assertThat(fileGroupsMetadata.getAllStaleGroups().get()).isEmpty();
+
+    verify(mockDownloader, times(0)).stopDownloading(any());
+  }
+
+  @Test
+  public void testRemoveFileGroups_whenMatchingPendingGroups_performsRemove() throws Exception {
+    // Create 2 pending groups that will be removed, 1 pending group that shouldn't be removed, and
+    // 1 downloaded group that shouldn't be removed
+    DataFileGroupInternal pendingGroupToRemove1 =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder().build();
+    DataFileGroupInternal pendingGroupToRemove2 =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP_2, 1);
+    DataFileGroupInternal pendingGroupToKeep =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP_3, 1).toBuilder().build();
+    DataFileGroupInternal downloadedGroupToKeep =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP_4, 1);
+
+    GroupKey.Builder groupKeyBuilder =
+        GroupKey.newBuilder().setOwnerPackage(context.getPackageName()).setDownloaded(false);
+    GroupKey pendingGroupKeyToRemove1 = groupKeyBuilder.setGroupName(TEST_GROUP).build();
+    GroupKey pendingGroupKeyToRemove2 = groupKeyBuilder.setGroupName(TEST_GROUP_2).build();
+    GroupKey pendingGroupKeyToKeep = groupKeyBuilder.setGroupName(TEST_GROUP_3).build();
+    GroupKey downloadedGroupKeyToKeep =
+        groupKeyBuilder.setGroupName(TEST_GROUP_4).setDownloaded(true).build();
+
+    writePendingFileGroup(pendingGroupKeyToRemove1, pendingGroupToRemove1);
+    writePendingFileGroup(pendingGroupKeyToRemove2, pendingGroupToRemove2);
+    writePendingFileGroup(pendingGroupKeyToKeep, pendingGroupToKeep);
+    writeDownloadedFileGroup(downloadedGroupKeyToKeep, downloadedGroupToKeep);
+
+    writeSharedFiles(
+        sharedFilesMetadata,
+        pendingGroupToRemove1,
+        ImmutableList.of(FileStatus.DOWNLOAD_IN_PROGRESS));
+    writeSharedFiles(
+        sharedFilesMetadata,
+        pendingGroupToRemove2,
+        ImmutableList.of(FileStatus.DOWNLOAD_IN_PROGRESS));
+    writeSharedFiles(
+        sharedFilesMetadata, pendingGroupToKeep, ImmutableList.of(FileStatus.DOWNLOAD_IN_PROGRESS));
+    writeSharedFiles(
+        sharedFilesMetadata, downloadedGroupToKeep, ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE));
+
+    fileGroupManager
+        .removeFileGroups(ImmutableList.of(pendingGroupKeyToRemove1, pendingGroupKeyToRemove2))
+        .get();
+
+    // Construct Pending File Uris to check which downloads were stopped
+    NewFileKey pendingFileKey1 =
+        MddTestUtil.createFileKeysForDataFileGroupInternal(pendingGroupToRemove1)[0];
+    NewFileKey pendingFileKey2 =
+        MddTestUtil.createFileKeysForDataFileGroupInternal(pendingGroupToRemove2)[0];
+    NewFileKey pendingFileKey3 =
+        MddTestUtil.createFileKeysForDataFileGroupInternal(pendingGroupToKeep)[0];
+    Uri pendingFileUri1 =
+        DirectoryUtil.getOnDeviceUri(
+            context,
+            pendingFileKey1.getAllowedReaders(),
+            pendingGroupToRemove1.getFile(0).getFileId(),
+            pendingFileKey1.getChecksum(),
+            mockSilentFeedback,
+            /* instanceId = */ Optional.absent(),
+            /* androidShared = */ false);
+    Uri pendingFileUri2 =
+        DirectoryUtil.getOnDeviceUri(
+            context,
+            pendingFileKey2.getAllowedReaders(),
+            pendingGroupToRemove2.getFile(0).getFileId(),
+            pendingFileKey2.getChecksum(),
+            mockSilentFeedback,
+            /* instanceId = */ Optional.absent(),
+            /* androidShared = */ false);
+    Uri pendingFileUri3 =
+        DirectoryUtil.getOnDeviceUri(
+            context,
+            pendingFileKey3.getAllowedReaders(),
+            pendingGroupToKeep.getFile(0).getFileId(),
+            pendingFileKey3.getChecksum(),
+            mockSilentFeedback,
+            /* instanceId = */ Optional.absent(),
+            /* androidShared = */ false);
+
+    // Assert that matching pending groups are removed
+    assertThat(readPendingFileGroup(pendingGroupKeyToRemove1)).isNull();
+    assertThat(readPendingFileGroup(pendingGroupKeyToRemove2)).isNull();
+    assertThat(readPendingFileGroup(pendingGroupKeyToKeep)).isNotNull();
+    assertThat(readDownloadedFileGroup(downloadedGroupKeyToKeep)).isNotNull();
+    assertThat(fileGroupsMetadata.getAllFreshGroups().get()).hasSize(2);
+    assertThat(fileGroupsMetadata.getAllStaleGroups().get()).isEmpty();
+
+    verify(mockDownloader).stopDownloading(pendingFileUri1);
+    verify(mockDownloader).stopDownloading(pendingFileUri2);
+    verify(mockDownloader, times(0)).stopDownloading(pendingFileUri3);
+  }
+
+  @Test
+  public void testRemoveFileGroups_whenMatchingDownloadedGroups_performsRemove() throws Exception {
+    // Create 2 downloaded groups that will be removed, 1 downloaded group that shouldn't be
+    // removed, and 1 pending group that shouldn't be removed
+    DataFileGroupInternal downloadedGroupToRemove1 =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1);
+    DataFileGroupInternal downloadedGroupToRemove2 =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP_2, 1);
+    DataFileGroupInternal downloadedGroupToKeep =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP_3, 1);
+    DataFileGroupInternal pendingGroupToKeep =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP_4, 1).toBuilder().build();
+
+    GroupKey.Builder groupKeyBuilder =
+        GroupKey.newBuilder().setOwnerPackage(context.getPackageName()).setDownloaded(true);
+    GroupKey downloadedGroupKeyToRemove1 = groupKeyBuilder.setGroupName(TEST_GROUP).build();
+    GroupKey downloadedGroupKeyToRemove2 = groupKeyBuilder.setGroupName(TEST_GROUP_2).build();
+    GroupKey downloadedGroupKeyToKeep = groupKeyBuilder.setGroupName(TEST_GROUP_3).build();
+    GroupKey pendingGroupKeyToKeep =
+        groupKeyBuilder.setGroupName(TEST_GROUP_4).setDownloaded(false).build();
+
+    writeDownloadedFileGroup(downloadedGroupKeyToRemove1, downloadedGroupToRemove1);
+    writeDownloadedFileGroup(downloadedGroupKeyToRemove2, downloadedGroupToRemove2);
+    writeDownloadedFileGroup(downloadedGroupKeyToKeep, downloadedGroupToKeep);
+    writePendingFileGroup(pendingGroupKeyToKeep, pendingGroupToKeep);
+
+    writeSharedFiles(
+        sharedFilesMetadata,
+        downloadedGroupToRemove1,
+        ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE));
+    writeSharedFiles(
+        sharedFilesMetadata,
+        downloadedGroupToRemove2,
+        ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE));
+    writeSharedFiles(
+        sharedFilesMetadata, downloadedGroupToKeep, ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE));
+    writeSharedFiles(
+        sharedFilesMetadata, pendingGroupToKeep, ImmutableList.of(FileStatus.DOWNLOAD_IN_PROGRESS));
+
+    fileGroupManager
+        .removeFileGroups(
+            ImmutableList.of(downloadedGroupKeyToRemove1, downloadedGroupKeyToRemove2))
+        .get();
+
+    // Construct Pending File Uri to check that it isn't cancelled
+    NewFileKey pendingFileKey1 =
+        MddTestUtil.createFileKeysForDataFileGroupInternal(pendingGroupToKeep)[0];
+    Uri pendingFileUri1 =
+        DirectoryUtil.getOnDeviceUri(
+            context,
+            pendingFileKey1.getAllowedReaders(),
+            pendingGroupToKeep.getFile(0).getFileId(),
+            pendingFileKey1.getChecksum(),
+            mockSilentFeedback,
+            /* instanceId = */ Optional.absent(),
+            /* androidShared = */ false);
+
+    // Assert that matching pending groups are removed
+    assertThat(readDownloadedFileGroup(downloadedGroupKeyToRemove1)).isNull();
+    assertThat(readDownloadedFileGroup(downloadedGroupKeyToRemove2)).isNull();
+    assertThat(readDownloadedFileGroup(downloadedGroupKeyToKeep)).isNotNull();
+    assertThat(readPendingFileGroup(pendingGroupKeyToKeep)).isNotNull();
+
+    assertThat(fileGroupsMetadata.getAllFreshGroups().get())
+        .containsExactly(
+            Pair.create(downloadedGroupKeyToKeep, downloadedGroupToKeep),
+            Pair.create(pendingGroupKeyToKeep, pendingGroupToKeep));
+    assertThat(fileGroupsMetadata.getAllStaleGroups().get())
+        .containsExactly(
+            downloadedGroupToRemove1.toBuilder()
+                .setBookkeeping(
+                    downloadedGroupToRemove1.getBookkeeping().toBuilder()
+                        .setStaleExpirationDate(1)
+                        .build())
+                .build(),
+            downloadedGroupToRemove2.toBuilder()
+                .setBookkeeping(
+                    downloadedGroupToRemove2.getBookkeeping().toBuilder()
+                        .setStaleExpirationDate(1)
+                        .build())
+                .build());
+
+    verify(mockDownloader, times(0)).stopDownloading(pendingFileUri1);
+  }
+
+  @Test
+  public void testRemoveFileGroups_whenMatchingBothVersions_performsRemove() throws Exception {
+    // Create 2 file groups, each with 2 versions (downloaded and pending)
+    DataFileGroupInternal downloadedGroupToRemove1 =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1);
+    DataFileGroupInternal downloadedGroupToRemove2 =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP_2, 1);
+    DataFileGroupInternal pendingGroupToRemove1 =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder().build();
+    DataFileGroupInternal pendingGroupToRemove2 =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP_2, 1);
+
+    GroupKey.Builder groupKeyBuilder =
+        GroupKey.newBuilder().setOwnerPackage(context.getPackageName()).setDownloaded(true);
+    GroupKey downloadedGroupKeyToRemove1 = groupKeyBuilder.setGroupName(TEST_GROUP).build();
+    GroupKey downloadedGroupKeyToRemove2 = groupKeyBuilder.setGroupName(TEST_GROUP_2).build();
+    GroupKey pendingGroupKeyToRemove1 =
+        groupKeyBuilder.setGroupName(TEST_GROUP).setDownloaded(false).build();
+    GroupKey pendingGroupKeyToRemove2 =
+        groupKeyBuilder.setGroupName(TEST_GROUP_2).setDownloaded(false).build();
+
+    writeDownloadedFileGroup(downloadedGroupKeyToRemove1, downloadedGroupToRemove1);
+    writeDownloadedFileGroup(downloadedGroupKeyToRemove2, downloadedGroupToRemove2);
+    writePendingFileGroup(pendingGroupKeyToRemove1, pendingGroupToRemove1);
+    writePendingFileGroup(pendingGroupKeyToRemove2, pendingGroupToRemove2);
+
+    writeSharedFiles(
+        sharedFilesMetadata,
+        downloadedGroupToRemove1,
+        ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE));
+    writeSharedFiles(
+        sharedFilesMetadata,
+        downloadedGroupToRemove2,
+        ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE));
+    writeSharedFiles(
+        sharedFilesMetadata,
+        pendingGroupToRemove1,
+        ImmutableList.of(FileStatus.DOWNLOAD_IN_PROGRESS));
+    writeSharedFiles(
+        sharedFilesMetadata,
+        pendingGroupToRemove2,
+        ImmutableList.of(FileStatus.DOWNLOAD_IN_PROGRESS));
+
+    // NOTE: the downloaded version of keys are used in this call, but this shouldn't be relevant;
+    // both downloaded and pending versions of group keys should be checked for removal.
+    fileGroupManager
+        .removeFileGroups(
+            ImmutableList.of(downloadedGroupKeyToRemove1, downloadedGroupKeyToRemove2))
+        .get();
+
+    // Construct Pending File Uri to check that its download was cancelled
+    NewFileKey pendingFileKey1 =
+        MddTestUtil.createFileKeysForDataFileGroupInternal(pendingGroupToRemove1)[0];
+    NewFileKey pendingFileKey2 =
+        MddTestUtil.createFileKeysForDataFileGroupInternal(pendingGroupToRemove2)[0];
+    Uri pendingFileUri1 =
+        DirectoryUtil.getOnDeviceUri(
+            context,
+            pendingFileKey1.getAllowedReaders(),
+            pendingGroupToRemove1.getFile(0).getFileId(),
+            pendingFileKey1.getChecksum(),
+            mockSilentFeedback,
+            /* instanceId = */ Optional.absent(),
+            /* androidShared = */ false);
+    Uri pendingFileUri2 =
+        DirectoryUtil.getOnDeviceUri(
+            context,
+            pendingFileKey2.getAllowedReaders(),
+            pendingGroupToRemove2.getFile(0).getFileId(),
+            pendingFileKey2.getChecksum(),
+            mockSilentFeedback,
+            /* instanceId = */ Optional.absent(),
+            /* androidShared = */ false);
+
+    // Assert that matching pending groups are removed
+    assertThat(readDownloadedFileGroup(downloadedGroupKeyToRemove1)).isNull();
+    assertThat(readDownloadedFileGroup(downloadedGroupKeyToRemove2)).isNull();
+    assertThat(readPendingFileGroup(pendingGroupKeyToRemove1)).isNull();
+    assertThat(readPendingFileGroup(pendingGroupKeyToRemove2)).isNull();
+
+    assertThat(fileGroupsMetadata.getAllFreshGroups().get()).isEmpty();
+    assertThat(fileGroupsMetadata.getAllStaleGroups().get())
+        .containsExactly(
+            downloadedGroupToRemove1.toBuilder()
+                .setBookkeeping(
+                    downloadedGroupToRemove1.getBookkeeping().toBuilder()
+                        .setStaleExpirationDate(1)
+                        .build())
+                .build(),
+            downloadedGroupToRemove2.toBuilder()
+                .setBookkeeping(
+                    downloadedGroupToRemove2.getBookkeeping().toBuilder()
+                        .setStaleExpirationDate(1)
+                        .build())
+                .build());
+
+    verify(mockDownloader, times(1)).stopDownloading(pendingFileUri1);
+    verify(mockDownloader, times(1)).stopDownloading(pendingFileUri2);
+  }
+
+  @Test
+  public void testRemoveFileGroups_whenFilesAreReferencedByOtherGroups_doesNotCancelDownloads()
+      throws Exception {
+    // Setup 2 pending groups to remove that each contain a file referenced by a 3rd pending group
+    // that doesn't get removed. The pending file downloads referenced by the 3rd group should not
+    // be cancelled.
+    DataFileGroupInternal pendingGroupToKeep =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+    DataFileGroupInternal pendingGroupToRemove1 =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP_2, 1).toBuilder()
+            .addFile(pendingGroupToKeep.getFile(0))
+            .build();
+    DataFileGroupInternal pendingGroupToRemove2 =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP_3, 1).toBuilder()
+            .addFile(pendingGroupToKeep.getFile(1))
+            .build();
+
+    GroupKey.Builder groupKeyBuilder =
+        GroupKey.newBuilder().setOwnerPackage(context.getPackageName()).setDownloaded(false);
+    GroupKey pendingGroupKeyToKeep = groupKeyBuilder.setGroupName(TEST_GROUP).build();
+    GroupKey pendingGroupKeyToRemove1 = groupKeyBuilder.setGroupName(TEST_GROUP_2).build();
+    GroupKey pendingGroupKeyToRemove2 = groupKeyBuilder.setGroupName(TEST_GROUP_3).build();
+
+    writePendingFileGroup(pendingGroupKeyToKeep, pendingGroupToKeep);
+    writePendingFileGroup(pendingGroupKeyToRemove1, pendingGroupToRemove1);
+    writePendingFileGroup(pendingGroupKeyToRemove2, pendingGroupToRemove2);
+
+    writeSharedFiles(
+        sharedFilesMetadata,
+        pendingGroupToKeep,
+        ImmutableList.of(FileStatus.DOWNLOAD_IN_PROGRESS, FileStatus.DOWNLOAD_IN_PROGRESS));
+    writeSharedFiles(
+        sharedFilesMetadata,
+        pendingGroupToRemove1,
+        ImmutableList.of(FileStatus.DOWNLOAD_IN_PROGRESS, FileStatus.DOWNLOAD_IN_PROGRESS));
+    writeSharedFiles(
+        sharedFilesMetadata,
+        pendingGroupToRemove2,
+        ImmutableList.of(FileStatus.DOWNLOAD_IN_PROGRESS, FileStatus.DOWNLOAD_IN_PROGRESS));
+
+    fileGroupManager
+        .removeFileGroups(ImmutableList.of(pendingGroupKeyToRemove1, pendingGroupKeyToRemove2))
+        .get();
+
+    assertThat(readPendingFileGroup(pendingGroupKeyToRemove1)).isNull();
+    assertThat(readPendingFileGroup(pendingGroupKeyToRemove2)).isNull();
+    assertThat(readPendingFileGroup(pendingGroupKeyToKeep)).isNotNull();
+    assertThat(fileGroupsMetadata.getAllFreshGroups().get())
+        .containsExactly(Pair.create(pendingGroupKeyToKeep, pendingGroupToKeep));
+
+    // Get On Device Uris to check if file downloads were cancelled
+    List<Uri> uncancelledFileUris = getOnDeviceUrisForFileGroup(pendingGroupToKeep);
+    verify(mockDownloader, times(0)).stopDownloading(uncancelledFileUris.get(0));
+    verify(mockDownloader, times(0)).stopDownloading(uncancelledFileUris.get(1));
+
+    verify(mockDownloader, times(1))
+        .stopDownloading(getOnDeviceUrisForFileGroup(pendingGroupToRemove1).get(0));
+    verify(mockDownloader, times(1))
+        .stopDownloading(getOnDeviceUrisForFileGroup(pendingGroupToRemove2).get(0));
+  }
+
+  @Test
+  public void testRemoveFileGroups_whenRemovePendingGroupFails_doesNotContinue() throws Exception {
+    // Use Mocks to simulate failure scenario
+    resetFileGroupManager(mockFileGroupsMetadata, mockSharedFileManager);
+
+    DataFileGroupInternal pendingGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1);
+
+    GroupKey.Builder groupKeyBuilder =
+        GroupKey.newBuilder().setOwnerPackage(context.getPackageName());
+    GroupKey pendingGroupKey =
+        groupKeyBuilder.setGroupName(TEST_GROUP).setDownloaded(false).build();
+    GroupKey downloadedGroupKey =
+        groupKeyBuilder.setGroupName(TEST_GROUP_2).setDownloaded(true).build();
+
+    when(mockFileGroupsMetadata.read(pendingGroupKey))
+        .thenReturn(Futures.immediateFuture(pendingGroup));
+    when(mockFileGroupsMetadata.read(getPendingKey(downloadedGroupKey)))
+        .thenReturn(Futures.immediateFuture(null));
+    when(mockFileGroupsMetadata.removeAllGroupsWithKeys(groupKeysCaptor.capture()))
+        .thenReturn(Futures.immediateFuture(false));
+
+    ExecutionException ex =
+        assertThrows(
+            ExecutionException.class,
+            () ->
+                fileGroupManager
+                    .removeFileGroups(ImmutableList.of(pendingGroupKey, downloadedGroupKey))
+                    .get());
+    assertThat(ex).hasCauseThat().isInstanceOf(IOException.class);
+
+    verify(mockFileGroupsMetadata, times(0)).addStaleGroup(any());
+    verify(mockSharedFileManager, times(0)).cancelDownload(any());
+    verify(mockFileGroupsMetadata, times(1)).removeAllGroupsWithKeys(any());
+    List<GroupKey> attemptedRemoveKeys = groupKeysCaptor.getValue();
+    assertThat(attemptedRemoveKeys).containsExactly(pendingGroupKey);
+  }
+
+  @Test
+  public void testRemoveFileGroups_whenRemoveDownloadedGroupFails_doesNotContinue()
+      throws Exception {
+    // Use Mock FileGroupsMetadata to simulate failure scenario
+    resetFileGroupManager(mockFileGroupsMetadata, mockSharedFileManager);
+
+    DataFileGroupInternal pendingGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1);
+    DataFileGroupInternal downloadedGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP_2, 1);
+
+    GroupKey.Builder groupKeyBuilder =
+        GroupKey.newBuilder().setOwnerPackage(context.getPackageName());
+    GroupKey pendingGroupKey =
+        groupKeyBuilder.setGroupName(TEST_GROUP).setDownloaded(false).build();
+    GroupKey downloadedGroupKey =
+        groupKeyBuilder.setGroupName(TEST_GROUP_2).setDownloaded(true).build();
+
+    // Mock variations of group key reads
+    when(mockFileGroupsMetadata.read(pendingGroupKey))
+        .thenReturn(Futures.immediateFuture(pendingGroup));
+    when(mockFileGroupsMetadata.read(getPendingKey(downloadedGroupKey)))
+        .thenReturn(Futures.immediateFuture(null));
+    when(mockFileGroupsMetadata.read(downloadedGroupKey))
+        .thenReturn(Futures.immediateFuture(downloadedGroup));
+    when(mockFileGroupsMetadata.read(getDownloadedKey(pendingGroupKey)))
+        .thenReturn(Futures.immediateFuture(null));
+
+    // Return true for pending groups removed, but false for downloaded groups
+    when(mockFileGroupsMetadata.removeAllGroupsWithKeys(groupKeysCaptor.capture()))
+        .thenReturn(Futures.immediateFuture(true))
+        .thenReturn(Futures.immediateFuture(false));
+
+    ExecutionException ex =
+        assertThrows(
+            ExecutionException.class,
+            () ->
+                fileGroupManager
+                    .removeFileGroups(ImmutableList.of(pendingGroupKey, downloadedGroupKey))
+                    .get());
+    assertThat(ex).hasCauseThat().isInstanceOf(IOException.class);
+
+    verify(mockFileGroupsMetadata, times(0)).addStaleGroup(any());
+    verify(mockSharedFileManager, times(0)).cancelDownload(any());
+    verify(mockFileGroupsMetadata, times(2)).removeAllGroupsWithKeys(any());
+    List<List<GroupKey>> removeCallInvocations = groupKeysCaptor.getAllValues();
+    assertThat(removeCallInvocations.get(0)).containsExactly(pendingGroupKey);
+    assertThat(removeCallInvocations.get(1)).containsExactly(downloadedGroupKey);
+  }
+
+  @Test
+  public void testRemoveFileGroups_whenAddingStaleGroupFails_doesNotContinue() throws Exception {
+    // Use Mock FileGroupsMetadata to simulate failure scenario
+    resetFileGroupManager(mockFileGroupsMetadata, mockSharedFileManager);
+
+    DataFileGroupInternal pendingGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1);
+    DataFileGroupInternal downloadedGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP_2, 1);
+
+    GroupKey.Builder groupKeyBuilder =
+        GroupKey.newBuilder().setOwnerPackage(context.getPackageName());
+    GroupKey pendingGroupKey =
+        groupKeyBuilder.setGroupName(TEST_GROUP).setDownloaded(false).build();
+    GroupKey downloadedGroupKey =
+        groupKeyBuilder.setGroupName(TEST_GROUP_2).setDownloaded(true).build();
+
+    // Mock read group key variations
+    when(mockFileGroupsMetadata.read(pendingGroupKey))
+        .thenReturn(Futures.immediateFuture(pendingGroup));
+    when(mockFileGroupsMetadata.read(getPendingKey(downloadedGroupKey)))
+        .thenReturn(Futures.immediateFuture(null));
+    when(mockFileGroupsMetadata.read(downloadedGroupKey))
+        .thenReturn(Futures.immediateFuture(downloadedGroup));
+    when(mockFileGroupsMetadata.read(getDownloadedKey(pendingGroupKey)))
+        .thenReturn(Futures.immediateFuture(null));
+
+    // Always return true for remove calls
+    when(mockFileGroupsMetadata.removeAllGroupsWithKeys(groupKeysCaptor.capture()))
+        .thenReturn(Futures.immediateFuture(true));
+
+    // Fail when attempting to add a stale group
+    when(mockFileGroupsMetadata.addStaleGroup(downloadedGroup))
+        .thenReturn(Futures.immediateFuture(false));
+
+    ExecutionException ex =
+        assertThrows(
+            ExecutionException.class,
+            () ->
+                fileGroupManager
+                    .removeFileGroups(ImmutableList.of(pendingGroupKey, downloadedGroupKey))
+                    .get());
+    assertThat(ex).hasCauseThat().isInstanceOf(AggregateException.class);
+
+    verify(mockFileGroupsMetadata, times(1)).addStaleGroup(downloadedGroup);
+    verify(mockSharedFileManager, times(0)).cancelDownload(any());
+    verify(mockFileGroupsMetadata, times(2)).removeAllGroupsWithKeys(any());
+    List<List<GroupKey>> removeCallInvocations = groupKeysCaptor.getAllValues();
+    assertThat(removeCallInvocations.get(0)).containsExactly(pendingGroupKey);
+    assertThat(removeCallInvocations.get(1)).containsExactly(downloadedGroupKey);
+  }
+
+  @Test
+  public void testRemoveFileGroups_whenCancellingPendingDownloadFails_doesNotContinue()
+      throws Exception {
+    // Use Mock FileGroupsMetadata to simulate failure scenario
+    resetFileGroupManager(fileGroupsMetadata, mockSharedFileManager);
+
+    DataFileGroupInternal pendingGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1);
+    DataFileGroupInternal downloadedGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP_2, 1);
+
+    GroupKey.Builder groupKeyBuilder =
+        GroupKey.newBuilder().setOwnerPackage(context.getPackageName());
+    GroupKey pendingGroupKey =
+        groupKeyBuilder.setGroupName(TEST_GROUP).setDownloaded(false).build();
+    GroupKey downloadedGroupKey =
+        groupKeyBuilder.setGroupName(TEST_GROUP_2).setDownloaded(true).build();
+
+    writePendingFileGroup(pendingGroupKey, pendingGroup);
+    writeDownloadedFileGroup(downloadedGroupKey, downloadedGroup);
+    writeSharedFiles(
+        sharedFilesMetadata, pendingGroup, ImmutableList.of(FileStatus.DOWNLOAD_IN_PROGRESS));
+    writeSharedFiles(
+        sharedFilesMetadata, downloadedGroup, ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE));
+
+    // Fail when cancelling download
+    NewFileKey[] pendingFileKeys = MddTestUtil.createFileKeysForDataFileGroupInternal(pendingGroup);
+    when(mockSharedFileManager.cancelDownload(pendingFileKeys[0]))
+        .thenReturn(Futures.immediateFailedFuture(new Exception("Test failure")));
+
+    ExecutionException ex =
+        assertThrows(
+            ExecutionException.class,
+            () ->
+                fileGroupManager
+                    .removeFileGroups(ImmutableList.of(pendingGroupKey, downloadedGroupKey))
+                    .get());
+    assertThat(ex).hasCauseThat().isInstanceOf(AggregateException.class);
+
+    assertThat(readPendingFileGroup(pendingGroupKey)).isNull();
+    assertThat(readDownloadedFileGroup(downloadedGroupKey)).isNull();
+    assertThat(fileGroupsMetadata.getAllFreshGroups().get()).isEmpty();
+    assertThat(fileGroupsMetadata.getAllStaleGroups().get())
+        .containsExactly(
+            downloadedGroup.toBuilder()
+                .setBookkeeping(
+                    downloadedGroup.getBookkeeping().toBuilder().setStaleExpirationDate(1).build())
+                .build());
+  }
+
+  @Test
+  public void testRemoveFileGroups_whenMultipleVariantsExists_removesVariantsSpecified()
+      throws Exception {
+    // Create multiple variants of a group (default (empty), en, fr) and remove the default (empty)
+    // variant and en keys. Ensure that only the fr group remains.
+    GroupKey defaultGroupKey =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setOwnerPackage(context.getPackageName())
+            .build();
+    GroupKey enGroupKey = defaultGroupKey.toBuilder().setVariantId("en").build();
+    GroupKey frGroupKey = defaultGroupKey.toBuilder().setVariantId("fr").build();
+
+    DataFileGroupInternal defaultFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1);
+    DataFileGroupInternal enFileGroup = defaultFileGroup.toBuilder().setVariantId("en").build();
+    DataFileGroupInternal frFileGroup = defaultFileGroup.toBuilder().setVariantId("fr").build();
+
+    writePendingFileGroup(getPendingKey(defaultGroupKey), defaultFileGroup);
+    writePendingFileGroup(getPendingKey(enGroupKey), enFileGroup);
+    writePendingFileGroup(getPendingKey(frGroupKey), frFileGroup);
+
+    writeSharedFiles(
+        sharedFilesMetadata, defaultFileGroup, ImmutableList.of(FileStatus.DOWNLOAD_IN_PROGRESS));
+    writeSharedFiles(
+        sharedFilesMetadata, enFileGroup, ImmutableList.of(FileStatus.DOWNLOAD_IN_PROGRESS));
+    writeSharedFiles(
+        sharedFilesMetadata, frFileGroup, ImmutableList.of(FileStatus.DOWNLOAD_IN_PROGRESS));
+
+    // Assert that all file groups share the same file even through the variants are different
+    assertThat(sharedFilesMetadata.getAllFileKeys().get()).hasSize(1);
+
+    {
+      // Perfrom removal once and check that the correct groups get removed
+      fileGroupManager.removeFileGroups(ImmutableList.of(defaultGroupKey, enGroupKey)).get();
+
+      assertThat(fileGroupsMetadata.getAllGroupKeys().get())
+          .comparingElementsUsing(GROUP_KEY_TO_VARIANT)
+          .containsExactly("fr");
+      assertThat(fileGroupsMetadata.getAllFreshGroups().get())
+          .comparingElementsUsing(KEY_GROUP_PAIR_TO_VARIANT_PAIR)
+          .containsExactly(Pair.create("fr", "fr"));
+
+      assertThat(sharedFilesMetadata.getAllFileKeys().get()).hasSize(1);
+    }
+
+    {
+      // Perform remove again and verify that there is no change in state
+      fileGroupManager.removeFileGroups(ImmutableList.of(defaultGroupKey, enGroupKey)).get();
+
+      assertThat(fileGroupsMetadata.getAllGroupKeys().get())
+          .comparingElementsUsing(GROUP_KEY_TO_VARIANT)
+          .containsExactly("fr");
+      assertThat(fileGroupsMetadata.getAllFreshGroups().get())
+          .comparingElementsUsing(KEY_GROUP_PAIR_TO_VARIANT_PAIR)
+          .containsExactly(Pair.create("fr", "fr"));
+
+      assertThat(sharedFilesMetadata.getAllFileKeys().get()).hasSize(1);
+    }
+  }
+
+  @Test
+  public void testGetDownloadedGroup() throws Exception {
+    assertThat(fileGroupManager.getFileGroup(testKey, true).get()).isNull();
+
+    DataFileGroupInternal dataFileGroup =
+        MddTestUtil.createDownloadedDataFileGroupInternal(TEST_GROUP, 2);
+    writeDownloadedFileGroup(testKey, dataFileGroup);
+
+    DataFileGroupInternal downloadedGroup = fileGroupManager.getFileGroup(testKey, true).get();
+    MddTestUtil.assertMessageEquals(dataFileGroup, downloadedGroup);
+  }
+
+  @Test
+  public void testGetDownloadedGroup_whenMultipleVariantsExists_getsCorrectGroup()
+      throws Exception {
+    GroupKey defaultGroupKey =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setOwnerPackage(context.getPackageName())
+            .build();
+    GroupKey enGroupKey = defaultGroupKey.toBuilder().setVariantId("en").build();
+
+    // Initially, assert that groups don't exist
+    assertThat(fileGroupManager.getFileGroup(defaultGroupKey, true).get()).isNull();
+    assertThat(fileGroupManager.getFileGroup(enGroupKey, true).get()).isNull();
+
+    // Create groups and write them
+    DataFileGroupInternal defaultFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1);
+    DataFileGroupInternal enFileGroup = defaultFileGroup.toBuilder().setVariantId("en").build();
+
+    writeDownloadedFileGroup(getDownloadedKey(defaultGroupKey), defaultFileGroup);
+    writeDownloadedFileGroup(getDownloadedKey(enGroupKey), enFileGroup);
+
+    // Assert the correct group is returned for each key
+    DataFileGroupInternal groupForDefaultKey =
+        fileGroupManager.getFileGroup(defaultGroupKey, true).get();
+    MddTestUtil.assertMessageEquals(defaultFileGroup, groupForDefaultKey);
+
+    DataFileGroupInternal groupForEnKey = fileGroupManager.getFileGroup(enGroupKey, true).get();
+    MddTestUtil.assertMessageEquals(enFileGroup, groupForEnKey);
+  }
+
+  @Test
+  public void testGetPendingGroup() throws Exception {
+    assertThat(fileGroupManager.getFileGroup(testKey, false).get()).isNull();
+
+    DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+    writePendingFileGroup(testKey, dataFileGroup);
+
+    DataFileGroupInternal pendingGroup = fileGroupManager.getFileGroup(testKey, false).get();
+    MddTestUtil.assertMessageEquals(dataFileGroup, pendingGroup);
+  }
+
+  @Test
+  public void testGetPendingGroup_whenMultipleVariantsExists_getsCorrectGroup() throws Exception {
+    GroupKey defaultGroupKey =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setOwnerPackage(context.getPackageName())
+            .build();
+    GroupKey enGroupKey = defaultGroupKey.toBuilder().setVariantId("en").build();
+
+    // Initially, assert that groups don't exist
+    assertThat(fileGroupManager.getFileGroup(defaultGroupKey, false).get()).isNull();
+    assertThat(fileGroupManager.getFileGroup(enGroupKey, false).get()).isNull();
+
+    // Create groups and write them
+    DataFileGroupInternal defaultFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1);
+    DataFileGroupInternal enFileGroup = defaultFileGroup.toBuilder().setVariantId("en").build();
+
+    writePendingFileGroup(getPendingKey(defaultGroupKey), defaultFileGroup);
+    writePendingFileGroup(getPendingKey(enGroupKey), enFileGroup);
+
+    // Assert the correct group is returned for each key
+    DataFileGroupInternal groupForDefaultKey =
+        fileGroupManager.getFileGroup(defaultGroupKey, false).get();
+    MddTestUtil.assertMessageEquals(defaultFileGroup, groupForDefaultKey);
+
+    DataFileGroupInternal groupForEnKey = fileGroupManager.getFileGroup(enGroupKey, false).get();
+    MddTestUtil.assertMessageEquals(enFileGroup, groupForEnKey);
+  }
+
+  @Test
+  public void testSetGroupActivation_deactivationRemovesGroupsRequiringActivation()
+      throws Exception {
+    flags.enableDelayedDownload = Optional.of(true);
+    // Create 2 groups, one of which requires device side activation.
+    DataFileGroupInternal.Builder fileGroup1 =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2).toBuilder();
+    DownloadConditions.Builder downloadConditions =
+        DownloadConditions.newBuilder()
+            .setActivatingCondition(ActivatingCondition.DEVICE_ACTIVATED);
+    fileGroup1.setDownloadConditions(downloadConditions);
+
+    DataFileGroupInternal fileGroup2 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP_2, 3);
+
+    // Activate both group keys and add groups to FileGroupManager.
+    assertThat(fileGroupManager.setGroupActivation(testKey, true).get()).isTrue();
+
+    assertThat(fileGroupManager.addGroupForDownload(testKey, fileGroup1.build()).get()).isTrue();
+
+    assertThat(fileGroupManager.setGroupActivation(testKey2, true).get()).isTrue();
+
+    assertThat(fileGroupManager.addGroupForDownload(testKey2, fileGroup2).get()).isTrue();
+
+    // Add a downloaded version of the second group, that requires device side activation.
+    DataFileGroupInternal downloadedfileGroup2 =
+        MddTestUtil.createDownloadedDataFileGroupInternal(TEST_GROUP_2, 1);
+    downloadConditions = DownloadConditions.newBuilder();
+    downloadedfileGroup2 =
+        downloadedfileGroup2.toBuilder()
+            .setDownloadConditions(
+                downloadConditions.setActivatingCondition(ActivatingCondition.DEVICE_ACTIVATED))
+            .build();
+    writeDownloadedFileGroup(testKey2, downloadedfileGroup2);
+
+    // Deactivate both group keys, and check that the groups that required activation are deleted.
+    assertThat(fileGroupManager.setGroupActivation(testKey, false).get()).isTrue();
+    // Setting group activation to false will only remove groups that have
+    // ActivatingCondition.DEVICE_ACTIVATED. So the pending version will remain, while the
+    // downloaded one is removed.
+    assertThat(fileGroupManager.setGroupActivation(testKey2, false).get()).isTrue();
+
+    assertThat(readPendingFileGroup(testKey)).isNull();
+    assertThat(readPendingFileGroup(testKey2)).isNotNull();
+    assertThat(readDownloadedFileGroup(testKey2)).isNull();
+  }
+
+  @Test
+  public void testImportFilesIntoFileGroup_whenExistingGroupDoesNotExist_fails() throws Exception {
+    DataFile inlineFile =
+        DataFile.newBuilder()
+            .setFileId("inline-file")
+            .setChecksum("abc")
+            .setUrlToDownload("inlinefile:sha1:abc")
+            .build();
+    ImmutableList<DataFile> updatedDataFileList = ImmutableList.of(inlineFile);
+
+    GroupKey groupKey = GroupKey.newBuilder().setGroupName("non-existing-group").build();
+
+    ExecutionException ex =
+        assertThrows(
+            ExecutionException.class,
+            () ->
+                fileGroupManager
+                    .importFilesIntoFileGroup(
+                        groupKey,
+                        /* buildId = */ 0,
+                        /* variantId = */ "",
+                        updatedDataFileList,
+                        /* inlineFileMap = */ ImmutableMap.of(),
+                        /* customPropertyOptional = */ Optional.absent(),
+                        noCustomValidation())
+                    .get());
+
+    assertThat(ex).hasCauseThat().isInstanceOf(DownloadException.class);
+    DownloadException dex = (DownloadException) ex.getCause();
+    assertThat(dex.getDownloadResultCode()).isEqualTo(DownloadResultCode.GROUP_NOT_FOUND_ERROR);
+  }
+
+  @Test
+  public void testImportFilesIntoFileGroup_whenExistingPendingGroupDoesNotMatchIdentifiers_fails()
+      throws Exception {
+    // Set up existing pending file group
+    DataFileGroupInternal existingFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 0).toBuilder()
+            .addFile(
+                DataFile.newBuilder()
+                    .setFileId("inline-file")
+                    .setChecksum("abc")
+                    .setUrlToDownload("inlinefile:sha1:abc")
+                    .build())
+            .build();
+    GroupKey groupKey = GroupKey.newBuilder().setGroupName(TEST_GROUP).build();
+
+    writePendingFileGroup(getPendingKey(groupKey), existingFileGroup);
+    writeSharedFiles(
+        sharedFilesMetadata, existingFileGroup, ImmutableList.of(FileStatus.DOWNLOAD_IN_PROGRESS));
+
+    ExecutionException ex =
+        assertThrows(
+            ExecutionException.class,
+            () ->
+                fileGroupManager
+                    .importFilesIntoFileGroup(
+                        groupKey,
+                        /* buildId = */ 1,
+                        /* variantId = */ "",
+                        /* updatedDataFileList = */ ImmutableList.of(),
+                        /* inlineFileMap = */ ImmutableMap.of(),
+                        /* customPropertyOptional = */ Optional.absent(),
+                        noCustomValidation())
+                    .get());
+
+    assertThat(ex).hasCauseThat().isInstanceOf(DownloadException.class);
+    DownloadException dex = (DownloadException) ex.getCause();
+    assertThat(dex.getDownloadResultCode()).isEqualTo(DownloadResultCode.GROUP_NOT_FOUND_ERROR);
+  }
+
+  @Test
+  public void
+      testImportFilesIntoFileGroup_whenExistingDownloadedGroupDoesNotMatchIdentifiers_fails()
+          throws Exception {
+    // Set up existing downloaded file group
+    DataFileGroupInternal existingFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 0).toBuilder()
+            .addFile(
+                DataFile.newBuilder()
+                    .setFileId("inline-file")
+                    .setChecksum("abc")
+                    .setUrlToDownload("inlinefile:sha1:abc")
+                    .build())
+            .build();
+    GroupKey groupKey = GroupKey.newBuilder().setGroupName(TEST_GROUP).build();
+
+    writeDownloadedFileGroup(getDownloadedKey(groupKey), existingFileGroup);
+    writeSharedFiles(
+        sharedFilesMetadata, existingFileGroup, ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE));
+
+    ExecutionException ex =
+        assertThrows(
+            ExecutionException.class,
+            () ->
+                fileGroupManager
+                    .importFilesIntoFileGroup(
+                        groupKey,
+                        /* buildId = */ 0,
+                        /* variantId = */ "testvariant",
+                        /* updatedDataFileList = */ ImmutableList.of(),
+                        /* inlineFileMap = */ ImmutableMap.of(),
+                        /* customPropertyOptional = */ Optional.absent(),
+                        noCustomValidation())
+                    .get());
+
+    assertThat(ex).hasCauseThat().isInstanceOf(DownloadException.class);
+    DownloadException dex = (DownloadException) ex.getCause();
+    assertThat(dex.getDownloadResultCode()).isEqualTo(DownloadResultCode.GROUP_NOT_FOUND_ERROR);
+  }
+
+  @Test
+  public void testImportFilesIntoFileGroup_whenBuildIdDoesNotMatch_fails() throws Exception {
+    // Set up existing pending/downloaded groups and check that they do not match due to build ID
+    // differences.
+
+    // Any can pack proto messages only, so use StringValue.
+    Any customProperty =
+        Any.parseFrom(
+            StringValue.of("testCustomProperty").toByteString(),
+            ExtensionRegistryLite.getEmptyRegistry());
+    DataFileGroupInternal existingDownloadedFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 0).toBuilder()
+            .setBuildId(1)
+            .setVariantId("testvariant")
+            .setCustomProperty(customProperty)
+            .build();
+    DataFileGroupInternal existingPendingFileGroup =
+        existingDownloadedFileGroup.toBuilder().setBuildId(2).build();
+
+    GroupKey groupKey = GroupKey.newBuilder().setGroupName(TEST_GROUP).build();
+
+    writeDownloadedFileGroup(getDownloadedKey(groupKey), existingDownloadedFileGroup);
+    writePendingFileGroup(getPendingKey(groupKey), existingPendingFileGroup);
+
+    ExecutionException ex =
+        assertThrows(
+            ExecutionException.class,
+            () ->
+                fileGroupManager
+                    .importFilesIntoFileGroup(
+                        groupKey,
+                        /* buildId = */ 3,
+                        /* variantId = */ "testvariant",
+                        /* updatedDataFileList = */ ImmutableList.of(),
+                        /* inlineFileMap = */ ImmutableMap.of(),
+                        /* customPropertyOptional = */ Optional.of(customProperty),
+                        noCustomValidation())
+                    .get());
+
+    assertThat(ex).hasCauseThat().isInstanceOf(DownloadException.class);
+    DownloadException dex = (DownloadException) ex.getCause();
+    assertThat(dex.getDownloadResultCode()).isEqualTo(DownloadResultCode.GROUP_NOT_FOUND_ERROR);
+  }
+
+  @Test
+  public void testImportFilesIntoFileGroup_whenVariantIdDoesNotMatch_fails() throws Exception {
+    // Set up existing pending/downloaded groups and check that they do not match due to variant ID
+    // differences.
+
+    // Any can pack proto messages only, so use StringValue.
+    Any customProperty =
+        Any.parseFrom(
+            StringValue.of("testCustomProperty").toByteString(),
+            ExtensionRegistryLite.getEmptyRegistry());
+    DataFileGroupInternal existingDownloadedFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 0).toBuilder()
+            .setBuildId(1)
+            .setVariantId("testvariant")
+            .setCustomProperty(customProperty)
+            .build();
+    DataFileGroupInternal existingPendingFileGroup =
+        existingDownloadedFileGroup.toBuilder().setVariantId("testvariant2").build();
+
+    GroupKey groupKey = GroupKey.newBuilder().setGroupName(TEST_GROUP).build();
+
+    writeDownloadedFileGroup(getDownloadedKey(groupKey), existingDownloadedFileGroup);
+    writePendingFileGroup(getPendingKey(groupKey), existingPendingFileGroup);
+
+    ExecutionException ex =
+        assertThrows(
+            ExecutionException.class,
+            () ->
+                fileGroupManager
+                    .importFilesIntoFileGroup(
+                        groupKey,
+                        /* buildId = */ 1,
+                        /* variantId = */ "testvariant3",
+                        /* updatedDataFileList = */ ImmutableList.of(),
+                        /* inlineFileMap = */ ImmutableMap.of(),
+                        /* customPropertyOptional = */ Optional.of(customProperty),
+                        noCustomValidation())
+                    .get());
+
+    assertThat(ex).hasCauseThat().isInstanceOf(DownloadException.class);
+    DownloadException dex = (DownloadException) ex.getCause();
+    assertThat(dex.getDownloadResultCode()).isEqualTo(DownloadResultCode.GROUP_NOT_FOUND_ERROR);
+  }
+
+  @Test
+  public void testImportFilesIntoFileGroup_whenCustomPropertyDoesNotMatch_whenDueToMismatch_fails()
+      throws Exception {
+    // Set up existing pending/downloaded groups and check that they do not match due to custom
+    // property differences.
+
+    // Any can pack proto messages only, so use StringValue.
+    Any downloadedCustomProperty =
+        Any.parseFrom(
+            StringValue.of("testDownloadedCustomProperty").toByteString(),
+            ExtensionRegistryLite.getEmptyRegistry());
+    Any pendingCustomProperty =
+        Any.parseFrom(
+            StringValue.of("testPendingCustomProperty").toByteString(),
+            ExtensionRegistryLite.getEmptyRegistry());
+    Any mismatchedCustomProperty =
+        Any.parseFrom(
+            StringValue.of("testMismatcheCustomProperty").toByteString(),
+            ExtensionRegistryLite.getEmptyRegistry());
+
+    DataFileGroupInternal existingDownloadedFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 0).toBuilder()
+            .setBuildId(1)
+            .setVariantId("testvariant")
+            .setCustomProperty(downloadedCustomProperty)
+            .build();
+    DataFileGroupInternal existingPendingFileGroup =
+        existingDownloadedFileGroup.toBuilder().setCustomProperty(pendingCustomProperty).build();
+
+    GroupKey groupKey = GroupKey.newBuilder().setGroupName(TEST_GROUP).build();
+
+    writeDownloadedFileGroup(getDownloadedKey(groupKey), existingDownloadedFileGroup);
+    writePendingFileGroup(getPendingKey(groupKey), existingPendingFileGroup);
+
+    ExecutionException ex =
+        assertThrows(
+            ExecutionException.class,
+            () ->
+                fileGroupManager
+                    .importFilesIntoFileGroup(
+                        groupKey,
+                        /* buildId = */ 1,
+                        /* variantId = */ "testvariant",
+                        /* updatedDataFileList = */ ImmutableList.of(),
+                        /* inlineFileMap = */ ImmutableMap.of(),
+                        /* customPropertyOptional = */ Optional.of(mismatchedCustomProperty),
+                        noCustomValidation())
+                    .get());
+
+    assertThat(ex).hasCauseThat().isInstanceOf(DownloadException.class);
+    DownloadException dex = (DownloadException) ex.getCause();
+    assertThat(dex.getDownloadResultCode()).isEqualTo(DownloadResultCode.GROUP_NOT_FOUND_ERROR);
+  }
+
+  @Test
+  public void
+      testImportFilesIntoFileGroup_whenCustomPropertyDoesNotMatch_whenDueToBeingAbsent_fails()
+          throws Exception {
+    // Set up existing pending/downloaded groups and check that they do not match due to custom
+    // property differences.
+
+    // Any can pack proto messages only, so use StringValue.
+    Any downloadedCustomProperty =
+        Any.parseFrom(
+            StringValue.of("testDownloadedCustomProperty").toByteString(),
+            ExtensionRegistryLite.getEmptyRegistry());
+    Any pendingCustomProperty =
+        Any.parseFrom(
+            StringValue.of("testPendingCustomProperty").toByteString(),
+            ExtensionRegistryLite.getEmptyRegistry());
+
+    DataFileGroupInternal existingDownloadedFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 0).toBuilder()
+            .setBuildId(1)
+            .setVariantId("testvariant")
+            .setCustomProperty(downloadedCustomProperty)
+            .build();
+    DataFileGroupInternal existingPendingFileGroup =
+        existingDownloadedFileGroup.toBuilder().setCustomProperty(pendingCustomProperty).build();
+
+    GroupKey groupKey = GroupKey.newBuilder().setGroupName(TEST_GROUP).build();
+
+    writeDownloadedFileGroup(getDownloadedKey(groupKey), existingDownloadedFileGroup);
+    writePendingFileGroup(getPendingKey(groupKey), existingPendingFileGroup);
+
+    ExecutionException ex =
+        assertThrows(
+            ExecutionException.class,
+            () ->
+                fileGroupManager
+                    .importFilesIntoFileGroup(
+                        groupKey,
+                        /* buildId = */ 1,
+                        /* variantId = */ "testvariant",
+                        /* updatedDataFileList = */ ImmutableList.of(),
+                        /* inlineFileMap = */ ImmutableMap.of(),
+                        /* customPropertyOptional = */ Optional.absent(),
+                        noCustomValidation())
+                    .get());
+
+    assertThat(ex).hasCauseThat().isInstanceOf(DownloadException.class);
+    DownloadException dex = (DownloadException) ex.getCause();
+    assertThat(dex.getDownloadResultCode()).isEqualTo(DownloadResultCode.GROUP_NOT_FOUND_ERROR);
+  }
+
+  @Test
+  public void testImportFilesIntoFileGroup_whenUnableToReserveNewFiles_fails() throws Exception {
+    // Reset with mock SharedFileManager to force failures
+    resetFileGroupManager(fileGroupsMetadata, mockSharedFileManager);
+
+    // Create existing file group and a new file group that will add an inline file (which needs to
+    // be reserved in SharedFileManager).
+    DataFileGroupInternal existingFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1);
+    ImmutableList<DataFile> updatedDataFileList =
+        ImmutableList.of(
+            DataFile.newBuilder()
+                .setFileId("inline-file")
+                .setChecksum("abc")
+                .setUrlToDownload("inlinefile:sha1:abc")
+                .build());
+
+    GroupKey groupKey = GroupKey.newBuilder().setGroupName(TEST_GROUP).build();
+
+    writeDownloadedFileGroup(getDownloadedKey(groupKey), existingFileGroup);
+    writeSharedFiles(
+        sharedFilesMetadata, existingFileGroup, ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE));
+
+    NewFileKey newInlineFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(
+            updatedDataFileList.get(0), existingFileGroup.getAllowedReadersEnum());
+    NewFileKey existingFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(
+            existingFileGroup.getFile(0), existingFileGroup.getAllowedReadersEnum());
+
+    when(mockSharedFileManager.reserveFileEntry(newInlineFileKey))
+        .thenReturn(Futures.immediateFuture(false));
+    when(mockSharedFileManager.reserveFileEntry(existingFileKey))
+        .thenReturn(Futures.immediateFuture(true));
+
+    ExecutionException ex =
+        assertThrows(
+            ExecutionException.class,
+            () ->
+                fileGroupManager
+                    .importFilesIntoFileGroup(
+                        groupKey,
+                        /* buildId = */ 0,
+                        /* variantId = */ "",
+                        /* updatedDataFileList = */ updatedDataFileList,
+                        /* inlineFileMap = */ ImmutableMap.of(),
+                        /* customPropertyOptional = */ Optional.absent(),
+                        noCustomValidation())
+                    .get());
+    assertThat(ex).hasCauseThat().isInstanceOf(DownloadException.class);
+    DownloadException dex = (DownloadException) ex.getCause();
+    assertThat(dex.getDownloadResultCode())
+        .isEqualTo(DownloadResultCode.UNABLE_TO_RESERVE_FILE_ENTRY);
+  }
+
+  @Test
+  public void
+      testImportFilesIntoFileGroup_whenNoNewInlineFilesSpecifiedAndFilesDownloaded_completes()
+          throws Exception {
+    // Create a group that has 1 standard file and 1 inline file, both downloaded
+    DataFileGroupInternal dataFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder()
+            .addFile(
+                DataFile.newBuilder()
+                    .setFileId("inline-file")
+                    .setChecksum("abc")
+                    .setUrlToDownload("inlinefile:sha1:abc")
+                    .build())
+            .build();
+    ImmutableMap<String, FileSource> inlineFileMap =
+        ImmutableMap.of(
+            "inline-file", FileSource.ofByteString(ByteString.copyFromUtf8("TEST_CONTENT")));
+
+    GroupKey groupKey = GroupKey.newBuilder().setGroupName(TEST_GROUP).build();
+
+    writeDownloadedFileGroup(getDownloadedKey(groupKey), dataFileGroup);
+    writeSharedFiles(
+        sharedFilesMetadata,
+        dataFileGroup,
+        ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE, FileStatus.DOWNLOAD_COMPLETE));
+
+    fileGroupManager
+        .importFilesIntoFileGroup(
+            groupKey,
+            /* buildId = */ 0,
+            /* variantId = */ "",
+            /* updatedDataFileList = */ ImmutableList.of(),
+            inlineFileMap,
+            /* customPropertyOptional = */ Optional.absent(),
+            noCustomValidation())
+        .get();
+
+    // Since no new files were specified, the group should remain the same (downloaded).
+    DataFileGroupInternal downloadedFileGroup =
+        fileGroupsMetadata.read(getDownloadedKey(groupKey)).get();
+    assertThat(downloadedFileGroup.getGroupName()).isEqualTo(dataFileGroup.getGroupName());
+    assertThat(downloadedFileGroup.getBuildId()).isEqualTo(dataFileGroup.getBuildId());
+    assertThat(downloadedFileGroup.getVariantId()).isEqualTo(dataFileGroup.getVariantId());
+    assertThat(downloadedFileGroup.getFileList())
+        .containsExactlyElementsIn(dataFileGroup.getFileList());
+  }
+
+  @Test
+  public void testImportFilesIntoFileGroup_whenNoNewFilesSpecifiedAndFilesPending_completes()
+      throws Exception {
+    DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1);
+
+    GroupKey groupKey = GroupKey.newBuilder().setGroupName(TEST_GROUP).build();
+
+    writePendingFileGroup(getPendingKey(groupKey), dataFileGroup);
+    writeSharedFiles(
+        sharedFilesMetadata, dataFileGroup, ImmutableList.of(FileStatus.DOWNLOAD_IN_PROGRESS));
+
+    fileGroupManager
+        .importFilesIntoFileGroup(
+            groupKey,
+            /* buildId = */ 0,
+            /* variantId = */ "",
+            /* updatedDataFileList = */ ImmutableList.of(),
+            /* inlineFileMap = */ ImmutableMap.of(),
+            /* customPropertyOptional = */ Optional.absent(),
+            noCustomValidation())
+        .get();
+
+    // Since no new files were specified, the group should remain the same (pending).
+    DataFileGroupInternal pendingFileGroup = fileGroupsMetadata.read(getPendingKey(groupKey)).get();
+    assertThat(pendingFileGroup.getGroupName()).isEqualTo(dataFileGroup.getGroupName());
+    assertThat(pendingFileGroup.getBuildId()).isEqualTo(dataFileGroup.getBuildId());
+    assertThat(pendingFileGroup.getVariantId()).isEqualTo(dataFileGroup.getVariantId());
+    assertThat(pendingFileGroup.getFileList()).isEqualTo(dataFileGroup.getFileList());
+  }
+
+  @Test
+  public void testImportFilesIntoFileGroup_whenImportingInlineFileAndPending_mergesGroup()
+      throws Exception {
+    // Set up an existing pending file group and a new inline file to merge
+    DataFileGroupInternal existingPendingFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1);
+    ImmutableList<DataFile> updatedDataFileList =
+        ImmutableList.of(
+            DataFile.newBuilder()
+                .setFileId("inline-file")
+                .setChecksum("abc")
+                .setUrlToDownload("inlinefile:sha1:abc")
+                .build());
+    ImmutableMap<String, FileSource> inlineFileMap =
+        ImmutableMap.of(
+            "inline-file", FileSource.ofByteString(ByteString.copyFromUtf8("TEST_CONTENT")));
+
+    GroupKey groupKey = GroupKey.newBuilder().setGroupName(TEST_GROUP).build();
+
+    writePendingFileGroup(getPendingKey(groupKey), existingPendingFileGroup);
+    writeSharedFiles(
+        sharedFilesMetadata,
+        existingPendingFileGroup,
+        ImmutableList.of(FileStatus.DOWNLOAD_IN_PROGRESS));
+
+    // TODO: remove once SFM can perform import
+    // write inline file as downloaded so FGM can find it
+    NewFileKey newInlineFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(
+            updatedDataFileList.get(0), existingPendingFileGroup.getAllowedReadersEnum());
+    SharedFile newInlineSharedFile =
+        SharedFile.newBuilder()
+            .setFileName(updatedDataFileList.get(0).getFileId())
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .build();
+    sharedFilesMetadata.write(newInlineFileKey, newInlineSharedFile).get();
+
+    fileGroupManager
+        .importFilesIntoFileGroup(
+            groupKey,
+            /* buildId = */ 0,
+            /* variantId = */ "",
+            updatedDataFileList,
+            inlineFileMap,
+            /* customPropertyOptional = */ Optional.absent(),
+            noCustomValidation())
+        .get();
+
+    // Check that resulting file group remains pending, but should have both files merged together
+    DataFileGroupInternal pendingFileGroupAfterImport =
+        fileGroupsMetadata.read(getPendingKey(groupKey)).get();
+    assertThat(pendingFileGroupAfterImport.getFileCount()).isEqualTo(2);
+    assertThat(pendingFileGroupAfterImport.getFileList())
+        .containsExactly(existingPendingFileGroup.getFile(0), updatedDataFileList.get(0));
+  }
+
+  @Test
+  public void testImportFilesIntoFileGroup_whenImportingInlineFileAndDownloaded_mergesGroup()
+      throws Exception {
+    // Set up an existing downloaded file group and a new file group with an inline file to merge
+    DataFileGroupInternal existingDownloadedFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1);
+    ImmutableList<DataFile> updatedDataFileList =
+        ImmutableList.of(
+            DataFile.newBuilder()
+                .setFileId("inline-file")
+                .setChecksum("abc")
+                .setUrlToDownload("inlinefile:sha1:abc")
+                .build());
+    ImmutableMap<String, FileSource> inlineFileMap =
+        ImmutableMap.of(
+            "inline-file", FileSource.ofByteString(ByteString.copyFromUtf8("TEST_CONTENT")));
+
+    GroupKey groupKey = GroupKey.newBuilder().setGroupName(TEST_GROUP).build();
+
+    writeDownloadedFileGroup(getDownloadedKey(groupKey), existingDownloadedFileGroup);
+    writeSharedFiles(
+        sharedFilesMetadata,
+        existingDownloadedFileGroup,
+        ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE));
+
+    // TODO: remove once SFM can perform import
+    // write inline file as downloaded so FGM can find it
+    NewFileKey newInlineFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(
+            updatedDataFileList.get(0), existingDownloadedFileGroup.getAllowedReadersEnum());
+    SharedFile newInlineSharedFile =
+        SharedFile.newBuilder()
+            .setFileName(updatedDataFileList.get(0).getFileId())
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .build();
+    sharedFilesMetadata.write(newInlineFileKey, newInlineSharedFile).get();
+
+    fileGroupManager
+        .importFilesIntoFileGroup(
+            groupKey,
+            /* buildId = */ 0,
+            /* variantId = */ "",
+            updatedDataFileList,
+            inlineFileMap,
+            /* customPropertyOptional = */ Optional.absent(),
+            noCustomValidation())
+        .get();
+
+    // Check that resulting file group is downloaded, but should have both files merged together
+    DataFileGroupInternal downloadedFileGroupAfterImport =
+        fileGroupsMetadata.read(getDownloadedKey(groupKey)).get();
+    assertThat(downloadedFileGroupAfterImport.getFileCount()).isEqualTo(2);
+    assertThat(downloadedFileGroupAfterImport.getFileList())
+        .containsExactly(existingDownloadedFileGroup.getFile(0), updatedDataFileList.get(0));
+  }
+
+  @Test
+  public void testImportFilesIntoFileGroup_whenMatchesDownloadedButNotPending_importsToDownloaded()
+      throws Exception {
+    // Set up an existing pending file group, an existing downloaded file group and a new file
+    // group that matches the downloaded file group
+    DataFileGroupInternal existingPendingFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1);
+    DataFileGroupInternal existingDownloadedFileGroup =
+        existingPendingFileGroup.toBuilder()
+            .clearFile()
+            .addFile(MddTestUtil.createDataFile("downloaded-file", 0))
+            .setBuildId(10)
+            .build();
+    ImmutableList<DataFile> updatedDataFileList =
+        ImmutableList.of(
+            DataFile.newBuilder()
+                .setFileId("inline-file")
+                .setChecksum("abc")
+                .setUrlToDownload("inlinefile:abc")
+                .build());
+    ImmutableMap<String, FileSource> inlineFileMap =
+        ImmutableMap.of(
+            "inline-file", FileSource.ofByteString(ByteString.copyFromUtf8("TEST_CONTENT")));
+
+    GroupKey groupKey = GroupKey.newBuilder().setGroupName(TEST_GROUP).build();
+
+    writePendingFileGroup(getPendingKey(groupKey), existingPendingFileGroup);
+    writeDownloadedFileGroup(getDownloadedKey(groupKey), existingDownloadedFileGroup);
+
+    writeSharedFiles(
+        sharedFilesMetadata,
+        existingPendingFileGroup,
+        ImmutableList.of(FileStatus.DOWNLOAD_IN_PROGRESS));
+    writeSharedFiles(
+        sharedFilesMetadata,
+        existingDownloadedFileGroup,
+        ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE));
+
+    // TODO: remove once SFM can perform import
+    // write inline file as downloaded so FGM can find it
+    NewFileKey newInlineFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(
+            updatedDataFileList.get(0), existingDownloadedFileGroup.getAllowedReadersEnum());
+    SharedFile newInlineSharedFile =
+        SharedFile.newBuilder()
+            .setFileName(updatedDataFileList.get(0).getFileId())
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .build();
+    sharedFilesMetadata.write(newInlineFileKey, newInlineSharedFile).get();
+
+    fileGroupManager
+        .importFilesIntoFileGroup(
+            groupKey,
+            /* buildId = */ 10,
+            /* variantId = */ "",
+            updatedDataFileList,
+            inlineFileMap,
+            /* customPropertyOptional = */ Optional.absent(),
+            noCustomValidation())
+        .get();
+
+    // Check that downloaded file group now contains the merged file group and pending group remains
+    // the same.
+    DataFileGroupInternal downloadedFileGroupAfterImport =
+        fileGroupsMetadata.read(getDownloadedKey(groupKey)).get();
+    assertThat(downloadedFileGroupAfterImport.getBuildId())
+        .isEqualTo(existingDownloadedFileGroup.getBuildId());
+    assertThat(downloadedFileGroupAfterImport.getFileCount()).isEqualTo(2);
+    assertThat(downloadedFileGroupAfterImport.getFileList())
+        .containsExactly(existingDownloadedFileGroup.getFile(0), updatedDataFileList.get(0));
+    assertThat(fileGroupsMetadata.read(getPendingKey(groupKey)).get())
+        .isEqualTo(existingPendingFileGroup);
+  }
+
+  @Test
+  public void testImportFilesIntoFileGroup_whenMatchesPendingButNotDownloaded_importsToPending()
+      throws Exception {
+    // Set up an existing pending file group, an existing downloaded file group and a new file
+    // group that matches the pending file group
+    DataFileGroupInternal existingPendingFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1);
+    DataFileGroupInternal existingDownloadedFileGroup =
+        existingPendingFileGroup.toBuilder()
+            .clearFile()
+            .addFile(MddTestUtil.createDataFile("downloaded-file", 0))
+            .setBuildId(10)
+            .build();
+    ImmutableList<DataFile> updatedDataFileList =
+        ImmutableList.of(
+            DataFile.newBuilder()
+                .setFileId("inline-file")
+                .setChecksum("abc")
+                .setUrlToDownload("inlinefile:abc")
+                .build());
+    ImmutableMap<String, FileSource> inlineFileMap =
+        ImmutableMap.of(
+            "inline-file", FileSource.ofByteString(ByteString.copyFromUtf8("TEST_CONTENT")));
+
+    GroupKey groupKey = GroupKey.newBuilder().setGroupName(TEST_GROUP).build();
+
+    writePendingFileGroup(getPendingKey(groupKey), existingPendingFileGroup);
+    writeDownloadedFileGroup(getDownloadedKey(groupKey), existingDownloadedFileGroup);
+
+    writeSharedFiles(
+        sharedFilesMetadata,
+        existingPendingFileGroup,
+        ImmutableList.of(FileStatus.DOWNLOAD_IN_PROGRESS));
+    writeSharedFiles(
+        sharedFilesMetadata,
+        existingDownloadedFileGroup,
+        ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE));
+
+    // TODO: remove once SFM can perform import
+    // write inline file as downloaded so FGM can find it
+    NewFileKey newInlineFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(
+            updatedDataFileList.get(0), existingPendingFileGroup.getAllowedReadersEnum());
+    SharedFile newInlineSharedFile =
+        SharedFile.newBuilder()
+            .setFileName(updatedDataFileList.get(0).getFileId())
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .build();
+    sharedFilesMetadata.write(newInlineFileKey, newInlineSharedFile).get();
+
+    fileGroupManager
+        .importFilesIntoFileGroup(
+            groupKey,
+            /* buildId = */ 0,
+            /* variantId = */ "",
+            updatedDataFileList,
+            inlineFileMap,
+            /* customPropertyOptional = */ Optional.absent(),
+            noCustomValidation())
+        .get();
+
+    // Check that pending file group now contains the merged file group and downloaded group remains
+    // the same.
+    DataFileGroupInternal pendingFileGroupAfterImport =
+        fileGroupsMetadata.read(getPendingKey(groupKey)).get();
+    assertThat(pendingFileGroupAfterImport.getBuildId())
+        .isEqualTo(existingPendingFileGroup.getBuildId());
+    assertThat(pendingFileGroupAfterImport.getFileCount()).isEqualTo(2);
+    assertThat(pendingFileGroupAfterImport.getFileList())
+        .containsExactly(existingPendingFileGroup.getFile(0), updatedDataFileList.get(0));
+    assertThat(fileGroupsMetadata.read(getDownloadedKey(groupKey)).get())
+        .isEqualTo(existingDownloadedFileGroup);
+  }
+
+  @Test
+  public void testImportFilesIntoFileGroup_whenPerformingImport_choosesFileSourceById()
+      throws Exception {
+    // Use mockSharedFileManager to check startImport call
+    resetFileGroupManager(fileGroupsMetadata, mockSharedFileManager);
+
+    FileSource testFileSource =
+        FileSource.ofByteString(ByteString.copyFromUtf8("TEST_FILE_SOURCE"));
+
+    // Setup file group with inline file to import
+    DataFileGroupInternal fileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 0).toBuilder()
+            .addFile(
+                DataFile.newBuilder()
+                    .setFileId("inline-file")
+                    .setChecksum("abc")
+                    .setUrlToDownload("inlinefile:sha1:abc")
+                    .build())
+            .build();
+
+    GroupKey groupKey = GroupKey.newBuilder().setGroupName(TEST_GROUP).build();
+    NewFileKey inlineFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(
+            fileGroup.getFile(0), fileGroup.getAllowedReadersEnum());
+
+    writePendingFileGroup(getPendingKey(groupKey), fileGroup);
+
+    // Setup mock SFM with successful calls
+    when(mockSharedFileManager.reserveFileEntry(inlineFileKey))
+        .thenReturn(Futures.immediateFuture(true));
+    when(mockSharedFileManager.getFileStatus(inlineFileKey))
+        .thenReturn(Futures.immediateFuture(FileStatus.DOWNLOAD_IN_PROGRESS));
+    when(mockSharedFileManager.startImport(
+            groupKeyCaptor.capture(),
+            eq(fileGroup.getFile(0)),
+            eq(inlineFileKey),
+            any(),
+            fileSourceCaptor.capture()))
+        .thenReturn(Futures.immediateVoidFuture());
+
+    fileGroupManager
+        .importFilesIntoFileGroup(
+            groupKey,
+            /* buildId = */ 0,
+            /* variantId = */ "",
+            /* updatedDataFileList = */ ImmutableList.of(),
+            /* inlineFileMap = */ ImmutableMap.of("inline-file", testFileSource),
+            /* customPropertyOptional = */ Optional.absent(),
+            noCustomValidation())
+        .get();
+
+    // Check that SFM startImport was called with expected inputs
+    verify(mockSharedFileManager, times(1)).startImport(any(), any(), any(), any(), any());
+    assertThat(groupKeyCaptor.getValue().getGroupName()).isEqualTo(TEST_GROUP);
+    assertThat(fileSourceCaptor.getValue()).isEqualTo(testFileSource);
+  }
+
+  @Test
+  public void testImportFilesIntoFileGroup_whenFileAlreadyDownloaded_doesNotAttemptToImport()
+      throws Exception {
+    // Use mockSharedFileManager to check startImport call
+    resetFileGroupManager(fileGroupsMetadata, mockSharedFileManager);
+
+    FileSource testFileSource =
+        FileSource.ofByteString(ByteString.copyFromUtf8("TEST_FILE_SOURCE"));
+
+    // Create an existing downloaded file group and attempt to import again with a given source.
+    // Since the file is already marked DOWNLOAD_COMPLETE, the import should not be invoked.
+    DataFileGroupInternal existingDownloadedFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 0).toBuilder()
+            .addFile(
+                DataFile.newBuilder()
+                    .setFileId("inline-file")
+                    .setChecksum("abc")
+                    .setUrlToDownload("inlinefile:abc")
+                    .build())
+            .build();
+
+    GroupKey groupKey = GroupKey.newBuilder().setGroupName(TEST_GROUP).build();
+    NewFileKey inlineFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(
+            existingDownloadedFileGroup.getFile(0),
+            existingDownloadedFileGroup.getAllowedReadersEnum());
+
+    writeDownloadedFileGroup(getDownloadedKey(groupKey), existingDownloadedFileGroup);
+
+    // Setup mock SFM with successful calls
+    when(mockSharedFileManager.reserveFileEntry(inlineFileKey))
+        .thenReturn(Futures.immediateFuture(true));
+    when(mockSharedFileManager.getFileStatus(inlineFileKey))
+        .thenReturn(Futures.immediateFuture(FileStatus.DOWNLOAD_COMPLETE));
+
+    fileGroupManager
+        .importFilesIntoFileGroup(
+            groupKey,
+            /* buildId = */ 0,
+            /* variantId = */ "",
+            /* updatedDataFileList = */ ImmutableList.of(),
+            /* inlineFileMap = */ ImmutableMap.of("inline-file", testFileSource),
+            /* customPropertyOptional = */ Optional.absent(),
+            noCustomValidation())
+        .get();
+
+    // Check that SFM startImport was not called
+    verify(mockSharedFileManager, times(0)).startImport(any(), any(), any(), any(), any());
+  }
+
+  @Test
+  public void testImportFilesIntoFileGroups_whenFileSourceNotProvided_fails() throws Exception {
+    // create a file group added to MDD with an inline file and check that import call fails if
+    // source is not provided.
+    DataFileGroupInternal existingFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 0).toBuilder()
+            .addFile(
+                DataFile.newBuilder()
+                    .setFileId("inline-file")
+                    .setChecksum("abc")
+                    .setUrlToDownload("inlinefile:abc")
+                    .build())
+            .build();
+
+    GroupKey groupKey = GroupKey.newBuilder().setGroupName(TEST_GROUP).build();
+
+    writePendingFileGroup(getPendingKey(groupKey), existingFileGroup);
+
+    writeSharedFiles(
+        sharedFilesMetadata, existingFileGroup, ImmutableList.of(FileStatus.DOWNLOAD_IN_PROGRESS));
+
+    ExecutionException ex =
+        assertThrows(
+            ExecutionException.class,
+            () ->
+                fileGroupManager
+                    .importFilesIntoFileGroup(
+                        groupKey,
+                        /* buildId = */ 0,
+                        /* variantId = */ "",
+                        /* updatedDataFileList = */ ImmutableList.of(),
+                        /* inlineFileMap = */ ImmutableMap.of(),
+                        /* customPropertyOptional = */ Optional.absent(),
+                        noCustomValidation())
+                    .get());
+    assertThat(ex).hasCauseThat().isInstanceOf(AggregateException.class);
+    AggregateException aex = (AggregateException) ex.getCause();
+    assertThat(aex.getFailures()).hasSize(1);
+    assertThat(aex.getFailures().get(0)).isInstanceOf(DownloadException.class);
+    DownloadException dex = (DownloadException) aex.getFailures().get(0);
+    assertThat(dex.getDownloadResultCode())
+        .isEqualTo(DownloadResultCode.MISSING_INLINE_FILE_SOURCE);
+  }
+
+  @Test
+  public void testImportFilesIntoFileGroup_whenImportFails_preventsMetadataUpdate()
+      throws Exception {
+    // Use mockSharedFileManager to mock a failure for an import
+    resetFileGroupManager(fileGroupsMetadata, mockSharedFileManager);
+
+    FileSource testFileSource1 =
+        FileSource.ofByteString(ByteString.copyFromUtf8("TEST_FILE_SOURCE_1"));
+    FileSource testFileSource2 =
+        FileSource.ofByteString(ByteString.copyFromUtf8("TEST_FILE_SOURCE_2"));
+
+    // Setup empty file group
+    DataFileGroupInternal fileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 0);
+
+    // Setup list of files that should be imported
+    ImmutableList<DataFile> updatedDataFileList =
+        ImmutableList.of(
+            DataFile.newBuilder()
+                .setFileId("inline-file-1")
+                .setChecksum("abc")
+                .setUrlToDownload("inlinefile:sha1:abc")
+                .build(),
+            DataFile.newBuilder()
+                .setFileId("inline-file-2")
+                .setChecksum("def")
+                .setUrlToDownload("inlinefile:sha1:def")
+                .build());
+
+    GroupKey groupKey = GroupKey.newBuilder().setGroupName(TEST_GROUP).build();
+    NewFileKey inlineFileKey1 =
+        SharedFilesMetadata.createKeyFromDataFile(
+            updatedDataFileList.get(0), fileGroup.getAllowedReadersEnum());
+    NewFileKey inlineFileKey2 =
+        SharedFilesMetadata.createKeyFromDataFile(
+            updatedDataFileList.get(1), fileGroup.getAllowedReadersEnum());
+
+    writeDownloadedFileGroup(getDownloadedKey(groupKey), fileGroup);
+
+    // Setup mock calls to SFM
+    when(mockSharedFileManager.reserveFileEntry(any())).thenReturn(Futures.immediateFuture(true));
+
+    // Mock that inline file 1 completed, but inline file 2 failed
+    when(mockSharedFileManager.getFileStatus(inlineFileKey1))
+        .thenReturn(Futures.immediateFuture(FileStatus.DOWNLOAD_IN_PROGRESS));
+    when(mockSharedFileManager.getFileStatus(inlineFileKey2))
+        .thenReturn(Futures.immediateFuture(FileStatus.DOWNLOAD_FAILED));
+    when(mockSharedFileManager.startImport(
+            any(), eq(updatedDataFileList.get(0)), eq(inlineFileKey1), any(), any()))
+        .thenReturn(Futures.immediateVoidFuture());
+    when(mockSharedFileManager.startImport(
+            any(), eq(updatedDataFileList.get(1)), eq(inlineFileKey2), any(), any()))
+        .thenReturn(
+            Futures.immediateFailedFuture(
+                DownloadException.builder()
+                    .setDownloadResultCode(DownloadResultCode.DOWNLOAD_TRANSFORM_IO_ERROR)
+                    .build()));
+
+    ExecutionException ex =
+        assertThrows(
+            ExecutionException.class,
+            () ->
+                fileGroupManager
+                    .importFilesIntoFileGroup(
+                        groupKey,
+                        /* buildId = */ 0,
+                        /* variantId = */ "",
+                        updatedDataFileList,
+                        /* inlineFileMap = */ ImmutableMap.of(
+                            "inline-file-1", testFileSource1, "inline-file-2", testFileSource2),
+                        /* customPropertyOptional = */ Optional.absent(),
+                        noCustomValidation())
+                    .get());
+
+    // Check for expected cause
+    assertThat(ex).hasCauseThat().isInstanceOf(AggregateException.class);
+    AggregateException aex = (AggregateException) ex.getCause();
+    assertThat(aex.getFailures()).hasSize(1);
+    assertThat(aex.getFailures().get(0)).isInstanceOf(DownloadException.class);
+    DownloadException dex = (DownloadException) aex.getFailures().get(0);
+    assertThat(dex.getDownloadResultCode())
+        .isEqualTo(DownloadResultCode.DOWNLOAD_TRANSFORM_IO_ERROR);
+
+    // Check that existing (empty) group remains in metadata. iow, the files from
+    // updatedDataFileList were not added since the import failed.
+    DataFileGroupInternal existingFileGroup =
+        fileGroupsMetadata.read(getDownloadedKey(groupKey)).get();
+    assertThat(existingFileGroup.getFileList()).isEmpty();
+
+    // Check that SFM startImport was called with expected inputs
+    verify(mockSharedFileManager, times(2)).startImport(any(), any(), any(), any(), any());
+  }
+
+  @Test
+  public void testImportFilesIntoFileGroup_skipsSideloadedFile() throws Exception {
+    // Create sideloaded group with inline file
+    DataFileGroupInternal sideloadedGroup =
+        DataFileGroupInternal.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .addFile(
+                DataFile.newBuilder()
+                    .setFileId("sideloaded_file")
+                    .setUrlToDownload("file:/test")
+                    .setChecksumType(DataFile.ChecksumType.NONE)
+                    .build())
+            .addFile(
+                DataFile.newBuilder()
+                    .setFileId("inline_file")
+                    .setUrlToDownload("inlinefile:sha1:checksum")
+                    .setChecksum("checksum")
+                    .build())
+            .build();
+    ImmutableMap<String, FileSource> inlineFileMap =
+        ImmutableMap.of(
+            "inline_file", FileSource.ofByteString(ByteString.copyFromUtf8("TEST_CONTENT")));
+    NewFileKey inlineFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(
+            sideloadedGroup.getFile(1), sideloadedGroup.getAllowedReadersEnum());
+
+    // Write group as pending since we are waiting on inline file
+    writePendingFileGroup(testKey, sideloadedGroup);
+
+    // Write inline file as succeeded so we skip SFM's import call
+    SharedFile inlineSharedFile =
+        SharedFile.newBuilder()
+            .setFileName(sideloadedGroup.getFile(1).getFileId())
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .build();
+    sharedFilesMetadata.write(inlineFileKey, inlineSharedFile).get();
+
+    fileGroupManager
+        .importFilesIntoFileGroup(
+            testKey,
+            sideloadedGroup.getBuildId(),
+            sideloadedGroup.getVariantId(),
+            /* updatedDataFileList = */ ImmutableList.of(),
+            inlineFileMap,
+            /* customPropertyOptional = */ Optional.absent(),
+            noCustomValidation())
+        .get();
+
+    assertThat(readPendingFileGroup(testKey)).isNull();
+    assertThat(readDownloadedFileGroup(testKey)).isNotNull();
+  }
+
+  @Test
+  public void testDownloadPendingGroup_success() throws Exception {
+    // Write 1 group to the pending shared prefs.
+    DataFileGroupInternal fileGroup =
+        createDataFileGroup(
+            TEST_GROUP,
+            /*fileCount=*/ 2,
+            /*downloadAttemptCount=*/ 3,
+            /*newFilesReceivedTimestamp=*/ testClock.currentTimeMillis() - 500L);
+    ExtraHttpHeader extraHttpHeader =
+        ExtraHttpHeader.newBuilder().setKey("user-agent").setValue("mdd-downloader").build();
+
+    fileGroup =
+        fileGroup.toBuilder()
+            .setOwnerPackage(context.getPackageName())
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .setTrafficTag(TRAFFIC_TAG)
+            .addGroupExtraHttpHeaders(extraHttpHeader)
+            .build();
+    writePendingFileGroup(testKey, fileGroup);
+
+    writeSharedFiles(
+        sharedFilesMetadata,
+        fileGroup,
+        ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE, FileStatus.DOWNLOAD_COMPLETE));
+
+    fileGroupManager
+        .downloadFileGroup(testKey, DownloadConditions.getDefaultInstance(), noCustomValidation())
+        .get();
+
+    // Verify that pending key is removed if download is complete.
+    assertThat(readPendingFileGroup(testKey)).isNull();
+
+    // Verify that downloaded key is written into metadata if download is complete.
+    assertThat(readDownloadedFileGroup(testKey)).isNotNull();
+  }
+
+  @Test
+  public void testDownloadPendingGroup_withFailingCustomValidator() throws Exception {
+    // Write 1 group to the pending shared prefs.
+    DataFileGroupInternal fileGroup =
+        createDataFileGroup(
+            TEST_GROUP,
+            /*fileCount=*/ 2,
+            /*downloadAttemptCount=*/ 3,
+            /*newFilesReceivedTimestamp=*/ testClock.currentTimeMillis() - 500L);
+    ExtraHttpHeader extraHttpHeader =
+        ExtraHttpHeader.newBuilder().setKey("user-agent").setValue("mdd-downloader").build();
+
+    fileGroup =
+        fileGroup.toBuilder()
+            .setOwnerPackage(context.getPackageName())
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .setTrafficTag(TRAFFIC_TAG)
+            .addGroupExtraHttpHeaders(extraHttpHeader)
+            .build();
+    writePendingFileGroup(testKey, fileGroup);
+
+    writeSharedFiles(
+        sharedFilesMetadata,
+        fileGroup,
+        ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE, FileStatus.DOWNLOAD_COMPLETE));
+
+    AsyncFunction<DataFileGroupInternal, Boolean> failingValidator =
+        unused -> Futures.immediateFuture(false);
+    ListenableFuture<DataFileGroupInternal> downloadFuture =
+        fileGroupManager.downloadFileGroup(
+            testKey, DownloadConditions.getDefaultInstance(), failingValidator);
+
+    ExecutionException exception = assertThrows(ExecutionException.class, downloadFuture::get);
+    assertThat(exception).hasCauseThat().isInstanceOf(DownloadException.class);
+    DownloadException cause = (DownloadException) exception.getCause();
+    assertThat(cause).isNotNull();
+    assertThat(cause).hasMessageThat().contains("CUSTOM_FILEGROUP_VALIDATION_FAILED");
+
+    // Verify that pending key was removed. This will ensure the files are eligible for garbage
+    // collection.
+    assertThat(readPendingFileGroup(testKey)).isNull();
+  }
+
+  @Test
+  public void testDownloadFileGroup_failed() throws Exception {
+    // Write 1 group to the pending shared prefs.
+    DataFileGroupInternal fileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2).toBuilder()
+            .setVariantId("test-variant")
+            .setBuildId(10)
+            .build();
+    NewFileKey[] keys = MddTestUtil.createFileKeysForDataFileGroupInternal(fileGroup);
+    fileGroup =
+        fileGroup.toBuilder()
+            .setOwnerPackage(context.getPackageName())
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .build();
+    writePendingFileGroup(testKey, fileGroup);
+
+    // Not all files are downloaded.
+    writeSharedFiles(
+        sharedFilesMetadata,
+        fileGroup,
+        ImmutableList.of(FileStatus.DOWNLOAD_IN_PROGRESS, FileStatus.DOWNLOAD_IN_PROGRESS));
+
+    // First file failed.
+    Uri failingFileUri =
+        DirectoryUtil.getOnDeviceUri(
+            context,
+            keys[0].getAllowedReaders(),
+            fileGroup.getFile(0).getFileId(),
+            fileGroup.getFile(0).getChecksum(),
+            mockSilentFeedback,
+            /* instanceId= */ Optional.absent(),
+            false);
+    fileDownloadFails(keys[0], failingFileUri, DownloadResultCode.LOW_DISK_ERROR);
+
+    // Second file succeeded.
+    Uri succeedingFileUri =
+        DirectoryUtil.getOnDeviceUri(
+            context,
+            keys[1].getAllowedReaders(),
+            fileGroup.getFile(1).getFileId(),
+            fileGroup.getFile(1).getChecksum(),
+            mockSilentFeedback,
+            /* instanceId= */ Optional.absent(),
+            false);
+    fileDownloadSucceeds(keys[1], succeedingFileUri);
+
+    ListenableFuture<DataFileGroupInternal> downloadFuture =
+        fileGroupManager.downloadFileGroup(
+            testKey, DownloadConditions.getDefaultInstance(), noCustomValidation());
+
+    ExecutionException exception = assertThrows(ExecutionException.class, downloadFuture::get);
+    assertThat(exception).hasCauseThat().isInstanceOf(AggregateException.class);
+    AggregateException cause = (AggregateException) exception.getCause();
+    assertThat(cause).isNotNull();
+    ImmutableList<Throwable> failures = cause.getFailures();
+    assertThat(failures).hasSize(1);
+    assertThat(failures.get(0)).isInstanceOf(DownloadException.class);
+    assertThat(failures.get(0)).hasMessageThat().contains("LOW_DISK_ERROR");
+
+    // Verify that the pending group is still part of pending groups prefs.
+    assertThat(readPendingFileGroup(testKey)).isNotNull();
+
+    // Verify that the pending group is not changed from pending to downloaded.
+    assertThat(readDownloadedFileGroup(testKey)).isNull();
+  }
+
+  @Test
+  public void testDownloadFileGroup_failedWithMultipleExceptions() throws Exception {
+    // Write 1 group to the pending shared prefs.
+    DataFileGroupInternal fileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 3);
+    NewFileKey[] keys = MddTestUtil.createFileKeysForDataFileGroupInternal(fileGroup);
+    fileGroup =
+        fileGroup.toBuilder()
+            .setOwnerPackage(context.getPackageName())
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .build();
+    writePendingFileGroup(testKey, fileGroup);
+
+    // Not all files are downloaded.
+    writeSharedFiles(
+        sharedFilesMetadata,
+        fileGroup,
+        ImmutableList.of(
+            FileStatus.DOWNLOAD_IN_PROGRESS,
+            FileStatus.DOWNLOAD_IN_PROGRESS,
+            FileStatus.DOWNLOAD_IN_PROGRESS));
+
+    // First file succeeded.
+    Uri succeedingFileUri =
+        DirectoryUtil.getOnDeviceUri(
+            context,
+            keys[0].getAllowedReaders(),
+            fileGroup.getFile(0).getFileId(),
+            fileGroup.getFile(0).getChecksum(),
+            mockSilentFeedback,
+            /* instanceId= */ Optional.absent(),
+            false);
+    fileDownloadSucceeds(keys[0], succeedingFileUri);
+
+    // Second file failed with download transform I/O error.
+    Uri failingFileUri1 =
+        DirectoryUtil.getOnDeviceUri(
+            context,
+            keys[1].getAllowedReaders(),
+            fileGroup.getFile(1).getFileId(),
+            fileGroup.getFile(1).getChecksum(),
+            mockSilentFeedback,
+            /* instanceId= */ Optional.absent(),
+            false);
+    fileDownloadFails(keys[1], failingFileUri1, DownloadResultCode.DOWNLOAD_TRANSFORM_IO_ERROR);
+
+    // Third file failed with android downloader http error.
+    Uri failingFileUri2 =
+        DirectoryUtil.getOnDeviceUri(
+            context,
+            keys[2].getAllowedReaders(),
+            fileGroup.getFile(2).getFileId(),
+            fileGroup.getFile(2).getChecksum(),
+            mockSilentFeedback,
+            /* instanceId= */ Optional.absent(),
+            false);
+    fileDownloadFails(keys[2], failingFileUri2, DownloadResultCode.ANDROID_DOWNLOADER_HTTP_ERROR);
+
+    ListenableFuture<DataFileGroupInternal> downloadFuture =
+        fileGroupManager.downloadFileGroup(
+            testKey, DownloadConditions.getDefaultInstance(), noCustomValidation());
+
+    // Ensure that all exceptions are aggregated.
+    ExecutionException exception = assertThrows(ExecutionException.class, downloadFuture::get);
+    assertThat(exception).hasCauseThat().isInstanceOf(AggregateException.class);
+    AggregateException cause = (AggregateException) exception.getCause();
+    assertThat(cause).isNotNull();
+    ImmutableList<Throwable> failures = cause.getFailures();
+    assertThat(failures).hasSize(2);
+    assertThat(failures.get(0)).isInstanceOf(DownloadException.class);
+    assertThat(failures.get(0)).hasMessageThat().contains("DOWNLOAD_TRANSFORM_IO_ERROR");
+    assertThat(failures.get(1)).isInstanceOf(DownloadException.class);
+    assertThat(failures.get(1)).hasMessageThat().contains("ANDROID_DOWNLOADER_HTTP_ERROR");
+
+    // Verify that the pending group is still part of pending groups prefs.
+    assertThat(readPendingFileGroup(testKey)).isNotNull();
+
+    // Verify that the pending group is not changed from pending to downloaded.
+    assertThat(readDownloadedFileGroup(testKey)).isNull();
+  }
+
+  @Test
+  public void testDownloadFileGroup_failedWithUnknownError() throws Exception {
+    // Write 1 group to the pending shared prefs.
+    DataFileGroupInternal fileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+    NewFileKey[] keys = MddTestUtil.createFileKeysForDataFileGroupInternal(fileGroup);
+    fileGroup =
+        fileGroup.toBuilder()
+            .setOwnerPackage(context.getPackageName())
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .build();
+    writePendingFileGroup(testKey, fileGroup);
+
+    writeSharedFiles(
+        sharedFilesMetadata,
+        fileGroup,
+        ImmutableList.of(FileStatus.DOWNLOAD_IN_PROGRESS, FileStatus.DOWNLOAD_COMPLETE));
+
+    // First file failed.
+    Uri failingFileUri =
+        DirectoryUtil.getOnDeviceUri(
+            context,
+            keys[0].getAllowedReaders(),
+            fileGroup.getFile(0).getFileId(),
+            fileGroup.getFile(0).getChecksum(),
+            mockSilentFeedback,
+            /* instanceId= */ Optional.absent(),
+            false);
+    // The file status is set to DOWNLOAD_FAILED but the downloader returns an immediateVoidFuture.
+    // An UNKNOWN_ERROR is logged.
+    fileDownloadFails(keys[0], failingFileUri, /* failureCode = */ null);
+
+    ListenableFuture<DataFileGroupInternal> downloadFuture =
+        fileGroupManager.downloadFileGroup(
+            testKey, DownloadConditions.getDefaultInstance(), noCustomValidation());
+
+    ExecutionException exception = assertThrows(ExecutionException.class, downloadFuture::get);
+    assertThat(exception).hasMessageThat().contains("UNKNOWN_ERROR");
+
+    // Verify that the pending group is still part of pending groups prefs.
+    assertThat(readPendingFileGroup(testKey)).isNotNull();
+
+    // Verify that the pending group is not changed from pending to downloaded.
+    assertThat(readDownloadedFileGroup(testKey)).isNull();
+  }
+
+  @Test
+  public void testDownloadFileGroup_pending() throws Exception {
+    // Write 1 group to the pending shared prefs.
+    DataFileGroupInternal fileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+    fileGroup =
+        fileGroup.toBuilder()
+            .setOwnerPackage(context.getPackageName())
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .build();
+    writePendingFileGroup(testKey, fileGroup);
+
+    // Not all files are downloaded.
+    writeSharedFiles(
+        sharedFilesMetadata,
+        fileGroup,
+        ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE, FileStatus.DOWNLOAD_IN_PROGRESS));
+
+    when(mockDownloader.startDownloading(
+            any(GroupKey.class),
+            anyInt(),
+            anyLong(),
+            any(Uri.class),
+            any(String.class),
+            anyInt(),
+            any(DownloadConditions.class),
+            isA(DownloaderCallbackImpl.class),
+            anyInt(),
+            anyList()))
+        .thenReturn(Futures.immediateVoidFuture());
+
+    ListenableFuture<DataFileGroupInternal> downloadFuture =
+        fileGroupManager.downloadFileGroup(
+            testKey, DownloadConditions.getDefaultInstance(), noCustomValidation());
+
+    ExecutionException exception = assertThrows(ExecutionException.class, downloadFuture::get);
+    assertThat(exception).hasMessageThat().contains("UNKNOWN_ERROR");
+
+    // Verify that the pending group is still part of pending groups prefs.
+    assertThat(readPendingFileGroup(testKey)).isNotNull();
+
+    // Verify that the pending group is not changed from pending to downloaded.
+    assertThat(readDownloadedFileGroup(testKey)).isNull();
+  }
+
+  @Test
+  public void testDownloadFileGroup_alreadyDownloaded() throws Exception {
+    // Write 1 group to the downloaded shared prefs.
+    DataFileGroupInternal fileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+    fileGroup =
+        fileGroup.toBuilder()
+            .setOwnerPackage(context.getPackageName())
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .build();
+    writeDownloadedFileGroup(testKey, fileGroup);
+
+    List<GroupKey> originalKeys = fileGroupsMetadata.getAllGroupKeys().get();
+
+    ListenableFuture<DataFileGroupInternal> downloadFuture =
+        fileGroupManager.downloadFileGroup(
+            testKey, DownloadConditions.getDefaultInstance(), noCustomValidation());
+
+    assertThat(downloadFuture.get()).isEqualTo(fileGroup);
+
+    // Verify that the downloaded group is still part of downloaded groups prefs.
+    DataFileGroupInternal downloadedGroup = readDownloadedFileGroup(testKey);
+    assertThat(downloadedGroup).isEqualTo(fileGroup);
+
+    // Verify that no group metadata is written or removed.
+    assertThat(originalKeys).isEqualTo(fileGroupsMetadata.getAllGroupKeys().get());
+  }
+
+  @Test
+  public void testDownloadFileGroup_nullDownloadCondition() throws Exception {
+    DownloadConditions downloadConditions =
+        DownloadConditions.newBuilder()
+            .setDeviceNetworkPolicy(DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI)
+            .setDeviceStoragePolicy(DeviceStoragePolicy.BLOCK_DOWNLOAD_IN_LOW_STORAGE)
+            .build();
+
+    // Write 1 group to the pending shared prefs.
+    DataFileGroupInternal fileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2).toBuilder()
+            .setDownloadConditions(downloadConditions)
+            .build();
+    writePendingFileGroup(testKey, fileGroup);
+
+    writeSharedFiles(
+        sharedFilesMetadata,
+        fileGroup,
+        ImmutableList.of(FileStatus.DOWNLOAD_IN_PROGRESS, FileStatus.DOWNLOAD_IN_PROGRESS));
+
+    ArgumentCaptor<DownloadConditions> downloadConditionsCaptor =
+        ArgumentCaptor.forClass(DownloadConditions.class);
+    when(mockDownloader.startDownloading(
+            any(GroupKey.class),
+            anyInt(),
+            anyLong(),
+            any(Uri.class),
+            any(String.class),
+            anyInt(),
+            downloadConditionsCaptor.capture(),
+            isA(DownloaderCallbackImpl.class),
+            anyInt(),
+            anyList()))
+        .then(
+            new Answer<ListenableFuture<Void>>() {
+              @Override
+              public ListenableFuture<Void> answer(InvocationOnMock invocation) throws Throwable {
+                writeSharedFiles(
+                    sharedFilesMetadata,
+                    fileGroup,
+                    ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE, FileStatus.DOWNLOAD_COMPLETE));
+                return Futures.immediateVoidFuture();
+              }
+            });
+
+    DataFileGroupInternal updatedFileGroup =
+        fileGroup.toBuilder()
+            .setBookkeeping(
+                DataFileGroupBookkeeping.newBuilder()
+                    .setDownloadStartedCount(1)
+                    .setGroupDownloadStartedTimestampInMillis(1000L))
+            .build();
+
+    // Calling with DownloadConditions = null will use the config from server.
+    assertThat(
+            fileGroupManager
+                .downloadFileGroup(testKey, null /*downloadConditions*/, noCustomValidation())
+                .get())
+        .isEqualTo(updatedFileGroup);
+    assertThat(downloadConditionsCaptor.getValue()).isEqualTo(downloadConditions);
+  }
+
+  @Test
+  public void testDownloadFileGroup_nonNullDownloadCondition() throws Exception {
+    DownloadConditions downloadConditions =
+        DownloadConditions.newBuilder()
+            .setDeviceNetworkPolicy(DeviceNetworkPolicy.DOWNLOAD_ONLY_ON_WIFI)
+            .setDeviceStoragePolicy(DeviceStoragePolicy.BLOCK_DOWNLOAD_IN_LOW_STORAGE)
+            .build();
+
+    // Write 1 group to the pending shared prefs.
+    DataFileGroupInternal fileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2).toBuilder()
+            .setDownloadConditions(downloadConditions)
+            .build();
+    writePendingFileGroup(testKey, fileGroup);
+
+    writeSharedFiles(
+        sharedFilesMetadata,
+        fileGroup,
+        ImmutableList.of(FileStatus.DOWNLOAD_IN_PROGRESS, FileStatus.DOWNLOAD_IN_PROGRESS));
+
+    ArgumentCaptor<DownloadConditions> downloadConditionsCaptor =
+        ArgumentCaptor.forClass(DownloadConditions.class);
+    when(mockDownloader.startDownloading(
+            any(GroupKey.class),
+            anyInt(),
+            anyLong(),
+            any(Uri.class),
+            any(String.class),
+            anyInt(),
+            downloadConditionsCaptor.capture(),
+            isA(DownloaderCallbackImpl.class),
+            anyInt(),
+            anyList()))
+        .then(
+            new Answer<ListenableFuture<Void>>() {
+              @Override
+              public ListenableFuture<Void> answer(InvocationOnMock invocation) throws Throwable {
+                writeSharedFiles(
+                    sharedFilesMetadata,
+                    fileGroup,
+                    ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE, FileStatus.DOWNLOAD_COMPLETE));
+                return Futures.immediateVoidFuture();
+              }
+            });
+
+    DownloadConditions downloadConditions2 =
+        DownloadConditions.newBuilder()
+            .setDeviceNetworkPolicy(DeviceNetworkPolicy.DOWNLOAD_ON_ANY_NETWORK)
+            .setDeviceStoragePolicy(DeviceStoragePolicy.BLOCK_DOWNLOAD_IN_LOW_STORAGE)
+            .build();
+
+    DataFileGroupInternal updatedFileGroup =
+        fileGroup.toBuilder()
+            .setBookkeeping(
+                DataFileGroupBookkeeping.newBuilder()
+                    .setDownloadStartedCount(1)
+                    .setGroupDownloadStartedTimestampInMillis(1000L))
+            .build();
+
+    // downloadConditions2 will override the pendingGroup.downloadConditions
+    assertThat(
+            fileGroupManager
+                .downloadFileGroup(testKey, downloadConditions2, noCustomValidation())
+                .get())
+        .isEqualTo(updatedFileGroup);
+    assertThat(downloadConditionsCaptor.getValue()).isEqualTo(downloadConditions2);
+  }
+
+  @Test
+  public void testDownloadFileGroup_notFoundGroup() throws Exception {
+    // Mock FileGroupsMetadata to test failure scenario.
+    resetFileGroupManager(mockFileGroupsMetadata, sharedFileManager);
+    // Can't find the group.
+    ArgumentCaptor<GroupKey> groupKeyCaptor = ArgumentCaptor.forClass(GroupKey.class);
+    when(mockFileGroupsMetadata.read(groupKeyCaptor.capture()))
+        .thenReturn(Futures.immediateFuture(null));
+
+    // Download not-found group will lead to failed future.
+    ExecutionException exception =
+        assertThrows(
+            ExecutionException.class,
+            () ->
+                fileGroupManager
+                    .downloadFileGroup(testKey, null /*downloadConditions*/, noCustomValidation())
+                    .get());
+    assertThat(exception).hasCauseThat().isInstanceOf(DownloadException.class);
+
+    // Make sure that file group manager attempted to read both pending key and downloaded key.
+    assertThat(groupKeyCaptor.getAllValues())
+        .containsAtLeast(getPendingKey(testKey), getDownloadedKey(testKey));
+  }
+
+  @Test
+  public void testDownloadFileGroup_downloadStartedTimestampAbsent() throws Exception {
+    // Write 1 group to the pending shared prefs.
+    DataFileGroupInternal fileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+    fileGroup =
+        fileGroup.toBuilder()
+            .setOwnerPackage(context.getPackageName())
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .setTrafficTag(TRAFFIC_TAG)
+            .build();
+    writePendingFileGroup(testKey, fileGroup);
+
+    writeSharedFiles(
+        sharedFilesMetadata,
+        fileGroup,
+        ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE, FileStatus.DOWNLOAD_COMPLETE));
+
+    fileGroupManager
+        .downloadFileGroup(testKey, DownloadConditions.getDefaultInstance(), noCustomValidation())
+        .get();
+
+    DataFileGroupBookkeeping bookkeeping = readDownloadedFileGroup(testKey).getBookkeeping();
+    assertThat(bookkeeping.hasGroupDownloadStartedTimestampInMillis()).isTrue();
+    // Make sure that the download started timestamp is set to current time.
+    assertThat(bookkeeping.getGroupDownloadStartedTimestampInMillis())
+        .isEqualTo(testClock.currentTimeMillis());
+    // Make sure that the download started count is accumulated.
+    assertThat(bookkeeping.getDownloadStartedCount()).isEqualTo(1);
+  }
+
+  @Test
+  public void testDownloadFileGroup_downloadStartedTimestampPresent() throws Exception {
+    // Write 1 group to the pending shared prefs.
+    DataFileGroupInternal fileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+
+    fileGroup =
+        fileGroup.toBuilder()
+            .setOwnerPackage(context.getPackageName())
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .setTrafficTag(TRAFFIC_TAG)
+            .setBookkeeping(
+                DataFileGroupBookkeeping.newBuilder()
+                    .setGroupDownloadStartedTimestampInMillis(123456)
+                    .setDownloadStartedCount(2))
+            .build();
+    writePendingFileGroup(testKey, fileGroup);
+
+    writeSharedFiles(
+        sharedFilesMetadata,
+        fileGroup,
+        ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE, FileStatus.DOWNLOAD_COMPLETE));
+
+    fileGroupManager
+        .downloadFileGroup(testKey, DownloadConditions.getDefaultInstance(), noCustomValidation())
+        .get();
+
+    DataFileGroupBookkeeping bookkeeping = readDownloadedFileGroup(testKey).getBookkeeping();
+    assertThat(bookkeeping.hasGroupDownloadStartedTimestampInMillis()).isTrue();
+    // Make sure that the download started timestamp is not changed.
+    assertThat(bookkeeping.getGroupDownloadStartedTimestampInMillis()).isEqualTo(123456);
+    // Make sure that the download started count is accumulated.
+    assertThat(bookkeeping.getDownloadStartedCount()).isEqualTo(3);
+  }
+
+  @Test
+  public void testDownloadFileGroup_updateBookkeepingOnDownloadFailed() throws Exception {
+    // Mock FileGroupsMetadata to test failure scenario.
+    resetFileGroupManager(mockFileGroupsMetadata, sharedFileManager);
+    // Write 1 group to the pending shared prefs.
+    DataFileGroupInternal fileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+    fileGroup =
+        fileGroup.toBuilder()
+            .setOwnerPackage(context.getPackageName())
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .setTrafficTag(TRAFFIC_TAG)
+            .build();
+    GroupKey pendingKey = testKey.toBuilder().setDownloaded(false).build();
+    when(mockFileGroupsMetadata.read(pendingKey)).thenReturn(Futures.immediateFuture(fileGroup));
+
+    // All files are downloaded.
+    writeSharedFiles(
+        sharedFilesMetadata,
+        fileGroup,
+        ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE, FileStatus.DOWNLOAD_COMPLETE));
+
+    ArgumentCaptor<DataFileGroupInternal> fileGroupCaptor =
+        ArgumentCaptor.forClass(DataFileGroupInternal.class);
+    when(mockFileGroupsMetadata.write(eq(pendingKey), fileGroupCaptor.capture()))
+        .thenReturn(Futures.immediateFuture(false));
+
+    ExecutionException executionException =
+        assertThrows(
+            ExecutionException.class,
+            () ->
+                fileGroupManager
+                    .downloadFileGroup(
+                        testKey, DownloadConditions.getDefaultInstance(), noCustomValidation())
+                    .get());
+    assertThat(executionException).hasCauseThat().isInstanceOf(DownloadException.class);
+    DownloadException downloadException = (DownloadException) executionException.getCause();
+    assertThat(downloadException).hasCauseThat().isInstanceOf(IOException.class);
+    assertThat(downloadException.getDownloadResultCode())
+        .isEqualTo(DownloadResultCode.UNABLE_TO_UPDATE_GROUP_METADATA_ERROR);
+
+    DataFileGroupBookkeeping bookkeeping = fileGroupCaptor.getValue().getBookkeeping();
+    assertThat(bookkeeping.hasGroupDownloadStartedTimestampInMillis()).isTrue();
+    assertThat(bookkeeping.getGroupDownloadStartedTimestampInMillis())
+        .isEqualTo(testClock.currentTimeMillis());
+  }
+
+  @Test
+  public void testDownloadToBeSharedPendingGroup_success_lowSdk_notShared() throws Exception {
+    ReflectionHelpers.setStaticField(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.R - 1);
+    // Write 1 group to the pending shared prefs.
+    DataFileGroupInternal fileGroup =
+        createDataFileGroup(
+            TEST_GROUP,
+            /*fileCount=*/ 0,
+            /*downloadAttemptCount=*/ 3,
+            /*newFilesReceivedTimestamp=*/ testClock.currentTimeMillis() - 500L);
+    ExtraHttpHeader extraHttpHeader =
+        ExtraHttpHeader.newBuilder().setKey("user-agent").setValue("mdd-downloader").build();
+
+    fileGroup =
+        fileGroup.toBuilder()
+            .setOwnerPackage(context.getPackageName())
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .setTrafficTag(TRAFFIC_TAG)
+            .addGroupExtraHttpHeaders(extraHttpHeader)
+            .addFile(0, MddTestUtil.createSharedDataFile(TEST_GROUP, 0))
+            .addFile(1, MddTestUtil.createDataFile(TEST_GROUP, 1))
+            .build();
+    writePendingFileGroup(testKey, fileGroup);
+
+    writeSharedFiles(
+        sharedFilesMetadata,
+        fileGroup,
+        ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE, FileStatus.DOWNLOAD_COMPLETE));
+
+    fileGroupManager
+        .downloadFileGroup(testKey, DownloadConditions.getDefaultInstance(), noCustomValidation())
+        .get();
+
+    // Verify that pending key is removed if download is complete.
+    assertThat(readPendingFileGroup(testKey)).isNull();
+
+    // Verify that downloaded key is written into metadata if download is complete.
+    assertThat(readDownloadedFileGroup(testKey)).isNotNull();
+
+    // exists only called once in tryToShareBeforeDownload
+    verify(mockBackend, never()).exists(any());
+    // openForWrite is called in tryToShareBeforeDownload for copying the file and acquiring the
+    // lease.
+    verify(mockBackend, never()).openForWrite(any());
+  }
+
+  @Test
+  public void testDownloadFileGroup_success_oneFileAndroidSharedAndDownloaded() throws Exception {
+    // Write 1 group to the pending shared prefs.
+    DataFileGroupInternal fileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2).toBuilder()
+            .setVariantId("test-variant")
+            .setBuildId(10)
+            .build();
+    NewFileKey[] keys = MddTestUtil.createFileKeysForDataFileGroupInternal(fileGroup);
+    ExtraHttpHeader extraHttpHeader =
+        ExtraHttpHeader.newBuilder().setKey("user-agent").setValue("mdd-downloader").build();
+
+    fileGroup =
+        fileGroup.toBuilder()
+            .setOwnerPackage(context.getPackageName())
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .setTrafficTag(TRAFFIC_TAG)
+            .addGroupExtraHttpHeaders(extraHttpHeader)
+            .build();
+    writePendingFileGroup(testKey, fileGroup);
+
+    writeSharedFiles(
+        sharedFilesMetadata,
+        fileGroup,
+        ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE, FileStatus.DOWNLOAD_COMPLETE),
+        /* androidShared */ ImmutableList.of(true, false));
+
+    SharedFile file0 = sharedFileManager.getSharedFile(keys[0]).get();
+    SharedFile file1 = sharedFileManager.getSharedFile(keys[1]).get();
+    Uri blobUri = DirectoryUtil.getBlobUri(context, file0.getAndroidSharingChecksum());
+    Uri leaseUri =
+        DirectoryUtil.getBlobStoreLeaseUri(context, file0.getAndroidSharingChecksum(), 0);
+
+    fileGroupManager
+        .downloadFileGroup(testKey, DownloadConditions.getDefaultInstance(), noCustomValidation())
+        .get();
+
+    // Verify that pending key is removed if download is complete.
+    assertThat(readPendingFileGroup(testKey)).isNull();
+
+    // Verify that downloaded key is written into metadata if download is complete.
+    assertThat(readDownloadedFileGroup(testKey)).isNotNull();
+
+    verify(mockBackend, never()).exists(blobUri);
+    verify(mockBackend, never()).openForWrite(blobUri);
+    verify(mockBackend, never()).openForWrite(leaseUri);
+
+    assertThat(sharedFileManager.getSharedFile(keys[0]).get()).isEqualTo(file0);
+    assertThat(sharedFileManager.getSharedFile(keys[1]).get()).isEqualTo(file1);
+
+    ArgumentCaptor<Void> mddAndroidSharingLogArgumentCaptor = ArgumentCaptor.forClass(Void.class);
+  }
+
+  @Test
+  public void testDownloadFileGroup_pending_oneBlobExistsBeforeDownload() throws Exception {
+    // Write 1 group to the pending shared prefs.
+    DataFileGroupInternal fileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 0);
+    ExtraHttpHeader extraHttpHeader =
+        ExtraHttpHeader.newBuilder().setKey("user-agent").setValue("mdd-downloader").build();
+
+    fileGroup =
+        fileGroup.toBuilder()
+            .setOwnerPackage(context.getPackageName())
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .setExpirationDateSecs(FILE_GROUP_EXPIRATION_DATE_SECS)
+            .setTrafficTag(TRAFFIC_TAG)
+            .addGroupExtraHttpHeaders(extraHttpHeader)
+            .addFile(0, MddTestUtil.createSharedDataFile(TEST_GROUP, 0))
+            .addFile(1, MddTestUtil.createDataFile(TEST_GROUP, 1))
+            .build();
+
+    NewFileKey[] keys = MddTestUtil.createFileKeysForDataFileGroupInternal(fileGroup);
+    writePendingFileGroup(testKey, fileGroup);
+
+    writeSharedFiles(
+        sharedFilesMetadata,
+        fileGroup,
+        ImmutableList.of(FileStatus.DOWNLOAD_IN_PROGRESS, FileStatus.DOWNLOAD_IN_PROGRESS));
+
+    // File that can be shared
+    DataFile file = fileGroup.getFile(0);
+    Uri blobUri = DirectoryUtil.getBlobUri(context, file.getAndroidSharingChecksum());
+    Uri leaseUri =
+        DirectoryUtil.getBlobStoreLeaseUri(
+            context, file.getAndroidSharingChecksum(), FILE_GROUP_EXPIRATION_DATE_SECS);
+    // The file is available in the blob storage
+    when(mockBackend.exists(blobUri)).thenReturn(true);
+
+    // First file's download succeeds
+    Uri onDeviceuri =
+        DirectoryUtil.getOnDeviceUri(
+            context,
+            keys[0].getAllowedReaders(),
+            file.getFileId(),
+            keys[0].getChecksum(),
+            mockSilentFeedback,
+            /* instanceId= */ Optional.absent(),
+            /* androidShared = */ false);
+    fileDownloadSucceeds(keys[0], onDeviceuri);
+
+    // Second file's download succeeds
+    onDeviceuri =
+        DirectoryUtil.getOnDeviceUri(
+            context,
+            keys[1].getAllowedReaders(),
+            fileGroup.getFile(1).getFileId(),
+            keys[1].getChecksum(),
+            mockSilentFeedback,
+            /* instanceId= */ Optional.absent(),
+            /* androidShared = */ false);
+    fileDownloadSucceeds(keys[1], onDeviceuri);
+
+    fileGroupManager
+        .downloadFileGroup(testKey, DownloadConditions.getDefaultInstance(), noCustomValidation())
+        .get();
+
+    // Verify that the pending group is not part of pending groups prefs.
+    assertThat(readPendingFileGroup(testKey)).isNull();
+
+    // Verify that the downloaded group is still part of downloaded groups prefs.
+    assertThat(readDownloadedFileGroup(testKey)).isNotNull();
+
+    verify(mockBackend).exists(blobUri);
+    // openForWrite is called only once in tryToShareBeforeDownload for acquiring the lease.
+    verify(mockBackend, never()).openForWrite(blobUri);
+    verify(mockBackend).openForWrite(leaseUri);
+
+    SharedFile expectedSharedFile0 =
+        SharedFile.newBuilder()
+            .setFileName("android_shared_sha256_1230")
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .setAndroidShared(true)
+            .setAndroidSharingChecksum("sha256_1230")
+            .setMaxExpirationDateSecs(FILE_GROUP_EXPIRATION_DATE_SECS)
+            .build();
+    SharedFile expectedSharedFile1 =
+        SharedFile.newBuilder()
+            .setFileName(fileGroup.getFile(1).getFileId())
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .setMaxExpirationDateSecs(FILE_GROUP_EXPIRATION_DATE_SECS)
+            .build();
+    assertThat(sharedFileManager.getSharedFile(keys[0]).get()).isEqualTo(expectedSharedFile0);
+    assertThat(sharedFileManager.getSharedFile(keys[1]).get()).isEqualTo(expectedSharedFile1);
+  }
+
+  @Test
+  public void testDownloadFileGroup_pending_oneBlobExistsAfterDownload() throws Exception {
+    // Write 1 group to the pending shared prefs.
+    DataFileGroupInternal tmpFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 0);
+    ExtraHttpHeader extraHttpHeader =
+        ExtraHttpHeader.newBuilder().setKey("user-agent").setValue("mdd-downloader").build();
+
+    final DataFileGroupInternal fileGroup =
+        tmpFileGroup.toBuilder()
+            .setOwnerPackage(context.getPackageName())
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .setExpirationDateSecs(FILE_GROUP_EXPIRATION_DATE_SECS)
+            .setTrafficTag(TRAFFIC_TAG)
+            .addGroupExtraHttpHeaders(extraHttpHeader)
+            .addFile(0, MddTestUtil.createSharedDataFile(TEST_GROUP, 0))
+            .addFile(1, MddTestUtil.createDataFile(TEST_GROUP, 1))
+            .build();
+
+    NewFileKey[] keys = MddTestUtil.createFileKeysForDataFileGroupInternal(fileGroup);
+    writePendingFileGroup(testKey, fileGroup);
+
+    writeSharedFiles(
+        sharedFilesMetadata,
+        fileGroup,
+        ImmutableList.of(FileStatus.DOWNLOAD_IN_PROGRESS, FileStatus.DOWNLOAD_COMPLETE));
+
+    // File that can be shared
+    DataFile file = fileGroup.getFile(0);
+    Uri blobUri = DirectoryUtil.getBlobUri(context, file.getAndroidSharingChecksum());
+    Uri leaseUri =
+        DirectoryUtil.getBlobStoreLeaseUri(
+            context, file.getAndroidSharingChecksum(), FILE_GROUP_EXPIRATION_DATE_SECS);
+
+    // The file isn't available in the blob storage when tryToShareBeforeDownload is called
+    when(mockBackend.exists(blobUri)).thenReturn(false);
+
+    simulateDownload(file, file.getFileId());
+    Uri onDeviceuri =
+        DirectoryUtil.getOnDeviceUri(
+            context,
+            keys[0].getAllowedReaders(),
+            file.getFileId(),
+            keys[0].getChecksum(),
+            mockSilentFeedback,
+            /* instanceId= */ Optional.absent(),
+            /* androidShared = */ false);
+    assertThat(fileStorage.exists(onDeviceuri)).isTrue();
+
+    when(mockDownloader.startDownloading(
+            any(GroupKey.class),
+            anyInt(),
+            anyLong(),
+            eq(onDeviceuri),
+            any(String.class),
+            anyInt(),
+            any(DownloadConditions.class),
+            isA(DownloaderCallbackImpl.class),
+            anyInt(),
+            anyList()))
+        .then(
+            new Answer<ListenableFuture<Void>>() {
+              @Override
+              public ListenableFuture<Void> answer(InvocationOnMock invocation) throws Throwable {
+                // The file now exists in the shared storage
+                when(mockBackend.exists(blobUri)).thenReturn(true);
+                writeSharedFiles(
+                    sharedFilesMetadata,
+                    fileGroup,
+                    ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE, FileStatus.DOWNLOAD_COMPLETE));
+                return Futures.immediateVoidFuture();
+              }
+            });
+
+    fileGroupManager
+        .downloadFileGroup(testKey, DownloadConditions.getDefaultInstance(), noCustomValidation())
+        .get();
+
+    // Verify that pending key is removed if download is complete.
+    assertThat(readPendingFileGroup(testKey)).isNull();
+
+    // Verify that downloaded key is written into metadata if download is complete.
+    assertThat(readDownloadedFileGroup(testKey)).isNotNull();
+
+    // exists called once in tryToShareBeforeDownload and once in tryToShareAfterDownload
+    verify(mockBackend, times(2)).exists(blobUri);
+    // openForWrite is called only once in tryToShareAfterDownload for acquiring the lease.
+    verify(mockBackend, never()).openForWrite(blobUri);
+    verify(mockBackend).openForWrite(leaseUri);
+
+    SharedFile expectedSharedFile0 =
+        SharedFile.newBuilder()
+            .setFileName("android_shared_sha256_1230")
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .setAndroidShared(true)
+            .setAndroidSharingChecksum("sha256_1230")
+            .setMaxExpirationDateSecs(FILE_GROUP_EXPIRATION_DATE_SECS)
+            .build();
+    SharedFile expectedSharedFile1 =
+        SharedFile.newBuilder()
+            .setFileName(fileGroup.getFile(1).getFileId())
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .setMaxExpirationDateSecs(FILE_GROUP_EXPIRATION_DATE_SECS)
+            .build();
+    assertThat(sharedFileManager.getSharedFile(keys[0]).get()).isEqualTo(expectedSharedFile0);
+    assertThat(sharedFileManager.getSharedFile(keys[1]).get()).isEqualTo(expectedSharedFile1);
+
+    // tryToShareAfterDownload deletes the file
+    assertThat(fileStorage.exists(onDeviceuri)).isFalse();
+
+    ArgumentCaptor<Void> mddAndroidSharingLogArgumentCaptor = ArgumentCaptor.forClass(Void.class);
+  }
+
+  @Test
+  public void testDownloadFileGroup_success_oneFileCanBeCopiedBeforeDownload() throws Exception {
+    File tempFile = folder.newFile("blobFile");
+    // Write 1 group to the pending shared prefs.
+    DataFileGroupInternal fileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 0);
+    ExtraHttpHeader extraHttpHeader =
+        ExtraHttpHeader.newBuilder().setKey("user-agent").setValue("mdd-downloader").build();
+
+    fileGroup =
+        fileGroup.toBuilder()
+            .setOwnerPackage(context.getPackageName())
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .setTrafficTag(TRAFFIC_TAG)
+            .addGroupExtraHttpHeaders(extraHttpHeader)
+            .addFile(0, MddTestUtil.createSharedDataFile(TEST_GROUP, 0))
+            .addFile(1, MddTestUtil.createDataFile(TEST_GROUP, 1))
+            .build();
+    NewFileKey[] keys = MddTestUtil.createFileKeysForDataFileGroupInternal(fileGroup);
+    writePendingFileGroup(testKey, fileGroup);
+
+    // All files are downloaded.
+    writeSharedFiles(
+        sharedFilesMetadata,
+        fileGroup,
+        ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE, FileStatus.DOWNLOAD_COMPLETE));
+
+    DataFile file = fileGroup.getFile(0);
+    Uri blobUri = DirectoryUtil.getBlobUri(context, file.getAndroidSharingChecksum());
+    Uri leaseUri = DirectoryUtil.getBlobStoreLeaseUri(context, file.getAndroidSharingChecksum(), 0);
+    // The file isn't available yet in the blob storage
+    when(mockBackend.exists(blobUri)).thenReturn(false);
+    // File that can be copied to the blob storage
+    when(mockBackend.openForWrite(blobUri)).thenReturn(new FileOutputStream(tempFile));
+
+    File onDeviceFile = simulateDownload(file, fileGroup.getFile(0).getFileId());
+    Uri onDeviceuri =
+        DirectoryUtil.getOnDeviceUri(
+            context,
+            keys[0].getAllowedReaders(),
+            fileGroup.getFile(0).getFileId(),
+            keys[0].getChecksum(),
+            mockSilentFeedback,
+            /* instanceId= */ Optional.absent(),
+            /* androidShared = */ false);
+    assertThat(fileStorage.exists(onDeviceuri)).isTrue();
+
+    fileGroupManager
+        .downloadFileGroup(testKey, DownloadConditions.getDefaultInstance(), noCustomValidation())
+        .get();
+
+    // Verify that pending key is removed if download is complete.
+    assertThat(readPendingFileGroup(testKey)).isNull();
+
+    // Verify that downloaded key is written into metadata if download is complete.
+    assertThat(readDownloadedFileGroup(testKey)).isNotNull();
+
+    // exists only called once in tryToShareBeforeDownload
+    verify(mockBackend).exists(blobUri);
+    // openForWrite is called in tryToShareBeforeDownload for copying the file and acquiring the
+    // lease.
+    verify(mockBackend).openForWrite(blobUri);
+    verify(mockBackend).openForWrite(leaseUri);
+
+    SharedFile expectedSharedFile0 =
+        SharedFile.newBuilder()
+            .setFileName("android_shared_sha256_1230")
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .setAndroidShared(true)
+            .setAndroidSharingChecksum("sha256_1230")
+            .setMaxExpirationDateSecs(0)
+            .build();
+    SharedFile expectedSharedFile1 =
+        SharedFile.newBuilder()
+            .setFileName(fileGroup.getFile(1).getFileId())
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .build();
+    assertThat(sharedFileManager.getSharedFile(keys[0]).get()).isEqualTo(expectedSharedFile0);
+    assertThat(sharedFileManager.getSharedFile(keys[1]).get()).isEqualTo(expectedSharedFile1);
+
+    assertThat(fileStorage.exists(onDeviceuri)).isTrue();
+    onDeviceFile.delete();
+
+    ArgumentCaptor<Void> mddAndroidSharingLogArgumentCaptor = ArgumentCaptor.forClass(Void.class);
+  }
+
+  @Test
+  public void testDownloadFileGroup_oneFileCanBeCopiedAfterDownload() throws Exception {
+    File tempFile = folder.newFile("blobFile");
+    // Write 1 group to the pending shared prefs.
+    DataFileGroupInternal tmpFfileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 0);
+    ExtraHttpHeader extraHttpHeader =
+        ExtraHttpHeader.newBuilder().setKey("user-agent").setValue("mdd-downloader").build();
+
+    final DataFileGroupInternal fileGroup =
+        tmpFfileGroup.toBuilder()
+            .setOwnerPackage(context.getPackageName())
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .setTrafficTag(TRAFFIC_TAG)
+            .addGroupExtraHttpHeaders(extraHttpHeader)
+            .addFile(0, MddTestUtil.createSharedDataFile(TEST_GROUP, 0))
+            .addFile(1, MddTestUtil.createDataFile(TEST_GROUP, 1))
+            .build();
+    NewFileKey[] keys = MddTestUtil.createFileKeysForDataFileGroupInternal(fileGroup);
+    writePendingFileGroup(testKey, fileGroup);
+
+    writeSharedFiles(
+        sharedFilesMetadata,
+        fileGroup,
+        ImmutableList.of(FileStatus.DOWNLOAD_IN_PROGRESS, FileStatus.DOWNLOAD_COMPLETE));
+
+    // File that can be copied to the blob storage
+    DataFile file = fileGroup.getFile(0);
+    Uri blobUri = DirectoryUtil.getBlobUri(context, file.getAndroidSharingChecksum());
+    Uri leaseUri = DirectoryUtil.getBlobStoreLeaseUri(context, file.getAndroidSharingChecksum(), 0);
+    // The file is available in the blob storage
+    when(mockBackend.exists(blobUri)).thenReturn(false);
+
+    simulateDownload(file, file.getFileId());
+    Uri onDeviceuri =
+        DirectoryUtil.getOnDeviceUri(
+            context,
+            keys[0].getAllowedReaders(),
+            file.getFileId(),
+            keys[0].getChecksum(),
+            mockSilentFeedback,
+            /* instanceId= */ Optional.absent(),
+            /* androidShared = */ false);
+    assertThat(fileStorage.exists(onDeviceuri)).isTrue();
+
+    when(mockDownloader.startDownloading(
+            any(GroupKey.class),
+            anyInt(),
+            anyLong(),
+            eq(onDeviceuri),
+            any(String.class),
+            anyInt(),
+            any(DownloadConditions.class),
+            isA(DownloaderCallbackImpl.class),
+            anyInt(),
+            anyList()))
+        .then(
+            new Answer<ListenableFuture<Void>>() {
+              @Override
+              public ListenableFuture<Void> answer(InvocationOnMock invocation) throws Throwable {
+                // The file will be copied in tryToShareAfterDownload
+                when(mockBackend.openForWrite(blobUri)).thenReturn(new FileOutputStream(tempFile));
+                writeSharedFiles(
+                    sharedFilesMetadata,
+                    fileGroup,
+                    ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE, FileStatus.DOWNLOAD_COMPLETE));
+                return Futures.immediateVoidFuture();
+              }
+            });
+
+    fileGroupManager
+        .downloadFileGroup(testKey, DownloadConditions.getDefaultInstance(), noCustomValidation())
+        .get();
+
+    // Verify that pending key is removed if download is complete.
+    assertThat(readPendingFileGroup(testKey)).isNull();
+
+    // Verify that downloaded key is written into metadata if download is complete.
+    assertThat(readDownloadedFileGroup(testKey)).isNotNull();
+
+    // exists only called once in tryToShareBeforeDownload, once in tryToShareAfterDownload
+    verify(mockBackend, times(2)).exists(blobUri);
+    //  File copied once in tryToShareAfterDownload
+    verify(mockBackend).openForWrite(blobUri);
+    verify(mockBackend).openForWrite(leaseUri);
+
+    SharedFile expectedSharedFile0 =
+        SharedFile.newBuilder()
+            .setFileName("android_shared_sha256_1230")
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .setAndroidShared(true)
+            .setAndroidSharingChecksum("sha256_1230")
+            .setMaxExpirationDateSecs(0)
+            .build();
+    SharedFile expectedSharedFile1 =
+        SharedFile.newBuilder()
+            .setFileName(fileGroup.getFile(1).getFileId())
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .build();
+    assertThat(sharedFileManager.getSharedFile(keys[0]).get()).isEqualTo(expectedSharedFile0);
+    assertThat(sharedFileManager.getSharedFile(keys[1]).get()).isEqualTo(expectedSharedFile1);
+
+    // File deleted after being copied to the blob storage.
+    assertThat(fileStorage.exists(onDeviceuri)).isFalse();
+
+    ArgumentCaptor<Void> mddAndroidSharingLogArgumentCaptor = ArgumentCaptor.forClass(Void.class);
+  }
+
+  @Test
+  public void testDownloadFileGroup_nonToBeSharedFile_neverShared() throws Exception {
+    // Write 1 group to the pending shared prefs.
+    DataFileGroupInternal tmpFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1);
+    ExtraHttpHeader extraHttpHeader =
+        ExtraHttpHeader.newBuilder().setKey("user-agent").setValue("mdd-downloader").build();
+
+    final DataFileGroupInternal fileGroup =
+        tmpFileGroup.toBuilder()
+            .setOwnerPackage(context.getPackageName())
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .setExpirationDateSecs(FILE_GROUP_EXPIRATION_DATE_SECS)
+            .setTrafficTag(TRAFFIC_TAG)
+            .addGroupExtraHttpHeaders(extraHttpHeader)
+            .build();
+    NewFileKey[] keys = MddTestUtil.createFileKeysForDataFileGroupInternal(fileGroup);
+    writePendingFileGroup(testKey, fileGroup);
+
+    writeSharedFiles(
+        sharedFilesMetadata, fileGroup, ImmutableList.of(FileStatus.DOWNLOAD_IN_PROGRESS));
+
+    DataFile file = fileGroup.getFile(0);
+    File onDeviceFile = simulateDownload(file, file.getFileId());
+    Uri onDeviceuri =
+        DirectoryUtil.getOnDeviceUri(
+            context,
+            keys[0].getAllowedReaders(),
+            file.getFileId(),
+            keys[0].getChecksum(),
+            mockSilentFeedback,
+            /* instanceId= */ Optional.absent(),
+            /* androidShared = */ false);
+    assertThat(fileStorage.exists(onDeviceuri)).isTrue();
+
+    fileDownloadSucceeds(keys[0], onDeviceuri);
+
+    fileGroupManager
+        .downloadFileGroup(testKey, DownloadConditions.getDefaultInstance(), noCustomValidation())
+        .get();
+
+    // Verify that pending key is removed if download is complete.
+    assertThat(readPendingFileGroup(testKey)).isNull();
+
+    // Verify that downloaded key is written into metadata if download is complete.
+    assertThat(readDownloadedFileGroup(testKey)).isNotNull();
+
+    verify(mockBackend, never()).exists(any());
+    verify(mockBackend, never()).openForWrite(any());
+
+    SharedFile expectedSharedFile =
+        SharedFile.newBuilder()
+            .setFileName(file.getFileId())
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .setMaxExpirationDateSecs(FILE_GROUP_EXPIRATION_DATE_SECS)
+            .build();
+    assertThat(sharedFileManager.getSharedFile(keys[0]).get()).isEqualTo(expectedSharedFile);
+
+    assertThat(fileStorage.exists(onDeviceuri)).isTrue();
+    onDeviceFile.delete();
+  }
+
+  @Test
+  public void testDownloadFileGroup_androidSharingFails() throws Exception {
+    // Write 1 group to the pending shared prefs.
+    DataFileGroupInternal tmpFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 0);
+    ExtraHttpHeader extraHttpHeader =
+        ExtraHttpHeader.newBuilder().setKey("user-agent").setValue("mdd-downloader").build();
+
+    final DataFileGroupInternal fileGroup =
+        tmpFileGroup.toBuilder()
+            .setOwnerPackage(context.getPackageName())
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .setTrafficTag(TRAFFIC_TAG)
+            .addGroupExtraHttpHeaders(extraHttpHeader)
+            .addFile(0, MddTestUtil.createDataFile(TEST_GROUP, 0))
+            .addFile(1, MddTestUtil.createSharedDataFile(TEST_GROUP, 1))
+            .build();
+    NewFileKey[] keys = MddTestUtil.createFileKeysForDataFileGroupInternal(fileGroup);
+    writePendingFileGroup(testKey, fileGroup);
+
+    writeSharedFiles(
+        sharedFilesMetadata,
+        fileGroup,
+        ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE, FileStatus.DOWNLOAD_COMPLETE));
+
+    // Second file fails with file storage I/O exception when called from tryToShareBeforeDownload
+    // and tryToShareAfterDownload.
+    DataFile file = fileGroup.getFile(1);
+    Uri blobUri = DirectoryUtil.getBlobUri(context, file.getAndroidSharingChecksum());
+    when(mockBackend.exists(blobUri)).thenThrow(new IOException());
+
+    // Any error during sharing doesn't stop the download: the file will be stored locally.
+    fileGroupManager
+        .downloadFileGroup(testKey, DownloadConditions.getDefaultInstance(), noCustomValidation())
+        .get();
+
+    // Verify that pending key is removed if download is complete.
+    assertThat(readPendingFileGroup(testKey)).isNull();
+
+    // Verify that downloaded key is written into metadata if download is complete.
+    assertThat(readDownloadedFileGroup(testKey)).isNotNull();
+
+    verify(mockBackend, times(2)).exists(blobUri);
+    verify(mockBackend, never()).openForWrite(any());
+
+    SharedFile expectedSharedFile0 =
+        SharedFile.newBuilder()
+            .setFileName(fileGroup.getFile(0).getFileId())
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .build();
+    SharedFile expectedSharedFile1 =
+        expectedSharedFile0.toBuilder().setFileName(fileGroup.getFile(1).getFileId()).build();
+    assertThat(sharedFileManager.getSharedFile(keys[0]).get()).isEqualTo(expectedSharedFile0);
+    assertThat(sharedFileManager.getSharedFile(keys[1]).get()).isEqualTo(expectedSharedFile1);
+  }
+
+  @Test
+  public void testDownloadFileGroup_skipsSideloadedFiles() throws Exception {
+    // Create sideloaded group with normal file
+    DataFileGroupInternal sideloadedGroup =
+        DataFileGroupInternal.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .addFile(
+                DataFile.newBuilder()
+                    .setFileId("sideloaded_file")
+                    .setUrlToDownload("file:/test")
+                    .setChecksumType(DataFile.ChecksumType.NONE)
+                    .build())
+            .addFile(
+                DataFile.newBuilder()
+                    .setFileId("normal_file")
+                    .setUrlToDownload("https://url.to.download")
+                    .setChecksumType(DataFile.ChecksumType.NONE)
+                    .build())
+            .build();
+    NewFileKey normalFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(
+            sideloadedGroup.getFile(1), sideloadedGroup.getAllowedReadersEnum());
+
+    // Write group as pending since we are waiting on normal file
+    writePendingFileGroup(testKey, sideloadedGroup);
+    SharedFile normalSharedFile =
+        SharedFile.newBuilder()
+            .setFileName(sideloadedGroup.getFile(1).getFileId())
+            .setFileStatus(FileStatus.DOWNLOAD_IN_PROGRESS)
+            .build();
+    sharedFilesMetadata.write(normalFileKey, normalSharedFile).get();
+
+    // Mock that download of normal file succeeds
+    Uri normalFileUri =
+        DirectoryUtil.getOnDeviceUri(
+            context,
+            normalFileKey.getAllowedReaders(),
+            sideloadedGroup.getFile(1).getFileId(),
+            sideloadedGroup.getFile(1).getChecksum(),
+            mockSilentFeedback,
+            /* instanceId = */ Optional.absent(),
+            false);
+    fileDownloadSucceeds(normalFileKey, normalFileUri);
+
+    fileGroupManager
+        .downloadFileGroup(testKey, DownloadConditions.getDefaultInstance(), noCustomValidation())
+        .get();
+
+    assertThat(readPendingFileGroup(testKey)).isNull();
+    assertThat(readDownloadedFileGroup(testKey)).isNotNull();
+
+    verify(mockDownloader)
+        .startDownloading(
+            eq(testKey),
+            anyInt(),
+            anyLong(),
+            eq(normalFileUri),
+            eq(sideloadedGroup.getFile(1).getUrlToDownload()),
+            anyInt(),
+            any(),
+            any(),
+            anyInt(),
+            anyList());
+  }
+
+  @Test
+  public void testDownloadFileGroup_whenMultipleVariantsExist_downloadsSpecifiedVariant()
+      throws Exception {
+    GroupKey defaultGroupKey =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setOwnerPackage(context.getPackageName())
+            .build();
+    GroupKey enGroupKey = defaultGroupKey.toBuilder().setVariantId("en").build();
+
+    DataFileGroupInternal defaultFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1);
+    // Create EN with custom file ids so it doesn't overlap with the default file group.
+    DataFileGroupInternal enFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 0).toBuilder()
+            .addFile(MddTestUtil.createDataFile("en", 0))
+            .addFile(MddTestUtil.createDataFile("en", 1))
+            .build();
+
+    writePendingFileGroup(getPendingKey(defaultGroupKey), defaultFileGroup);
+    writePendingFileGroup(getPendingKey(enGroupKey), enFileGroup);
+
+    writeSharedFiles(
+        sharedFilesMetadata, defaultFileGroup, ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE));
+
+    fileGroupManager
+        .downloadFileGroup(
+            defaultGroupKey, DownloadConditions.getDefaultInstance(), noCustomValidation())
+        .get();
+
+    // Verify that correct group was downloaded
+    assertThat(readPendingFileGroup(defaultGroupKey)).isNull();
+    assertThat(readDownloadedFileGroup(defaultGroupKey)).isNotNull();
+
+    assertThat(readPendingFileGroup(enGroupKey)).isNotNull();
+    assertThat(readDownloadedFileGroup(enGroupKey)).isNull();
+
+    // Attempt to download en group and check that it is now downloaded
+    writeSharedFiles(
+        sharedFilesMetadata,
+        enFileGroup,
+        ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE, FileStatus.DOWNLOAD_COMPLETE));
+
+    fileGroupManager
+        .downloadFileGroup(
+            enGroupKey, DownloadConditions.getDefaultInstance(), noCustomValidation())
+        .get();
+
+    // Verify that correct group was downloaded
+    assertThat(readPendingFileGroup(defaultGroupKey)).isNull();
+    assertThat(readDownloadedFileGroup(defaultGroupKey)).isNotNull();
+
+    assertThat(readPendingFileGroup(enGroupKey)).isNull();
+    assertThat(readDownloadedFileGroup(enGroupKey)).isNotNull();
+  }
+
+  @Test
+  public void testDownloadAllPendingGroups_onWifi() throws Exception {
+    // Write 3 groups to the pending shared prefs.
+    // MDD successfully downloaded filegroup1, partially downloaded filegroup2 and failed to
+    // download filegroup3.
+    DataFileGroupInternal fileGroup1 =
+        createDataFileGroup(
+            TEST_GROUP,
+            /*fileCount=*/ 2,
+            /*downloadAttemptCount=*/ 7,
+            /*newFilesReceivedTimestamp=*/ testClock.currentTimeMillis() - 500L);
+    fileGroup1 =
+        fileGroup1.toBuilder()
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .build();
+    writePendingFileGroup(testKey, fileGroup1);
+    // All files are downloaded.
+    writeSharedFiles(
+        sharedFilesMetadata,
+        fileGroup1,
+        ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE, FileStatus.DOWNLOAD_COMPLETE));
+
+    DataFileGroupInternal fileGroup2 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP_2, 2);
+    fileGroup2 =
+        fileGroup2.toBuilder()
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .build();
+    writePendingFileGroup(testKey2, fileGroup2);
+    // Not all files are downloaded.
+    writeSharedFiles(
+        sharedFilesMetadata,
+        fileGroup2,
+        ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE, FileStatus.DOWNLOAD_IN_PROGRESS));
+
+    GroupKey expectedKey2 = testKey2.toBuilder().setDownloaded(false).build();
+    // The file status isn't changed to DOWNLOAD_COMPLETE, it remains DOWNLOAD_IN_PROGRESS.
+    //  An UNKNOWN_ERROR is logged.
+    when(mockDownloader.startDownloading(
+            eq(expectedKey2),
+            anyInt(),
+            anyLong(),
+            any(Uri.class),
+            any(String.class),
+            anyInt(),
+            any(DownloadConditions.class),
+            isA(DownloaderCallbackImpl.class),
+            anyInt(),
+            anyList()))
+        .thenReturn(Futures.immediateVoidFuture());
+
+    DataFileGroupInternal tmpFileGroup3 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP_3, 2);
+    final DataFileGroupInternal fileGroup3 =
+        tmpFileGroup3.toBuilder()
+            .setDownloadConditions(
+                DownloadConditions.newBuilder().setDownloadFirstOnWifiPeriodSecs(1000000))
+            .build();
+    writePendingFileGroup(testKey3, fileGroup3);
+    // Not all files are downloaded.
+    writeSharedFiles(
+        sharedFilesMetadata,
+        fileGroup3,
+        ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE, FileStatus.DOWNLOAD_IN_PROGRESS));
+
+    GroupKey expectedKey3 = testKey3.toBuilder().setDownloaded(false).build();
+    // One file fails, new status is DOWNLOAD_FAILED but the downloader returns an
+    // immediateVoidFuture. An UNKNOWN_ERROR is logged.
+    when(mockDownloader.startDownloading(
+            eq(expectedKey3),
+            anyInt(),
+            anyLong(),
+            any(Uri.class),
+            any(String.class),
+            anyInt(),
+            any(DownloadConditions.class),
+            isA(DownloaderCallbackImpl.class),
+            anyInt(),
+            anyList()))
+        .then(
+            new Answer<ListenableFuture<Void>>() {
+              @Override
+              public ListenableFuture<Void> answer(InvocationOnMock invocation) throws Throwable {
+                writeSharedFiles(
+                    sharedFilesMetadata,
+                    fileGroup3,
+                    ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE, FileStatus.DOWNLOAD_FAILED));
+                return Futures.immediateVoidFuture();
+              }
+            });
+
+    fileGroupManager.scheduleAllPendingGroupsForDownload(true, noCustomValidation()).get();
+  }
+
+  @Test
+  public void testDownloadAllPendingGroups_withoutWifi() throws Exception {
+    // Write 2 groups to the pending shared prefs.
+    DataFileGroupInternal fileGroup1 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+    fileGroup1 =
+        fileGroup1.toBuilder()
+            .setDownloadConditions(
+                DownloadConditions.newBuilder()
+                    .setDeviceNetworkPolicy(DeviceNetworkPolicy.DOWNLOAD_ON_ANY_NETWORK))
+            .build();
+    writePendingFileGroup(testKey, fileGroup1);
+    writeSharedFiles(
+        sharedFilesMetadata,
+        fileGroup1,
+        ImmutableList.of(FileStatus.DOWNLOAD_IN_PROGRESS, FileStatus.DOWNLOAD_IN_PROGRESS));
+
+    DataFileGroupInternal fileGroup2 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP_2, 3);
+    writePendingFileGroup(testKey2, fileGroup2);
+    writeSharedFiles(
+        sharedFilesMetadata,
+        fileGroup2,
+        ImmutableList.of(
+            FileStatus.DOWNLOAD_IN_PROGRESS,
+            FileStatus.DOWNLOAD_IN_PROGRESS,
+            FileStatus.DOWNLOAD_IN_PROGRESS));
+
+    fileGroupManager.scheduleAllPendingGroupsForDownload(false, noCustomValidation()).get();
+
+    // Only the files in the first group will be downloaded.
+    verify(mockDownloader, times(2))
+        .startDownloading(
+            eq(getPendingKey(testKey)),
+            anyInt(),
+            anyLong(),
+            any(Uri.class),
+            any(String.class),
+            anyInt(),
+            any(DownloadConditions.class),
+            isA(DownloaderCallbackImpl.class),
+            anyInt(),
+            anyList());
+    verifyNoMoreInteractions(mockDownloader);
+  }
+
+  @Test
+  public void testDownloadAllPendingGroups_wifiFirst_without_Wifi() throws Exception {
+    DataFileGroupInternal dataFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2).toBuilder()
+            .setDownloadConditions(
+                DownloadConditions.newBuilder()
+                    .setDeviceNetworkPolicy(
+                        DeviceNetworkPolicy.DOWNLOAD_FIRST_ON_WIFI_THEN_ON_ANY_NETWORK)
+                    .setDownloadFirstOnWifiPeriodSecs(10))
+            .build();
+
+    testClock.set(1000);
+
+    {
+      // Check that pending groups contain the added file group.
+      assertThat(fileGroupManager.addGroupForDownload(testKey, dataFileGroup).get()).isTrue();
+      verifyAddGroupForDownloadWritesMetadata(testKey, dataFileGroup, 1000);
+    }
+
+    {
+      // Set time so that it has not passed the wifi only period.
+      testClock.set(2000);
+      fileGroupManager.scheduleAllPendingGroupsForDownload(false, noCustomValidation()).get();
+    }
+
+    {
+      // Set time so that it has passed the wifi only period.
+      testClock.set(2000 + 10 * 1000);
+      ArgumentCaptor<DownloadConditions> downloadConditionCaptor =
+          ArgumentCaptor.forClass(DownloadConditions.class);
+      when(mockDownloader.startDownloading(
+              any(GroupKey.class),
+              anyInt(),
+              anyLong(),
+              any(Uri.class),
+              any(String.class),
+              anyInt(),
+              downloadConditionCaptor.capture(),
+              isA(DownloaderCallbackImpl.class),
+              anyInt(),
+              anyList()))
+          .thenReturn(Futures.immediateVoidFuture());
+
+      fileGroupManager.scheduleAllPendingGroupsForDownload(false, noCustomValidation()).get();
+
+      // verify that the group's DeviceNetworkPolicy changes to
+      // DeviceNetworkPolicy.DOWNLOAD_ON_ANY_NETWORK
+      assertThat(downloadConditionCaptor.getValue().getDeviceNetworkPolicy())
+          .isEqualTo(DeviceNetworkPolicy.DOWNLOAD_ON_ANY_NETWORK);
+    }
+  }
+
+  @Test
+  public void testDownloadAllPendingGroups_startDownloadFails() throws Exception {
+    // Write 2 groups to the pending shared prefs.
+    DataFileGroupInternal fileGroup1 =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2).toBuilder()
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .build();
+    writePendingFileGroup(testKey, fileGroup1);
+    writeSharedFiles(
+        sharedFilesMetadata,
+        fileGroup1,
+        ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE, FileStatus.DOWNLOAD_IN_PROGRESS));
+    NewFileKey[] keys1 = MddTestUtil.createFileKeysForDataFileGroupInternal(fileGroup1);
+
+    DataFileGroupInternal fileGroup2 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP_2, 1);
+    writePendingFileGroup(testKey2, fileGroup2);
+    writeSharedFiles(
+        sharedFilesMetadata, fileGroup2, ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE));
+
+    // Make the download call fail for one of the files in first group.
+    Uri failingFileUri =
+        DirectoryUtil.getOnDeviceUri(
+            context,
+            keys1[1].getAllowedReaders(),
+            fileGroup1.getFile(1).getFileId(),
+            fileGroup1.getFile(1).getChecksum(),
+            mockSilentFeedback,
+            /* instanceId= */ Optional.absent(),
+            false);
+    fileDownloadFails(keys1[1], failingFileUri, DownloadResultCode.LOW_DISK_ERROR);
+
+    fileGroupManager.scheduleAllPendingGroupsForDownload(true, noCustomValidation()).get();
+  }
+
+  // case 1: the file is already shared in the blob storage.
+  @Test
+  public void tryToShareBeforeDownload_alreadyShared() throws Exception {
+    DataFileGroupInternal fileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2).toBuilder()
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .build();
+
+    DataFile file = MddTestUtil.createSharedDataFile("fileId", 0);
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+    // Set the file metadata as already downloaded and shared
+    SharedFile existingDownloadedSharedFile =
+        SharedFile.newBuilder()
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .setFileName("")
+            .setAndroidShared(true)
+            .setAndroidSharingChecksum(file.getAndroidSharingChecksum())
+            .setMaxExpirationDateSecs(FILE_GROUP_EXPIRATION_DATE_SECS)
+            .build();
+    sharedFilesMetadata.write(newFileKey, existingDownloadedSharedFile).get();
+
+    fileGroupManager.tryToShareBeforeDownload(fileGroup, file, newFileKey).get();
+
+    verify(mockBackend, never()).exists(any());
+    // openForWrite isn't called to update the lease because the current fileGroup's expiration date
+    // is < maxExpirationDate.
+    verify(mockBackend, never()).openForWrite(any());
+
+    assertThat(sharedFileManager.getSharedFile(newFileKey).get())
+        .isEqualTo(existingDownloadedSharedFile);
+  }
+
+  // case 2a: the to-be-shared file is available in the blob storage.
+  @Test
+  public void tryToShareBeforeDownload_toBeSharedFile_blobExists() throws Exception {
+    // Create a file group with expiration date smaller than the expiration date of the existing
+    // SharedFile.
+    DataFileGroupInternal fileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2).toBuilder()
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .setExpirationDateSecs(FILE_GROUP_EXPIRATION_DATE_SECS - 1)
+            .build();
+
+    // Create a to-be-shared file
+    DataFile file = MddTestUtil.createSharedDataFile("fileId", 0);
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+    // Set the file metadata as download pending and non shared
+    SharedFile existingSharedFile =
+        SharedFile.newBuilder()
+            .setFileStatus(FileStatus.DOWNLOAD_IN_PROGRESS)
+            .setFileName("fileName")
+            .setAndroidShared(false)
+            .setMaxExpirationDateSecs(FILE_GROUP_EXPIRATION_DATE_SECS)
+            .build();
+
+    sharedFilesMetadata.write(newFileKey, existingSharedFile).get();
+
+    Uri blobUri = DirectoryUtil.getBlobUri(context, file.getAndroidSharingChecksum());
+    Uri leaseUri =
+        DirectoryUtil.getBlobStoreLeaseUri(
+            context, file.getAndroidSharingChecksum(), FILE_GROUP_EXPIRATION_DATE_SECS);
+    // The file is available in the blob storage
+    when(mockBackend.exists(blobUri)).thenReturn(true);
+
+    fileGroupManager.tryToShareBeforeDownload(fileGroup, file, newFileKey).get();
+
+    // openForWrite is called only once for acquiring the lease.
+    verify(mockBackend, never()).openForWrite(blobUri);
+    verify(mockBackend).openForWrite(leaseUri);
+    SharedFile sharedFile = sharedFileManager.getSharedFile(newFileKey).get();
+    // Verify that the SharedFile retains the longest expiration date after the download.
+    assertThat(sharedFile.getMaxExpirationDateSecs()).isEqualTo(FILE_GROUP_EXPIRATION_DATE_SECS);
+    assertThat(sharedFile.getAndroidShared()).isTrue();
+    assertThat(sharedFileManager.getOnDeviceUri(newFileKey).get()).isEqualTo(blobUri);
+
+    ArgumentCaptor<Void> mddAndroidSharingLogArgumentCaptor = ArgumentCaptor.forClass(Void.class);
+  }
+
+  // case 3: the to-be-shared file is available in the local storage.
+  @Test
+  public void tryToShareBeforeDownload_toBeSharedFile_canBeCopied() throws Exception {
+    File tempFile = folder.newFile("blobFile");
+    // Create a file group with expiration date bigger than the expiration date of the existing
+    // SharedFile.
+    DataFileGroupInternal fileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2).toBuilder()
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .setExpirationDateSecs(FILE_GROUP_EXPIRATION_DATE_SECS + 1)
+            .build();
+
+    // Create a to-be-shared file
+    DataFile file = MddTestUtil.createSharedDataFile("fileId", 0);
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+    // Set the file metadata as downloaded and non shared
+    SharedFile existingSharedFile =
+        SharedFile.newBuilder()
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .setFileName("fileName")
+            .setAndroidShared(false)
+            .setMaxExpirationDateSecs(FILE_GROUP_EXPIRATION_DATE_SECS)
+            .build();
+    sharedFilesMetadata.write(newFileKey, existingSharedFile).get();
+
+    Uri blobUri = DirectoryUtil.getBlobUri(context, file.getAndroidSharingChecksum());
+    Uri leaseUri =
+        DirectoryUtil.getBlobStoreLeaseUri(
+            context, file.getAndroidSharingChecksum(), FILE_GROUP_EXPIRATION_DATE_SECS + 1);
+    // The file isn't available in the blob storage
+    when(mockBackend.exists(blobUri)).thenReturn(false);
+    when(mockBackend.openForWrite(blobUri)).thenReturn(new FileOutputStream(tempFile));
+
+    File onDeviceFile = simulateDownload(file, existingSharedFile.getFileName());
+    Uri onDeviceuri =
+        DirectoryUtil.getOnDeviceUri(
+            context,
+            newFileKey.getAllowedReaders(),
+            existingSharedFile.getFileName(),
+            newFileKey.getChecksum(),
+            mockSilentFeedback,
+            /* instanceId= */ Optional.absent(),
+            /* androidShared = */ false);
+    assertThat(fileStorage.exists(onDeviceuri)).isTrue();
+
+    fileGroupManager.tryToShareBeforeDownload(fileGroup, file, newFileKey).get();
+
+    // openForWrite is called once for writing the blob, once for acquiring the lease.
+    verify(mockBackend).openForWrite(blobUri);
+    verify(mockBackend).openForWrite(leaseUri);
+
+    SharedFile sharedFile = sharedFileManager.getSharedFile(newFileKey).get();
+    // Verify that the SharedFile has updated its expiration date after the download.
+    assertThat(sharedFile.getMaxExpirationDateSecs())
+        .isEqualTo(FILE_GROUP_EXPIRATION_DATE_SECS + 1);
+    assertThat(sharedFile.getAndroidShared()).isTrue();
+    assertThat(sharedFileManager.getOnDeviceUri(newFileKey).get()).isEqualTo(blobUri);
+
+    // The local copy will be deleted in daily maintance
+    assertThat(fileStorage.exists(onDeviceuri)).isTrue();
+    onDeviceFile.delete();
+
+    ArgumentCaptor<Void> mddAndroidSharingLogArgumentCaptor = ArgumentCaptor.forClass(Void.class);
+  }
+
+  // The file can't be shared and isn't available locally.
+  @Test
+  public void tryToShareBeforeDownload_toBeSharedFile_cannotBeShared_neverDownloaded()
+      throws Exception {
+    DataFileGroupInternal fileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2).toBuilder()
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .build();
+
+    DataFile file = MddTestUtil.createSharedDataFile("fileId", 0);
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+    SharedFile existingSharedFile =
+        SharedFile.newBuilder()
+            .setFileStatus(FileStatus.DOWNLOAD_IN_PROGRESS)
+            .setFileName("fileName")
+            .setAndroidShared(false)
+            .build();
+    sharedFilesMetadata.write(newFileKey, existingSharedFile).get();
+
+    Uri blobUri = DirectoryUtil.getBlobUri(context, file.getAndroidSharingChecksum());
+    // The file isn't available in the blob storage
+    when(mockBackend.exists(blobUri)).thenReturn(false);
+
+    fileGroupManager.tryToShareBeforeDownload(fileGroup, file, newFileKey).get();
+
+    // We never acquire the lease nor update the max expiration date.
+    verify(mockBackend).exists(blobUri);
+    verify(mockBackend, never()).openForWrite(any());
+
+    SharedFile sharedFile = sharedFileManager.getSharedFile(newFileKey).get();
+    assertThat(sharedFile).isEqualTo(existingSharedFile);
+  }
+
+  // case 4: the non-to-be-shared file can't be shared and is available in the local storage.
+  @Test
+  public void tryToShareBeforeDownload_nonToBeSharedFile_alreadyDownloaded_cannotBeShared()
+      throws Exception {
+    DataFileGroupInternal fileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2).toBuilder()
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .build();
+    // non-to-be-shared file with ChecksumType SHA1
+    DataFile file = MddTestUtil.createDataFile("fileId", 0);
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+    // Set the file metadata downloaded and non shared
+    SharedFile existingSharedFile =
+        SharedFile.newBuilder()
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .setFileName("fileName")
+            .setAndroidShared(false)
+            .setMaxExpirationDateSecs(FILE_GROUP_EXPIRATION_DATE_SECS)
+            .build();
+    sharedFilesMetadata.write(newFileKey, existingSharedFile).get();
+
+    fileGroupManager.tryToShareBeforeDownload(fileGroup, file, newFileKey).get();
+
+    verify(mockBackend, never()).exists(any());
+    // We never acquire the lease since the file can't be shared.
+    verify(mockBackend, never()).openForWrite(any());
+
+    SharedFile sharedFile = sharedFileManager.getSharedFile(newFileKey).get();
+    assertThat(sharedFile).isEqualTo(existingSharedFile);
+
+    verify(mockSharedFileManager, never())
+        .setAndroidSharedDownloadedFileEntry(any(), any(), anyLong());
+  }
+
+  @Test
+  public void tryToShareBeforeDownload_blobUriNotSupported() throws Exception {
+    // FileStorage without BlobStoreBackend
+    fileStorage =
+        new SynchronousFileStorage(Arrays.asList(AndroidFileBackend.builder(context).build()));
+    fileGroupManager =
+        new FileGroupManager(
+            context,
+            mockLogger,
+            mockSilentFeedback,
+            fileGroupsMetadata,
+            sharedFileManager,
+            testClock,
+            Optional.of(mockAccountSource),
+            SEQUENTIAL_CONTROL_EXECUTOR,
+            Optional.absent(),
+            fileStorage,
+            downloadStageManager,
+            flags);
+
+    DataFileGroupInternal fileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 0).toBuilder()
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .build();
+
+    // Create a to-be-shared file
+    DataFile file = MddTestUtil.createSharedDataFile("fileId", 0);
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+    // Set the file metadata as download completed and non shared
+    SharedFile existingSharedFile =
+        SharedFile.newBuilder()
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .setFileName("fileName")
+            .setAndroidShared(false)
+            .setMaxExpirationDateSecs(FILE_GROUP_EXPIRATION_DATE_SECS)
+            .build();
+
+    sharedFilesMetadata.write(newFileKey, existingSharedFile).get();
+
+    fileGroupManager.tryToShareBeforeDownload(fileGroup, file, newFileKey).get();
+
+    verify(mockBackend, never()).openForWrite(any());
+
+    SharedFile sharedFile = sharedFileManager.getSharedFile(newFileKey).get();
+    assertThat(sharedFile).isEqualTo(existingSharedFile);
+  }
+
+  @Test
+  public void tryToShareBeforeDownload_setAndroidSharedDownloadedFileEntryReturnsFalse()
+      throws Exception {
+    // Mock SharedFileManager to test failure scenario.
+    resetFileGroupManager(fileGroupsMetadata, mockSharedFileManager);
+    DataFileGroupInternal fileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2).toBuilder()
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .build();
+
+    // Create a to-be-shared file
+    DataFile file = MddTestUtil.createSharedDataFile("fileId", 0);
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+
+    SharedFile existingSharedFile =
+        SharedFile.newBuilder()
+            .setFileStatus(FileStatus.DOWNLOAD_IN_PROGRESS)
+            .setFileName("fileName")
+            .setAndroidShared(false)
+            .setMaxExpirationDateSecs(FILE_GROUP_EXPIRATION_DATE_SECS)
+            .build();
+
+    when(mockSharedFileManager.getSharedFile(newFileKey))
+        .thenReturn(Futures.immediateFuture(existingSharedFile));
+    Uri blobUri = DirectoryUtil.getBlobUri(context, file.getAndroidSharingChecksum());
+    Uri leaseUri =
+        DirectoryUtil.getBlobStoreLeaseUri(
+            context, file.getAndroidSharingChecksum(), FILE_GROUP_EXPIRATION_DATE_SECS);
+    // The file is available in the blob storage
+    when(mockBackend.exists(blobUri)).thenReturn(true);
+
+    // Last operation fails
+    when(mockSharedFileManager.setAndroidSharedDownloadedFileEntry(
+            newFileKey, file.getAndroidSharingChecksum(), FILE_GROUP_EXPIRATION_DATE_SECS))
+        .thenReturn(Futures.immediateFuture(false));
+
+    fileGroupManager.tryToShareBeforeDownload(fileGroup, file, newFileKey).get();
+
+    // openForWrite is called only once for acquiring the lease.
+    verify(mockBackend, never()).openForWrite(blobUri);
+    verify(mockBackend).openForWrite(leaseUri);
+  }
+
+  @Test
+  public void tryToShareBeforeDownload_blobExistsThrowsIOException() throws Exception {
+    DataFileGroupInternal fileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2).toBuilder()
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .build();
+
+    // Create a to-be-shared file
+    DataFile file = MddTestUtil.createSharedDataFile("fileId", 0);
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+
+    SharedFile existingSharedFile =
+        SharedFile.newBuilder()
+            .setFileStatus(FileStatus.DOWNLOAD_IN_PROGRESS)
+            .setFileName("fileName")
+            .setAndroidShared(false)
+            .setMaxExpirationDateSecs(FILE_GROUP_EXPIRATION_DATE_SECS)
+            .build();
+    sharedFilesMetadata.write(newFileKey, existingSharedFile).get();
+
+    Uri blobUri = DirectoryUtil.getBlobUri(context, file.getAndroidSharingChecksum());
+    Uri leaseUri =
+        DirectoryUtil.getBlobStoreLeaseUri(
+            context, file.getAndroidSharingChecksum(), FILE_GROUP_EXPIRATION_DATE_SECS);
+
+    when(mockBackend.exists(blobUri)).thenReturn(true);
+    when(mockBackend.openForWrite(leaseUri)).thenThrow(new IOException());
+
+    fileGroupManager.tryToShareBeforeDownload(fileGroup, file, newFileKey).get();
+
+    SharedFile sharedFile = sharedFileManager.getSharedFile(newFileKey).get();
+    assertThat(sharedFile).isEqualTo(existingSharedFile);
+
+    ArgumentCaptor<Void> mddAndroidSharingLogArgumentCaptor = ArgumentCaptor.forClass(Void.class);
+  }
+
+  @Test
+  public void tryToShareBeforeDownload_fileStorageThrowsLimitExceededException() throws Exception {
+    // Create a file group with expiration date bigger than the expiration date of the existing
+    // SharedFile.
+    DataFileGroupInternal fileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2).toBuilder()
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .setExpirationDateSecs(FILE_GROUP_EXPIRATION_DATE_SECS + 1)
+            .build();
+
+    // Create a to-be-shared file
+    DataFile file = MddTestUtil.createSharedDataFile("fileId", 0);
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+
+    SharedFile existingSharedFile =
+        SharedFile.newBuilder()
+            .setFileStatus(FileStatus.DOWNLOAD_IN_PROGRESS)
+            .setFileName("fileName")
+            .setAndroidShared(false)
+            .setMaxExpirationDateSecs(FILE_GROUP_EXPIRATION_DATE_SECS)
+            .build();
+    sharedFilesMetadata.write(newFileKey, existingSharedFile).get();
+
+    Uri blobUri = DirectoryUtil.getBlobUri(context, file.getAndroidSharingChecksum());
+    Uri leaseUri =
+        DirectoryUtil.getBlobStoreLeaseUri(
+            context, file.getAndroidSharingChecksum(), FILE_GROUP_EXPIRATION_DATE_SECS + 1);
+    // The file is available in the blob storage
+    when(mockBackend.exists(blobUri)).thenReturn(true);
+    // Writing the lease throws an exception
+    when(mockBackend.openForWrite(leaseUri)).thenThrow(new LimitExceededException());
+
+    fileGroupManager.tryToShareBeforeDownload(fileGroup, file, newFileKey).get();
+
+    verify(mockBackend, never()).openForWrite(blobUri);
+    verify(mockBackend).openForWrite(leaseUri);
+
+    SharedFile sharedFile = sharedFileManager.getSharedFile(newFileKey).get();
+    // Since there was an exception, the existing shared file didn't update the expiration date.
+    assertThat(sharedFile).isEqualTo(existingSharedFile);
+  }
+
+  @Test
+  public void tryToShareAfterDownload_alreadyShared_sameFileGroup() throws Exception {
+    DataFileGroupInternal fileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder()
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .build();
+
+    DataFile file = MddTestUtil.createSharedDataFile("fileId", 0);
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+    SharedFile existingSharedFile =
+        SharedFile.newBuilder()
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .setFileName("fileName")
+            .setAndroidShared(true)
+            .setAndroidSharingChecksum(file.getAndroidSharingChecksum())
+            .setMaxExpirationDateSecs(FILE_GROUP_EXPIRATION_DATE_SECS)
+            .build();
+    sharedFilesMetadata.write(newFileKey, existingSharedFile).get();
+
+    fileGroupManager.tryToShareAfterDownload(fileGroup, file, newFileKey).get();
+
+    verify(mockBackend, never()).exists(any());
+    verify(mockBackend, never()).openForWrite(any());
+
+    SharedFile sharedFile = sharedFileManager.getSharedFile(newFileKey).get();
+    assertThat(sharedFile).isEqualTo(existingSharedFile);
+  }
+
+  @Test
+  public void tryToShareAfterDownload_alreadyShared_differentFileGroup() throws Exception {
+    // Create a file group with expiration date bigger than the expiration date of the existing
+    // SharedFile.
+    DataFileGroupInternal fileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder()
+            .setExpirationDateSecs(FILE_GROUP_EXPIRATION_DATE_SECS)
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .build();
+
+    DataFile file = MddTestUtil.createSharedDataFile("fileId", 0);
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+    SharedFile existingSharedFile =
+        SharedFile.newBuilder()
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .setFileName("fileName")
+            .setAndroidShared(true)
+            .setAndroidSharingChecksum(file.getAndroidSharingChecksum())
+            .setMaxExpirationDateSecs(FILE_GROUP_EXPIRATION_DATE_SECS - 1)
+            .build();
+    sharedFilesMetadata.write(newFileKey, existingSharedFile).get();
+
+    Uri blobUri = DirectoryUtil.getBlobUri(context, file.getAndroidSharingChecksum());
+    Uri leaseUri =
+        DirectoryUtil.getBlobStoreLeaseUri(
+            context, file.getAndroidSharingChecksum(), FILE_GROUP_EXPIRATION_DATE_SECS);
+
+    fileGroupManager.tryToShareAfterDownload(fileGroup, file, newFileKey).get();
+
+    // openForWrite is called only once for acquiring the lease.
+    verify(mockBackend, never()).exists(any());
+    verify(mockBackend).openForWrite(leaseUri);
+
+    SharedFile sharedFile = sharedFileManager.getSharedFile(newFileKey).get();
+    // Verify that the SharedFile has updated its expiration date after the download.
+    assertThat(sharedFile.getMaxExpirationDateSecs()).isEqualTo(FILE_GROUP_EXPIRATION_DATE_SECS);
+    assertThat(sharedFile.getAndroidShared()).isTrue();
+    assertThat(sharedFileManager.getOnDeviceUri(newFileKey).get()).isEqualTo(blobUri);
+  }
+
+  @Test
+  public void tryToShareAfterDownload_toBeSharedFile_blobExists() throws Exception {
+    // Create a file group with expiration date bigger than the expiration date of the existing
+    // SharedFile.
+    DataFileGroupInternal fileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder()
+            .setExpirationDateSecs(FILE_GROUP_EXPIRATION_DATE_SECS)
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .build();
+
+    DataFile file = MddTestUtil.createSharedDataFile("fileId", 0);
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+    SharedFile existingSharedFile =
+        SharedFile.newBuilder()
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .setFileName("fileName")
+            .setAndroidShared(false)
+            .build();
+    sharedFilesMetadata.write(newFileKey, existingSharedFile).get();
+
+    Uri blobUri = DirectoryUtil.getBlobUri(context, file.getAndroidSharingChecksum());
+    Uri leaseUri =
+        DirectoryUtil.getBlobStoreLeaseUri(
+            context, file.getAndroidSharingChecksum(), FILE_GROUP_EXPIRATION_DATE_SECS);
+    // The file is available in the blob storage
+    when(mockBackend.exists(blobUri)).thenReturn(true);
+
+    simulateDownload(file, existingSharedFile.getFileName());
+    Uri onDeviceuri =
+        DirectoryUtil.getOnDeviceUri(
+            context,
+            newFileKey.getAllowedReaders(),
+            existingSharedFile.getFileName(),
+            newFileKey.getChecksum(),
+            mockSilentFeedback,
+            /* instanceId= */ Optional.absent(),
+            false);
+    assertThat(fileStorage.exists(onDeviceuri)).isTrue();
+
+    fileGroupManager.tryToShareAfterDownload(fileGroup, file, newFileKey).get();
+
+    verify(mockBackend).exists(blobUri);
+    verify(mockBackend).openForWrite(leaseUri);
+
+    SharedFile sharedFile = sharedFileManager.getSharedFile(newFileKey).get();
+    // Verify that the SharedFile has updated its expiration date after the download.
+    assertThat(sharedFile.getMaxExpirationDateSecs()).isEqualTo(FILE_GROUP_EXPIRATION_DATE_SECS);
+    assertThat(sharedFile.getAndroidShared()).isTrue();
+    assertThat(sharedFileManager.getOnDeviceUri(newFileKey).get()).isEqualTo(blobUri);
+
+    // Local copy has been deleted.
+    assertThat(fileStorage.exists(onDeviceuri)).isFalse();
+  }
+
+  @Test
+  public void tryToShareAfterDownload_toBeSharedFile_canBeCopied() throws Exception {
+    // Create a file group with expiration date bigger than the expiration date of the existing
+    // SharedFile.
+    File tempFile = folder.newFile("blobFile");
+    DataFileGroupInternal fileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder()
+            .setExpirationDateSecs(FILE_GROUP_EXPIRATION_DATE_SECS)
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .build();
+
+    DataFile file = MddTestUtil.createSharedDataFile("fileId", 0);
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+    SharedFile existingSharedFile =
+        SharedFile.newBuilder()
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .setFileName("fileName")
+            .setAndroidShared(false)
+            .build();
+    sharedFilesMetadata.write(newFileKey, existingSharedFile).get();
+
+    Uri blobUri = DirectoryUtil.getBlobUri(context, file.getAndroidSharingChecksum());
+    Uri leaseUri =
+        DirectoryUtil.getBlobStoreLeaseUri(
+            context, file.getAndroidSharingChecksum(), FILE_GROUP_EXPIRATION_DATE_SECS);
+    // The file isn't available yet in the blob storage
+    when(mockBackend.exists(blobUri)).thenReturn(false);
+    when(mockBackend.openForWrite(blobUri)).thenReturn(new FileOutputStream(tempFile));
+
+    simulateDownload(file, existingSharedFile.getFileName());
+    Uri onDeviceuri =
+        DirectoryUtil.getOnDeviceUri(
+            context,
+            newFileKey.getAllowedReaders(),
+            existingSharedFile.getFileName(),
+            newFileKey.getChecksum(),
+            mockSilentFeedback,
+            /* instanceId= */ Optional.absent(),
+            false);
+    assertThat(fileStorage.exists(onDeviceuri)).isTrue();
+
+    fileGroupManager.tryToShareAfterDownload(fileGroup, file, newFileKey).get();
+
+    // openForWrite is called once for writing the blob, once for acquiring the lease.
+    verify(mockBackend).openForWrite(blobUri);
+    verify(mockBackend).openForWrite(leaseUri);
+
+    SharedFile sharedFile = sharedFileManager.getSharedFile(newFileKey).get();
+    // Verify that the SharedFile has updated its expiration date after the download.
+    assertThat(sharedFile.getMaxExpirationDateSecs()).isEqualTo(FILE_GROUP_EXPIRATION_DATE_SECS);
+    assertThat(sharedFile.getAndroidShared()).isTrue();
+    assertThat(sharedFileManager.getOnDeviceUri(newFileKey).get()).isEqualTo(blobUri);
+
+    // Local copy has been deleted.
+    assertThat(fileStorage.exists(onDeviceuri)).isFalse();
+  }
+
+  @Test
+  public void tryToShareAfterDownload_nonToBeSharedFile_neverShared() throws Exception {
+    // Create a file group with expiration date bigger than the expiration date of the existing
+    // SharedFile.
+    DataFileGroupInternal fileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder()
+            .setExpirationDateSecs(FILE_GROUP_EXPIRATION_DATE_SECS)
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .build();
+
+    DataFile file = MddTestUtil.createDataFile("fileId", 0);
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+    SharedFile existingSharedFile =
+        SharedFile.newBuilder()
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .setFileName("fileName")
+            .setAndroidShared(false)
+            .build();
+    sharedFilesMetadata.write(newFileKey, existingSharedFile).get();
+
+    File onDeviceFile = simulateDownload(file, existingSharedFile.getFileName());
+    Uri onDeviceuri =
+        DirectoryUtil.getOnDeviceUri(
+            context,
+            newFileKey.getAllowedReaders(),
+            existingSharedFile.getFileName(),
+            newFileKey.getChecksum(),
+            mockSilentFeedback,
+            /* instanceId= */ Optional.absent(),
+            false);
+    assertThat(fileStorage.exists(onDeviceuri)).isTrue();
+
+    fileGroupManager.tryToShareAfterDownload(fileGroup, file, newFileKey).get();
+
+    verify(mockBackend, never()).exists(any());
+    verify(mockBackend, never()).openForWrite(any());
+
+    SharedFile sharedFile = sharedFileManager.getSharedFile(newFileKey).get();
+    // Verify that the SharedFile has updated its expiration date after the download.
+    SharedFile expectedSharedFile =
+        existingSharedFile.toBuilder()
+            .setMaxExpirationDateSecs(FILE_GROUP_EXPIRATION_DATE_SECS)
+            .build();
+    assertThat(sharedFile).isEqualTo(expectedSharedFile);
+
+    // Local copy still available.
+    assertThat(fileStorage.exists(onDeviceuri)).isTrue();
+    onDeviceFile.delete();
+  }
+
+  @Test
+  public void tryToShareAfterDownload_toBeSharedFile_neverShared() throws Exception {
+    // Create a file group with expiration date bigger than the expiration date of the existing
+    // SharedFile.
+    DataFileGroupInternal fileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder()
+            .setExpirationDateSecs(FILE_GROUP_EXPIRATION_DATE_SECS)
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .build();
+
+    DataFile file = MddTestUtil.createSharedDataFile("fileId", 0);
+    // This should never happened in a real scenario.
+    file = file.toBuilder().setAndroidSharingChecksum("").build();
+
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+    SharedFile existingSharedFile =
+        SharedFile.newBuilder()
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .setFileName("fileName")
+            .setAndroidShared(false)
+            .build();
+    sharedFilesMetadata.write(newFileKey, existingSharedFile).get();
+
+    File onDeviceFile = simulateDownload(file, existingSharedFile.getFileName());
+    Uri onDeviceuri =
+        DirectoryUtil.getOnDeviceUri(
+            context,
+            newFileKey.getAllowedReaders(),
+            existingSharedFile.getFileName(),
+            newFileKey.getChecksum(),
+            mockSilentFeedback,
+            /* instanceId= */ Optional.absent(),
+            false);
+    assertThat(fileStorage.exists(onDeviceuri)).isTrue();
+
+    fileGroupManager.tryToShareAfterDownload(fileGroup, file, newFileKey).get();
+
+    verify(mockBackend, never()).exists(any());
+    verify(mockBackend, never()).openForWrite(any());
+
+    SharedFile sharedFile = sharedFileManager.getSharedFile(newFileKey).get();
+    // Verify that the SharedFile has updated its expiration date after the download.
+    SharedFile expectedSharedFile =
+        existingSharedFile.toBuilder()
+            .setMaxExpirationDateSecs(FILE_GROUP_EXPIRATION_DATE_SECS)
+            .build();
+    assertThat(sharedFile).isEqualTo(expectedSharedFile);
+
+    // Local copy still available.
+    assertThat(fileStorage.exists(onDeviceuri)).isTrue();
+    onDeviceFile.delete();
+  }
+
+  @Test
+  public void tryToShareAfterDownload_blobUriNotSupported() throws Exception {
+    // FileStorage without BlobStoreBackend
+    fileStorage =
+        new SynchronousFileStorage(Arrays.asList(AndroidFileBackend.builder(context).build()));
+    fileGroupManager =
+        new FileGroupManager(
+            context,
+            mockLogger,
+            mockSilentFeedback,
+            fileGroupsMetadata,
+            sharedFileManager,
+            testClock,
+            Optional.of(mockAccountSource),
+            SEQUENTIAL_CONTROL_EXECUTOR,
+            Optional.absent(),
+            fileStorage,
+            downloadStageManager,
+            flags);
+
+    DataFileGroupInternal fileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 0).toBuilder()
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .build();
+
+    // Create a to-be-shared file
+    DataFile file = MddTestUtil.createSharedDataFile("fileId", 0);
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+    // Set the file metadata as download completed and non shared
+    SharedFile existingSharedFile =
+        SharedFile.newBuilder()
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .setFileName("fileName")
+            .setAndroidShared(false)
+            .setMaxExpirationDateSecs(FILE_GROUP_EXPIRATION_DATE_SECS)
+            .build();
+    sharedFilesMetadata.write(newFileKey, existingSharedFile).get();
+
+    fileGroupManager.tryToShareAfterDownload(fileGroup, file, newFileKey).get();
+
+    verify(mockBackend, never()).openForWrite(any());
+
+    SharedFile sharedFile = sharedFileManager.getSharedFile(newFileKey).get();
+    assertThat(sharedFile).isEqualTo(existingSharedFile);
+  }
+
+  @Test
+  public void tryToShareAfterDownload_nonExistentFile() throws Exception {
+    DataFileGroupInternal fileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder()
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .build();
+
+    DataFile file = MddTestUtil.createSharedDataFile("fileId", 0);
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+
+    ListenableFuture<Void> tryToShareFuture =
+        fileGroupManager.tryToShareAfterDownload(fileGroup, file, newFileKey);
+
+    ExecutionException exception = assertThrows(ExecutionException.class, tryToShareFuture::get);
+    assertThat(exception).hasCauseThat().isInstanceOf(SharedFileMissingException.class);
+
+    verify(mockBackend, never()).exists(any());
+    verify(mockBackend, never()).openForWrite(any());
+    verify(mockSharedFileManager, never())
+        .setAndroidSharedDownloadedFileEntry(any(), any(), anyLong());
+    verify(mockSharedFileManager, never()).updateMaxExpirationDateSecs(newFileKey, 0);
+  }
+
+  @Test
+  public void tryToShareAfterDownload_updateMaxExpirationDateSecsReturnsFalse() throws Exception {
+    // Mock SharedFileManager to test failure scenario.
+    resetFileGroupManager(fileGroupsMetadata, mockSharedFileManager);
+    // Create a file group with expiration date bigger than the expiration date of the existing
+    // SharedFile.
+    DataFileGroupInternal fileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder()
+            .setExpirationDateSecs(FILE_GROUP_EXPIRATION_DATE_SECS)
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .build();
+
+    DataFile file = MddTestUtil.createDataFile("fileId", 0);
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+    SharedFile existingSharedFile =
+        SharedFile.newBuilder()
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .setFileName("fileName")
+            .setAndroidShared(false)
+            .build();
+
+    when(mockSharedFileManager.getSharedFile(newFileKey))
+        .thenReturn(Futures.immediateFuture(existingSharedFile));
+    when(mockSharedFileManager.updateMaxExpirationDateSecs(
+            newFileKey, FILE_GROUP_EXPIRATION_DATE_SECS))
+        .thenReturn(Futures.immediateFuture(false));
+
+    File onDeviceFile = simulateDownload(file, existingSharedFile.getFileName());
+    Uri onDeviceuri =
+        DirectoryUtil.getOnDeviceUri(
+            context,
+            newFileKey.getAllowedReaders(),
+            existingSharedFile.getFileName(),
+            newFileKey.getChecksum(),
+            mockSilentFeedback,
+            /* instanceId= */ Optional.absent(),
+            false);
+    assertThat(fileStorage.exists(onDeviceuri)).isTrue();
+
+    fileGroupManager.tryToShareAfterDownload(fileGroup, file, newFileKey).get();
+
+    verify(mockBackend, never()).exists(any());
+    verify(mockBackend, never()).openForWrite(any());
+    verify(mockSharedFileManager, never())
+        .setAndroidSharedDownloadedFileEntry(any(), any(), anyLong());
+    verify(mockSharedFileManager)
+        .updateMaxExpirationDateSecs(newFileKey, FILE_GROUP_EXPIRATION_DATE_SECS);
+    assertThat(fileStorage.exists(onDeviceuri)).isTrue();
+    onDeviceFile.delete();
+  }
+
+  @Test
+  public void tryToShareAfterDownload_setAndroidSharedDownloadedFileEntryReturnsFalse()
+      throws Exception {
+    // Mock SharedFileManager to test failure scenario.
+    resetFileGroupManager(fileGroupsMetadata, mockSharedFileManager);
+    DataFileGroupInternal fileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2).toBuilder()
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .build();
+
+    // Create a to-be-shared file
+    DataFile file = MddTestUtil.createSharedDataFile("fileId", 0);
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+
+    SharedFile existingSharedFile =
+        SharedFile.newBuilder()
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .setFileName("fileName")
+            .setAndroidShared(false)
+            .setMaxExpirationDateSecs(FILE_GROUP_EXPIRATION_DATE_SECS)
+            .build();
+
+    when(mockSharedFileManager.getSharedFile(newFileKey))
+        .thenReturn(Futures.immediateFuture(existingSharedFile));
+    Uri blobUri = DirectoryUtil.getBlobUri(context, file.getAndroidSharingChecksum());
+    Uri leaseUri =
+        DirectoryUtil.getBlobStoreLeaseUri(
+            context, file.getAndroidSharingChecksum(), FILE_GROUP_EXPIRATION_DATE_SECS);
+    // The file is available in the blob storage
+    when(mockBackend.exists(blobUri)).thenReturn(true);
+
+    // Last operation fails
+    when(mockSharedFileManager.setAndroidSharedDownloadedFileEntry(
+            newFileKey, file.getAndroidSharingChecksum(), FILE_GROUP_EXPIRATION_DATE_SECS))
+        .thenReturn(Futures.immediateFuture(false));
+    when(mockSharedFileManager.updateMaxExpirationDateSecs(newFileKey, 0))
+        .thenReturn(Futures.immediateFuture(true));
+
+    File onDeviceFile = simulateDownload(file, existingSharedFile.getFileName());
+    Uri onDeviceuri =
+        DirectoryUtil.getOnDeviceUri(
+            context,
+            newFileKey.getAllowedReaders(),
+            existingSharedFile.getFileName(),
+            newFileKey.getChecksum(),
+            mockSilentFeedback,
+            /* instanceId= */ Optional.absent(),
+            false);
+    assertThat(fileStorage.exists(onDeviceuri)).isTrue();
+
+    fileGroupManager.tryToShareAfterDownload(fileGroup, file, newFileKey).get();
+
+    verify(mockBackend).exists(blobUri);
+    // openForWrite is called only once for acquiring the lease.
+    verify(mockBackend, never()).openForWrite(blobUri);
+    verify(mockBackend).openForWrite(leaseUri);
+    verify(mockSharedFileManager).updateMaxExpirationDateSecs(newFileKey, 0);
+    assertThat(fileStorage.exists(onDeviceuri)).isTrue();
+    onDeviceFile.delete();
+  }
+
+  @Test
+  public void tryToShareAfterDownload_copyBlobThrowsIOException() throws Exception {
+    DataFileGroupInternal fileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2).toBuilder()
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .build();
+
+    // Create a to-be-shared file
+    DataFile file = MddTestUtil.createSharedDataFile("fileId", 0);
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+
+    SharedFile existingSharedFile =
+        SharedFile.newBuilder()
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .setFileName("fileName")
+            .setAndroidShared(false)
+            .setMaxExpirationDateSecs(FILE_GROUP_EXPIRATION_DATE_SECS)
+            .build();
+    sharedFilesMetadata.write(newFileKey, existingSharedFile).get();
+
+    Uri blobUri = DirectoryUtil.getBlobUri(context, file.getAndroidSharingChecksum());
+    Uri leaseUri =
+        DirectoryUtil.getBlobStoreLeaseUri(
+            context, file.getAndroidSharingChecksum(), FILE_GROUP_EXPIRATION_DATE_SECS);
+    // The file isn't available in the blob storage
+    when(mockBackend.exists(blobUri)).thenReturn(false);
+    // Copying the blob throws an exception
+    when(mockBackend.openForWrite(blobUri)).thenThrow(new IOException());
+
+    File onDeviceFile = simulateDownload(file, existingSharedFile.getFileName());
+    Uri onDeviceuri =
+        DirectoryUtil.getOnDeviceUri(
+            context,
+            newFileKey.getAllowedReaders(),
+            existingSharedFile.getFileName(),
+            newFileKey.getChecksum(),
+            mockSilentFeedback,
+            /* instanceId= */ Optional.absent(),
+            false);
+    assertThat(fileStorage.exists(onDeviceuri)).isTrue();
+
+    fileGroupManager.tryToShareAfterDownload(fileGroup, file, newFileKey).get();
+
+    verify(mockBackend, never()).openForWrite(leaseUri);
+
+    SharedFile sharedFile = sharedFileManager.getSharedFile(newFileKey).get();
+    assertThat(sharedFile).isEqualTo(existingSharedFile);
+
+    // Local copy still available.
+    assertThat(fileStorage.exists(onDeviceuri)).isTrue();
+    onDeviceFile.delete();
+  }
+
+  @Test
+  public void tryToShareAfterDownload_fileStorageThrowsLimitExceededException() throws Exception {
+    // Create a file group with expiration date bigger than the expiration date of the existing
+    // SharedFile.
+    DataFileGroupInternal fileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2).toBuilder()
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .setExpirationDateSecs(FILE_GROUP_EXPIRATION_DATE_SECS + 1)
+            .build();
+
+    // Create a to-be-shared file
+    DataFile file = MddTestUtil.createSharedDataFile("fileId", 0);
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+
+    SharedFile existingSharedFile =
+        SharedFile.newBuilder()
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .setFileName("fileName")
+            .setAndroidShared(false)
+            .setMaxExpirationDateSecs(FILE_GROUP_EXPIRATION_DATE_SECS)
+            .build();
+    sharedFilesMetadata.write(newFileKey, existingSharedFile).get();
+
+    Uri blobUri = DirectoryUtil.getBlobUri(context, file.getAndroidSharingChecksum());
+    Uri leaseUri =
+        DirectoryUtil.getBlobStoreLeaseUri(
+            context, file.getAndroidSharingChecksum(), FILE_GROUP_EXPIRATION_DATE_SECS + 1);
+    // The file is available in the blob storage
+    when(mockBackend.exists(blobUri)).thenReturn(true);
+
+    File onDeviceFile = simulateDownload(file, existingSharedFile.getFileName());
+    Uri onDeviceuri =
+        DirectoryUtil.getOnDeviceUri(
+            context,
+            newFileKey.getAllowedReaders(),
+            existingSharedFile.getFileName(),
+            newFileKey.getChecksum(),
+            mockSilentFeedback,
+            /* instanceId= */ Optional.absent(),
+            false);
+    assertThat(fileStorage.exists(onDeviceuri)).isTrue();
+
+    // Writing the lease throws an exception
+    when(mockBackend.openForWrite(leaseUri)).thenThrow(new LimitExceededException());
+
+    fileGroupManager.tryToShareAfterDownload(fileGroup, file, newFileKey).get();
+
+    verify(mockBackend, never()).openForWrite(blobUri);
+    verify(mockBackend).openForWrite(leaseUri);
+
+    SharedFile sharedFile = sharedFileManager.getSharedFile(newFileKey).get();
+    // Even if there was an exception, the SharedFile has updated its expiration date after the
+    // download.
+    SharedFile expectedSharedFile =
+        existingSharedFile.toBuilder()
+            .setMaxExpirationDateSecs(FILE_GROUP_EXPIRATION_DATE_SECS + 1)
+            .build();
+    assertThat(sharedFile).isEqualTo(expectedSharedFile);
+
+    // Local copy still available.
+    assertThat(fileStorage.exists(onDeviceuri)).isTrue();
+    onDeviceFile.delete();
+  }
+
+  @Test
+  public void tryToShareAfterDownload_blobExists_deleteLocalCopyFails() throws Exception {
+    // Create a file group with expiration date bigger than the expiration date of the existing
+    // SharedFile.
+    DataFileGroupInternal fileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder()
+            .setExpirationDateSecs(FILE_GROUP_EXPIRATION_DATE_SECS)
+            .setDownloadConditions(DownloadConditions.getDefaultInstance())
+            .build();
+
+    DataFile file = MddTestUtil.createSharedDataFile("fileId", 0);
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+    SharedFile existingSharedFile =
+        SharedFile.newBuilder()
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .setFileName("fileName")
+            .setAndroidShared(false)
+            .build();
+    sharedFilesMetadata.write(newFileKey, existingSharedFile).get();
+
+    Uri blobUri = DirectoryUtil.getBlobUri(context, file.getAndroidSharingChecksum());
+    Uri leaseUri =
+        DirectoryUtil.getBlobStoreLeaseUri(
+            context, file.getAndroidSharingChecksum(), FILE_GROUP_EXPIRATION_DATE_SECS);
+    // The file is available in the blob storage
+    when(mockBackend.exists(blobUri)).thenReturn(true);
+
+    fileGroupManager.tryToShareAfterDownload(fileGroup, file, newFileKey).get();
+
+    // openForWrite is called only once for acquiring the lease.
+    verify(mockBackend).exists(blobUri);
+    verify(mockBackend).openForWrite(leaseUri);
+
+    SharedFile sharedFile = sharedFileManager.getSharedFile(newFileKey).get();
+    // Verify that the SharedFile has updated its expiration date after the download.
+    assertThat(sharedFile.getMaxExpirationDateSecs()).isEqualTo(FILE_GROUP_EXPIRATION_DATE_SECS);
+    assertThat(sharedFile.getAndroidShared()).isTrue();
+    assertThat(sharedFileManager.getOnDeviceUri(newFileKey).get()).isEqualTo(blobUri);
+  }
+
+  @Test
+  public void testVerifyPendingGroupDownloaded() throws Exception {
+    // Write 2 groups to the pending shared prefs.
+    DataFileGroupInternal fileGroup1 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+    writePendingFileGroup(testKey, fileGroup1);
+    DataFileGroupInternal fileGroup2 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP_2, 2);
+    writePendingFileGroup(testKey2, fileGroup2);
+
+    // Make the verify download call fail for one file in the first group.
+    writeSharedFiles(
+        sharedFilesMetadata,
+        fileGroup1,
+        ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE, FileStatus.DOWNLOAD_IN_PROGRESS));
+    writeSharedFiles(
+        sharedFilesMetadata,
+        fileGroup2,
+        ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE, FileStatus.DOWNLOAD_COMPLETE));
+
+    testClock.set(/* millis */ 1000);
+
+    assertThat(
+            fileGroupManager
+                .verifyPendingGroupDownloaded(testKey, fileGroup1, noCustomValidation())
+                .get())
+        .isEqualTo(GroupDownloadStatus.PENDING);
+    assertThat(
+            fileGroupManager
+                .verifyPendingGroupDownloaded(testKey2, fileGroup2, noCustomValidation())
+                .get())
+        .isEqualTo(GroupDownloadStatus.DOWNLOADED);
+
+    // Verify that the pending group is still part of pending groups prefs.
+    DataFileGroupInternal pendingGroup1 = readPendingFileGroup(testKey);
+    assertThat(pendingGroup1).isEqualTo(fileGroup1);
+
+    // Verify that the pending group is not written into metadata.
+    assertThat(readDownloadedFileGroup(testKey)).isNull();
+
+    fileGroup2 = FileGroupUtil.setDownloadedTimestampInMillis(fileGroup2, 1000);
+
+    // Verify that the completely downloaded group is written into metadata.
+    DataFileGroupInternal downloadedGroup2 = readDownloadedFileGroup(testKey2);
+    assertThat(downloadedGroup2).isEqualTo(fileGroup2);
+  }
+
+  @Test
+  public void testVerifyAllPendingGroupsDownloaded() throws Exception {
+    // Write 2 groups to the pending shared prefs.
+    DataFileGroupInternal fileGroup1 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+    writePendingFileGroup(testKey, fileGroup1);
+    DataFileGroupInternal fileGroup2 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP_2, 2);
+    writePendingFileGroup(testKey2, fileGroup2);
+
+    // Make the verify download call fail for one file in the first group.
+    writeSharedFiles(
+        sharedFilesMetadata,
+        fileGroup1,
+        ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE, FileStatus.DOWNLOAD_IN_PROGRESS));
+    writeSharedFiles(
+        sharedFilesMetadata,
+        fileGroup2,
+        ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE, FileStatus.DOWNLOAD_COMPLETE));
+
+    testClock.set(/* millis */ 1000);
+    fileGroupManager.verifyAllPendingGroupsDownloaded(noCustomValidation()).get();
+
+    // Verify that the pending group is still part of pending groups prefs.
+    DataFileGroupInternal pendingGroup1 = readPendingFileGroup(testKey);
+    MddTestUtil.assertMessageEquals(fileGroup1, pendingGroup1);
+
+    // Verify that the pending group is not written into metadata.
+    assertThat(readDownloadedFileGroup(testKey)).isNull();
+
+    fileGroup2 = FileGroupUtil.setDownloadedTimestampInMillis(fileGroup2, 1000);
+
+    // Verify that the completely downloaded group is written into metadata.
+    DataFileGroupInternal downloadedGroup2 = readDownloadedFileGroup(testKey2);
+    assertThat(downloadedGroup2).isEqualTo(fileGroup2);
+  }
+
+  @Test
+  public void testVerifyAllPendingGroupsDownloaded_existingDownloadedGroup() throws Exception {
+    // Write 2 groups to the pending shared prefs.
+    DataFileGroupInternal fileGroup1 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+    writePendingFileGroup(testKey, fileGroup1);
+    DataFileGroupInternal fileGroup2 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP_2, 2);
+    writePendingFileGroup(testKey2, fileGroup2);
+
+    // Also write 2 groups to the downloaded shared prefs.
+    // fileGroup3 is the downloaded version if fileGroup1.
+    DataFileGroupInternal fileGroup3 =
+        MddTestUtil.createDownloadedDataFileGroupInternal(TEST_GROUP, 1);
+    writeDownloadedFileGroup(testKey, fileGroup3);
+    DataFileGroupInternal fileGroup4 =
+        MddTestUtil.createDownloadedDataFileGroupInternal(TEST_GROUP_3, 2);
+    writeDownloadedFileGroup(testKey3, fileGroup4);
+
+    // All file are downloaded.
+    writeSharedFiles(
+        sharedFilesMetadata,
+        fileGroup1,
+        ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE, FileStatus.DOWNLOAD_COMPLETE));
+    writeSharedFiles(
+        sharedFilesMetadata,
+        fileGroup2,
+        ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE, FileStatus.DOWNLOAD_COMPLETE));
+    writeSharedFiles(
+        sharedFilesMetadata, fileGroup3, ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE));
+    writeSharedFiles(
+        sharedFilesMetadata,
+        fileGroup4,
+        ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE, FileStatus.DOWNLOAD_COMPLETE));
+
+    testClock.set(/* millis */ 1000);
+    fileGroupManager.verifyAllPendingGroupsDownloaded(noCustomValidation()).get();
+
+    // Verify that pending key is removed if the group is downloaded.
+    assertThat(readPendingFileGroup(testKey)).isNull();
+    assertThat(readPendingFileGroup(testKey2)).isNull();
+    assertThat(readPendingFileGroup(testKey3)).isNull();
+
+    fileGroup1 = FileGroupUtil.setDownloadedTimestampInMillis(fileGroup1, 1000);
+    fileGroup2 = FileGroupUtil.setDownloadedTimestampInMillis(fileGroup2, 1000);
+
+    // Verify that pending group is marked as downloaded group.
+    DataFileGroupInternal downloadedGroup1 = readDownloadedFileGroup(testKey);
+    assertThat(downloadedGroup1).isEqualTo(fileGroup1);
+    DataFileGroupInternal downloadedGroup2 = readDownloadedFileGroup(testKey2);
+    assertThat(downloadedGroup2).isEqualTo(fileGroup2);
+    DataFileGroupInternal downloadedGroup4 = readDownloadedFileGroup(testKey3);
+    assertThat(downloadedGroup4).isEqualTo(fileGroup4);
+
+    // fileGroup3 should have been scheduled for deletion.
+    fileGroup3 =
+        fileGroup3.toBuilder()
+            .setBookkeeping(DataFileGroupBookkeeping.newBuilder().setStaleExpirationDate(1).build())
+            .build();
+    assertThat(fileGroupsMetadata.getAllStaleGroups().get()).containsExactly(fileGroup3);
+  }
+
+  @Test
+  public void testGroupDownloadFailed() throws Exception {
+    // Write 2 groups to the pending shared prefs.
+    DataFileGroupInternal fileGroup1 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+    writePendingFileGroup(testKey, fileGroup1);
+    DataFileGroupInternal fileGroup2 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP_2, 2);
+    writePendingFileGroup(testKey2, fileGroup2);
+
+    // Make the second file of the first group fail.
+    writeSharedFiles(
+        sharedFilesMetadata,
+        fileGroup1,
+        ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE, FileStatus.DOWNLOAD_FAILED));
+    writeSharedFiles(
+        sharedFilesMetadata,
+        fileGroup2,
+        ImmutableList.of(FileStatus.DOWNLOAD_COMPLETE, FileStatus.DOWNLOAD_COMPLETE));
+
+    fileGroupManager.verifyAllPendingGroupsDownloaded(noCustomValidation()).get();
+
+    // Verify that pending key is removed if download is complete.
+    assertThat(readPendingFileGroup(testKey)).isEqualTo(fileGroup1);
+    assertThat(readPendingFileGroup(testKey2)).isNull();
+
+    // Verify that downloaded key is written into metadata if download is complete.
+    fileGroup2 = FileGroupUtil.setDownloadedTimestampInMillis(fileGroup2, 1000);
+    DataFileGroupInternal downloadedGroup2 = readDownloadedFileGroup(testKey2);
+    assertThat(downloadedGroup2).isEqualTo(fileGroup2);
+  }
+
+  @Test
+  public void testDeleteUninstalledAppGroups_noUninstalledApps() throws Exception {
+    PackageManager packageManager = context.getPackageManager();
+    final PackageInfo packageInfo = new PackageInfo();
+    packageInfo.packageName = context.getPackageName();
+    packageInfo.lastUpdateTime = System.currentTimeMillis();
+    Shadows.shadowOf(packageManager).addPackage(packageInfo);
+
+    DataFileGroupInternal fileGroup1 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+    writePendingFileGroup(testKey, fileGroup1);
+
+    DataFileGroupInternal fileGroup2 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP_2, 2);
+    writePendingFileGroup(testKey2, fileGroup2);
+
+    fileGroupManager.deleteUninstalledAppGroups().get();
+
+    assertThat(readPendingFileGroup(testKey)).isEqualTo(fileGroup1);
+    assertThat(readPendingFileGroup(testKey2)).isEqualTo(fileGroup2);
+  }
+
+  @Test
+  public void testDeleteUninstalledAppGroups_uninstalledApp() throws Exception {
+    PackageManager packageManager = context.getPackageManager();
+    final PackageInfo packageInfo = new PackageInfo();
+    packageInfo.packageName = context.getPackageName();
+    packageInfo.lastUpdateTime = System.currentTimeMillis();
+    Shadows.shadowOf(packageManager).addPackage(packageInfo);
+
+    DataFileGroupInternal fileGroup1 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+    writePendingFileGroup(testKey, fileGroup1);
+    DataFileGroupInternal fileGroup2 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP_2, 2);
+    GroupKey uninstalledAppKey =
+        GroupKey.newBuilder().setGroupName(TEST_GROUP_2).setOwnerPackage("uninstalled.app").build();
+    writeDownloadedFileGroup(uninstalledAppKey, fileGroup2);
+
+    assertThat(readPendingFileGroup(testKey)).isEqualTo(fileGroup1);
+    assertThat(readDownloadedFileGroup(uninstalledAppKey)).isEqualTo(fileGroup2);
+
+    fileGroupManager.deleteUninstalledAppGroups().get();
+
+    assertThat(readPendingFileGroup(testKey)).isEqualTo(fileGroup1);
+    assertThat(readDownloadedFileGroup(uninstalledAppKey)).isNull();
+  }
+
+  @Test
+  public void testDeleteRemovedAccountGroups_noRemovedAccounts() throws Exception {
+    Account account1 = new Account("name1", "type1");
+    Account account2 = new Account("name2", "type2");
+
+    when(mockAccountSource.getAllAccounts()).thenReturn(ImmutableList.of(account1, account2));
+
+    DataFileGroupInternal fileGroup1 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+    GroupKey key1 =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setOwnerPackage(context.getPackageName())
+            .setAccount(AccountUtil.serialize(account1))
+            .build();
+
+    DataFileGroupInternal fileGroup2 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP_2, 2);
+    GroupKey key2 =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP_2)
+            .setOwnerPackage(context.getPackageName())
+            .setAccount(AccountUtil.serialize(account2))
+            .build();
+
+    DataFileGroupInternal fileGroup3 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP_3, 2);
+    GroupKey key3 =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP_3)
+            .setOwnerPackage(context.getPackageName())
+            .build();
+
+    writeDownloadedFileGroup(key1, fileGroup1);
+    writeDownloadedFileGroup(key2, fileGroup2);
+    writeDownloadedFileGroup(key3, fileGroup3);
+
+    fileGroupManager.deleteRemovedAccountGroups().get();
+
+    assertThat(fileGroupsMetadata.getAllGroupKeys().get())
+        .containsExactly(getDownloadedKey(key1), getDownloadedKey(key2), getDownloadedKey(key3));
+  }
+
+  @Test
+  public void testDeleteRemovedAccountGroups_removedAccounts() throws Exception {
+    Account account1 = new Account("name1", "type1");
+    Account account2 = new Account("name2", "type2");
+
+    when(mockAccountSource.getAllAccounts()).thenReturn(ImmutableList.of(account1));
+
+    DataFileGroupInternal fileGroup1 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+    GroupKey key1 =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setOwnerPackage(context.getPackageName())
+            .setAccount(AccountUtil.serialize(account1))
+            .build();
+
+    DataFileGroupInternal fileGroup2 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP_2, 2);
+    GroupKey key2 =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP_2)
+            .setOwnerPackage(context.getPackageName())
+            .setAccount(AccountUtil.serialize(account2))
+            .build();
+
+    DataFileGroupInternal fileGroup3 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP_3, 2);
+    GroupKey key3 =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP_3)
+            .setOwnerPackage(context.getPackageName())
+            .build();
+
+    writeDownloadedFileGroup(key1, fileGroup1);
+    writeDownloadedFileGroup(key2, fileGroup2);
+    writeDownloadedFileGroup(key3, fileGroup3);
+
+    fileGroupManager.deleteRemovedAccountGroups().get();
+
+    assertThat(fileGroupsMetadata.getAllGroupKeys().get())
+        .containsExactly(getDownloadedKey(key1), getDownloadedKey(key3));
+  }
+
+  @Test
+  public void testLogAndDeleteForMissingSharedFiles() throws Exception {
+    resetFileGroupManager(fileGroupsMetadata, mockSharedFileManager);
+
+    GroupKey downloadedGroupKeyWithFileMissing =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setOwnerPackage(context.getPackageName())
+            .setDownloaded(true)
+            .build();
+    DataFileGroupInternal fileGroup1 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+    writeDownloadedFileGroup(downloadedGroupKeyWithFileMissing, fileGroup1);
+    NewFileKey[] keys = MddTestUtil.createFileKeysForDataFileGroupInternal(fileGroup1);
+    when(mockSharedFileManager.reVerifyFile(eq(keys[0]), eq(fileGroup1.getFile(0))))
+        .thenReturn(Futures.immediateFailedFuture(new SharedFileMissingException()));
+    when(mockSharedFileManager.reVerifyFile(eq(keys[1]), eq(fileGroup1.getFile(1))))
+        .thenReturn(Futures.immediateFailedFuture(new SharedFileMissingException()));
+
+    GroupKey pendingGroupKeyWithFileMissing =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP_2)
+            .setOwnerPackage(context.getPackageName())
+            .setDownloaded(false)
+            .build();
+    DataFileGroupInternal fileGroup2 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP_2, 2);
+    writePendingFileGroup(pendingGroupKeyWithFileMissing, fileGroup2);
+    // Write only the first file metadata.
+    NewFileKey[] keys2 = MddTestUtil.createFileKeysForDataFileGroupInternal(fileGroup2);
+    when(mockSharedFileManager.reVerifyFile(eq(keys2[0]), eq(fileGroup2.getFile(0))))
+        .thenReturn(Futures.immediateFailedFuture(new SharedFileMissingException()));
+    // mockSharedFileManager returns "OK" when verifying second file.
+
+    GroupKey groupKeyWithNoFileMissing =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP_3)
+            .setOwnerPackage(context.getPackageName())
+            .setDownloaded(true)
+            .build();
+    DataFileGroupInternal fileGroup3 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP_3, 2);
+    writeDownloadedFileGroup(groupKeyWithNoFileMissing, fileGroup3);
+    // mockSharedFileManager always returns "OK" when verifying files.
+
+    fileGroupManager.logAndDeleteForMissingSharedFiles().get();
+
+    if (flags.deleteFileGroupsWithFilesMissing()) {
+      assertThat(fileGroupsMetadata.getAllGroupKeys().get())
+          .containsExactly(groupKeyWithNoFileMissing);
+    } else {
+      assertThat(fileGroupsMetadata.getAllGroupKeys().get())
+          .containsExactly(
+              downloadedGroupKeyWithFileMissing,
+              pendingGroupKeyWithFileMissing,
+              groupKeyWithNoFileMissing);
+    }
+  }
+
+  @Test
+  public void getOnDeviceUri_shortcutsForSideloadedFiles_delegatesToSharedFileManagerOtherwise()
+      throws Exception {
+    // Ensure that sideloading is turned off
+    flags.enableSideloading = Optional.of(true);
+
+    // Create mixed group
+    DataFileGroupInternal sideloadedGroup =
+        DataFileGroupInternal.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .addFile(
+                DataFile.newBuilder()
+                    .setFileId("sideloaded_file")
+                    .setUrlToDownload("file:/test")
+                    .setChecksumType(DataFile.ChecksumType.NONE)
+                    .build())
+            .addFile(
+                DataFile.newBuilder()
+                    .setFileId("standard_file")
+                    .setUrlToDownload("https://url.to.download")
+                    .setChecksumType(DataFile.ChecksumType.NONE)
+                    .build())
+            .addFile(
+                DataFile.newBuilder()
+                    .setFileId("inline_file")
+                    .setUrlToDownload("inlinefile:sha1:checksum")
+                    .setChecksum("checksum")
+                    .build())
+            .build();
+
+    // Write shared files so shared file manager can get uris
+    NewFileKey[] newFileKeys = MddTestUtil.createFileKeysForDataFileGroupInternal(sideloadedGroup);
+
+    sharedFilesMetadata
+        .write(
+            newFileKeys[1],
+            SharedFile.newBuilder()
+                .setFileName(sideloadedGroup.getFile(1).getFileId())
+                .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+                .build())
+        .get();
+    sharedFilesMetadata
+        .write(
+            newFileKeys[2],
+            SharedFile.newBuilder()
+                .setFileName(sideloadedGroup.getFile(2).getFileId())
+                .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+                .build())
+        .get();
+
+    assertThat(
+            fileGroupManager
+                .getOnDeviceUri(sideloadedGroup.getFile(0), sideloadedGroup)
+                .get()
+                .getScheme())
+        .isEqualTo("file");
+    assertThat(
+            fileGroupManager
+                .getOnDeviceUri(sideloadedGroup.getFile(1), sideloadedGroup)
+                .get()
+                .getScheme())
+        .isEqualTo("android");
+    assertThat(
+            fileGroupManager
+                .getOnDeviceUri(sideloadedGroup.getFile(2), sideloadedGroup)
+                .get()
+                .getScheme())
+        .isEqualTo("android");
+  }
+
+  /**
+   * Re-instantiates {@code fileGroupManager} with the injected parameters.
+   *
+   * <p>It can be used to work with the mocks for FileGroupsMetadata and/or SharedFileManager.
+   */
+  private void resetFileGroupManager(
+      FileGroupsMetadata fileGroupsMetadata, SharedFileManager sharedFileManager) throws Exception {
+    fileGroupManager =
+        new FileGroupManager(
+            context,
+            mockLogger,
+            mockSilentFeedback,
+            fileGroupsMetadata,
+            sharedFileManager,
+            testClock,
+            Optional.of(mockAccountSource),
+            SEQUENTIAL_CONTROL_EXECUTOR,
+            Optional.absent(),
+            fileStorage,
+            downloadStageManager,
+            flags);
+  }
+
+  private static Void createFileGroupDetails(DataFileGroupInternal fileGroup) {
+    return null;
+  }
+
+  private static Void createMddDownloadLatency(
+      int downloadAttemptCount, long downloadLatencyMs, long totalLatencyMs) {
+    return null;
+  }
+
+  private static DataFileGroupInternal createDataFileGroup(
+      String groupName, int fileCount, int downloadAttemptCount, long newFilesReceivedTimestamp) {
+    return MddTestUtil.createDataFileGroupInternal(groupName, fileCount).toBuilder()
+        .setBookkeeping(
+            DataFileGroupBookkeeping.newBuilder()
+                .setDownloadStartedCount(downloadAttemptCount)
+                .setGroupNewFilesReceivedTimestamp(newFilesReceivedTimestamp))
+        .build();
+  }
+
+  /** The file download succeeds so the new file status is DOWNLOAD_COMPLETE. */
+  private void fileDownloadSucceeds(NewFileKey key, Uri fileUri) {
+    when(mockDownloader.startDownloading(
+            any(GroupKey.class),
+            anyInt(),
+            anyLong(),
+            eq(fileUri),
+            any(String.class),
+            anyInt(),
+            any(DownloadConditions.class),
+            isA(DownloaderCallbackImpl.class),
+            anyInt(),
+            anyList()))
+        .then(
+            new Answer<ListenableFuture<Void>>() {
+              @Override
+              public ListenableFuture<Void> answer(InvocationOnMock invocation) throws Throwable {
+                SharedFile sharedFile =
+                    sharedFileManager.getSharedFile(key).get().toBuilder()
+                        .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+                        .build();
+                sharedFilesMetadata.write(key, sharedFile).get();
+                return Futures.immediateVoidFuture();
+              }
+            });
+  }
+
+  /**
+   * The file download fails so the new file status is DOWNLOAD_FAILED. If failureCode is not null,
+   * the downloader returns a immediateFailedFuture; otherwise it returns an immediateVoidFuture.
+   */
+  private void fileDownloadFails(NewFileKey key, Uri fileUri, DownloadResultCode failureCode) {
+    when(mockDownloader.startDownloading(
+            any(GroupKey.class),
+            anyInt(),
+            anyLong(),
+            eq(fileUri),
+            any(String.class),
+            anyInt(),
+            any(DownloadConditions.class),
+            isA(DownloaderCallbackImpl.class),
+            anyInt(),
+            anyList()))
+        .then(
+            new Answer<ListenableFuture<Void>>() {
+              @Override
+              public ListenableFuture<Void> answer(InvocationOnMock invocation) throws Throwable {
+                SharedFile sharedFile =
+                    sharedFileManager.getSharedFile(key).get().toBuilder()
+                        .setFileStatus(FileStatus.DOWNLOAD_FAILED)
+                        .build();
+                sharedFilesMetadata.write(key, sharedFile).get();
+                if (failureCode == null) {
+                  return Futures.immediateVoidFuture();
+                }
+                return Futures.immediateFailedFuture(
+                    DownloadException.builder().setDownloadResultCode(failureCode).build());
+              }
+            });
+  }
+
+  private DataFileGroupInternal readPendingFileGroup(GroupKey key) throws Exception {
+    GroupKey duplicateGroupKey = key.toBuilder().setDownloaded(false).build();
+    return fileGroupsMetadata.read(duplicateGroupKey).get();
+  }
+
+  private DataFileGroupInternal readDownloadedFileGroup(GroupKey key) throws Exception {
+    GroupKey duplicateGroupKey = key.toBuilder().setDownloaded(true).build();
+    return fileGroupsMetadata.read(duplicateGroupKey).get();
+  }
+
+  private void writePendingFileGroup(GroupKey key, DataFileGroupInternal group) throws Exception {
+    GroupKey duplicateGroupKey = key.toBuilder().setDownloaded(false).build();
+    fileGroupsMetadata.write(duplicateGroupKey, group).get();
+  }
+
+  private void writeDownloadedFileGroup(GroupKey key, DataFileGroupInternal group)
+      throws Exception {
+    GroupKey duplicateGroupKey = key.toBuilder().setDownloaded(true).build();
+    fileGroupsMetadata.write(duplicateGroupKey, group).get();
+  }
+
+  private void verifyAddGroupForDownloadWritesMetadata(
+      GroupKey key, DataFileGroupInternal group, long expectedTimestamp) throws Exception {
+    GroupKey duplicateGroupKey = key.toBuilder().setDownloaded(false).build();
+
+    DataFileGroupInternal updatedFileGroup =
+        setReceivedTimeStampWithFeatureOn(group, expectedTimestamp);
+    assertThat(fileGroupsMetadata.read(duplicateGroupKey).get()).isEqualTo(updatedFileGroup);
+  }
+
+  private static GroupKey getPendingKey(GroupKey key) {
+    return key.toBuilder().setDownloaded(false).build();
+  }
+
+  private static GroupKey getDownloadedKey(GroupKey key) {
+    return key.toBuilder().setDownloaded(true).build();
+  }
+
+  private static DataFileGroupInternal setReceivedTimeStampWithFeatureOn(
+      DataFileGroupInternal dataFileGroup, long elapsedTime) {
+    DataFileGroupBookkeeping bookkeeping =
+        dataFileGroup.getBookkeeping().toBuilder()
+            .setGroupNewFilesReceivedTimestamp(elapsedTime)
+            .build();
+    return dataFileGroup.toBuilder().setBookkeeping(bookkeeping).build();
+  }
+
+  /**
+   * Simulates the download of the file {@code dataFile} by writing a file with name {@code
+   * fileName}.
+   */
+  private File simulateDownload(DataFile dataFile, String fileName) throws IOException {
+    File onDeviceFile = new File(publicDirectory, fileName);
+    byte[] bytes = new byte[dataFile.getByteSize()];
+    try (FileOutputStream writer = new FileOutputStream(onDeviceFile)) {
+      writer.write(bytes);
+    }
+    return onDeviceFile;
+  }
+
+  private List<Uri> getOnDeviceUrisForFileGroup(DataFileGroupInternal fileGroup) {
+    ArrayList<Uri> uriList = new ArrayList<>(fileGroup.getFileCount());
+    NewFileKey[] newFileKeys = MddTestUtil.createFileKeysForDataFileGroupInternal(fileGroup);
+
+    for (int i = 0; i < newFileKeys.length; i++) {
+      NewFileKey newFileKey = newFileKeys[i];
+      DataFile dataFile = fileGroup.getFile(i);
+      uriList.add(
+          DirectoryUtil.getOnDeviceUri(
+              context,
+              newFileKey.getAllowedReaders(),
+              dataFile.getFileId(),
+              newFileKey.getChecksum(),
+              mockSilentFeedback,
+              /* instanceId = */ Optional.absent(),
+              /* androidShared = */ false));
+    }
+    return uriList;
+  }
+
+  private AsyncFunction<DataFileGroupInternal, Boolean> noCustomValidation() {
+    return unused -> Futures.immediateFuture(true);
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/internal/FileGroupsMetadataTest.java b/javatests/com/google/android/libraries/mobiledatadownload/internal/FileGroupsMetadataTest.java
new file mode 100644
index 0000000..0fe4f28
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/internal/FileGroupsMetadataTest.java
@@ -0,0 +1,640 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.util.Pair;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.android.libraries.mobiledatadownload.SilentFeedback;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.AndroidFileBackend;
+import com.google.android.libraries.mobiledatadownload.internal.logging.EventLogger;
+import com.google.android.libraries.mobiledatadownload.internal.util.DirectoryUtil;
+import com.google.android.libraries.mobiledatadownload.internal.util.FileGroupUtil;
+import com.google.android.libraries.mobiledatadownload.internal.util.FileGroupsMetadataUtil;
+import com.google.android.libraries.mobiledatadownload.internal.util.ProtoConversionUtil;
+import com.google.android.libraries.mobiledatadownload.internal.util.SharedPreferencesUtil;
+import com.google.android.libraries.mobiledatadownload.testing.FakeTimeSource;
+import com.google.android.libraries.mobiledatadownload.testing.TestFlags;
+import com.google.common.base.Optional;
+import com.google.common.collect.ImmutableList;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.mobiledatadownload.DownloadConfigProto.DataFileGroup;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal;
+import com.google.mobiledatadownload.internal.MetadataProto.GroupKey;
+import com.google.mobiledatadownload.internal.MetadataProto.GroupKeyProperties;
+import java.io.File;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.ParameterizedRobolectricTestRunner;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameter;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameters;
+
+@RunWith(ParameterizedRobolectricTestRunner.class)
+public class FileGroupsMetadataTest {
+
+  // TODO(b/26110951): use Parameterized runner once android_test supports it
+  private enum MetadataStoreImpl {
+    SP_IMPL,
+  }
+
+  // Whether to use PDS metadata store or SharedPreferences metadata store.
+  @Parameter(value = 0)
+  public MetadataStoreImpl metadataStoreImpl;
+
+  @Parameter(value = 1)
+  public Optional<String> instanceId;
+
+  @Parameters(name = "metadataStoreImpl = {0} instanceId = {1}")
+  public static Collection<Object[]> parameters() {
+    return Arrays.asList(
+        new Object[][] {
+          {MetadataStoreImpl.SP_IMPL, Optional.absent()},
+          {MetadataStoreImpl.SP_IMPL, Optional.of("id")},
+        });
+  }
+
+  private static final String TEST_GROUP = "test-group";
+  private static final String TEST_GROUP_2 = "test-group-2";
+  private static final String TEST_GROUP_3 = "test-group-3";
+  private static final Executor CONTROL_EXECUTOR =
+      MoreExecutors.newSequentialExecutor(Executors.newCachedThreadPool());
+
+  private static GroupKey testKey;
+  private static GroupKey testKey2;
+  private static GroupKey testKey3;
+
+  private SynchronousFileStorage fileStorage;
+  private Context context;
+  private FakeTimeSource testClock;
+  private FileGroupsMetadata fileGroupsMetadata;
+  private final TestFlags flags = new TestFlags();
+  @Mock EventLogger mockLogger;
+  @Mock SilentFeedback mockSilentFeedback;
+
+  @Rule public final MockitoRule mocks = MockitoJUnit.rule();
+
+  @Before
+  public void setUp() throws Exception {
+
+    context = ApplicationProvider.getApplicationContext();
+
+    testKey =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setOwnerPackage(context.getPackageName())
+            .setDownloaded(false)
+            .build();
+
+    testKey2 =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP_2)
+            .setOwnerPackage(context.getPackageName())
+            .setDownloaded(false)
+            .build();
+
+    testKey3 =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP_3)
+            .setOwnerPackage(context.getPackageName())
+            .setDownloaded(false)
+            .build();
+
+    fileStorage =
+        new SynchronousFileStorage(Arrays.asList(AndroidFileBackend.builder(context).build()));
+
+    testClock = new FakeTimeSource();
+    SharedPreferencesFileGroupsMetadata sharedPreferencesImpl =
+        new SharedPreferencesFileGroupsMetadata(
+            context, testClock, mockSilentFeedback, instanceId, CONTROL_EXECUTOR);
+    switch (metadataStoreImpl) {
+      case SP_IMPL:
+        fileGroupsMetadata = sharedPreferencesImpl;
+        break;
+    }
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    fileGroupsMetadata.clear().get();
+  }
+
+  @Test
+  public void serializeAndDeserializeFileGroupKey() throws Exception {
+    String serializedGroupKey = FileGroupsMetadataUtil.getSerializedGroupKey(testKey, context);
+    GroupKey deserializedGroupKey = FileGroupsMetadataUtil.deserializeGroupKey(serializedGroupKey);
+
+    assertThat(deserializedGroupKey.getGroupName()).isEqualTo(TEST_GROUP);
+    assertThat(deserializedGroupKey.getOwnerPackage()).isEqualTo(context.getPackageName());
+    assertThat(deserializedGroupKey.getDownloaded()).isFalse();
+  }
+
+  @Test
+  public void readAndWriteFileGroup() throws Exception {
+    DataFileGroupInternal writeFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+    DataFileGroupInternal writeFileGroup2 =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP_2, 1);
+    DataFileGroupInternal writeFileGroup3 =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP_3, 5);
+
+    assertThat(fileGroupsMetadata.read(testKey).get()).isNull();
+    assertThat(fileGroupsMetadata.write(testKey, writeFileGroup).get()).isTrue();
+
+    assertThat(fileGroupsMetadata.read(testKey2).get()).isNull();
+    assertThat(fileGroupsMetadata.write(testKey2, writeFileGroup2).get()).isTrue();
+
+    assertThat(fileGroupsMetadata.read(testKey3).get()).isNull();
+    assertThat(fileGroupsMetadata.write(testKey3, writeFileGroup3).get()).isTrue();
+
+    DataFileGroupInternal readFileGroup = fileGroupsMetadata.read(testKey).get();
+    MddTestUtil.assertMessageEquals(readFileGroup, writeFileGroup);
+
+    DataFileGroupInternal readFileGroup2 = fileGroupsMetadata.read(testKey2).get();
+    MddTestUtil.assertMessageEquals(readFileGroup2, writeFileGroup2);
+
+    DataFileGroupInternal readFileGroup3 = fileGroupsMetadata.read(testKey3).get();
+    MddTestUtil.assertMessageEquals(readFileGroup3, writeFileGroup3);
+
+    verifyNoErrorInPdsMigration();
+  }
+
+  @Test
+  public void readAndWriteFileGroup_withExtension() throws Exception {
+    DataFileGroupInternal writeFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+
+    assertThat(fileGroupsMetadata.read(testKey).get()).isNull();
+    assertThat(fileGroupsMetadata.write(testKey, writeFileGroup).get()).isTrue();
+
+    DataFileGroupInternal readFileGroup = fileGroupsMetadata.read(testKey).get();
+    MddTestUtil.assertMessageEquals(readFileGroup, writeFileGroup);
+
+    writeFileGroup = FileGroupUtil.setStaleExpirationDate(writeFileGroup, 1000);
+    assertThat(fileGroupsMetadata.write(testKey, writeFileGroup).get()).isTrue();
+
+    readFileGroup = fileGroupsMetadata.read(testKey).get();
+    MddTestUtil.assertMessageEquals(readFileGroup, writeFileGroup);
+
+    verifyNoErrorInPdsMigration();
+  }
+
+  @Test
+  public void removeFileGroup() throws Exception {
+    DataFileGroupInternal fileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+    DataFileGroupInternal fileGroup2 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP_2, 1);
+    DataFileGroupInternal fileGroup3 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP_3, 5);
+
+    assertThat(fileGroupsMetadata.write(testKey, fileGroup).get()).isTrue();
+    assertThat(fileGroupsMetadata.remove(testKey).get()).isTrue();
+    assertThat(fileGroupsMetadata.read(testKey).get()).isNull();
+
+    assertThat(fileGroupsMetadata.write(testKey2, fileGroup2).get()).isTrue();
+    assertThat(fileGroupsMetadata.remove(testKey2).get()).isTrue();
+    assertThat(fileGroupsMetadata.read(testKey2).get()).isNull();
+
+    assertThat(fileGroupsMetadata.write(testKey3, fileGroup3).get()).isTrue();
+    assertThat(fileGroupsMetadata.remove(testKey3).get()).isTrue();
+    assertThat(fileGroupsMetadata.read(testKey3).get()).isNull();
+
+    verifyNoErrorInPdsMigration();
+  }
+
+  @Test
+  public void readAndWriteFileGroupKeyProperties() throws Exception {
+    GroupKeyProperties writeGroupKeyProperties =
+        GroupKeyProperties.newBuilder().setActivatedOnDevice(true).build();
+    GroupKeyProperties writeGroupKeyProperties2 =
+        GroupKeyProperties.newBuilder().setActivatedOnDevice(false).build();
+
+    assertThat(fileGroupsMetadata.readGroupKeyProperties(testKey).get()).isNull();
+    assertThat(fileGroupsMetadata.writeGroupKeyProperties(testKey, writeGroupKeyProperties).get())
+        .isTrue();
+
+    assertThat(fileGroupsMetadata.readGroupKeyProperties(testKey2).get()).isNull();
+    assertThat(fileGroupsMetadata.writeGroupKeyProperties(testKey2, writeGroupKeyProperties2).get())
+        .isTrue();
+
+    GroupKeyProperties readGroupKeyProperties =
+        fileGroupsMetadata.readGroupKeyProperties(testKey).get();
+    MddTestUtil.assertMessageEquals(writeGroupKeyProperties, readGroupKeyProperties);
+
+    GroupKeyProperties readGroupKeyProperties2 =
+        fileGroupsMetadata.readGroupKeyProperties(testKey2).get();
+    MddTestUtil.assertMessageEquals(writeGroupKeyProperties2, readGroupKeyProperties2);
+
+    verifyNoErrorInPdsMigration();
+  }
+
+  @Test
+  public void clear_removesAllMetadata() throws Exception {
+    DataFileGroupInternal fileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+    DataFileGroupInternal fileGroup2 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP_2, 1);
+    DataFileGroupInternal fileGroup3 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP_3, 5);
+
+    File parentDir =
+        new File(context.getFilesDir(), DirectoryUtil.MDD_STORAGE_MODULE + "/" + "shared");
+    assertThat(parentDir.mkdirs()).isTrue();
+    File garbageFile = FileGroupsMetadataUtil.getGarbageCollectorFile(context, instanceId);
+
+    DataFileGroupInternal staleFileGroup =
+        MddTestUtil.createDataFileGroupInternal("stale-group", 2).toBuilder()
+            .setStaleLifetimeSecs(Duration.ofDays(1).getSeconds())
+            .build();
+
+    assertThat(fileGroupsMetadata.write(testKey, fileGroup).get()).isTrue();
+    assertThat(fileGroupsMetadata.write(testKey2, fileGroup2).get()).isTrue();
+    assertThat(fileGroupsMetadata.write(testKey3, fileGroup3).get()).isTrue();
+    assertThat(fileGroupsMetadata.addStaleGroup(staleFileGroup).get()).isTrue();
+
+    fileGroupsMetadata.clear().get();
+
+    assertThat(fileGroupsMetadata.read(testKey).get()).isNull();
+    assertThat(fileGroupsMetadata.read(testKey2).get()).isNull();
+    assertThat(fileGroupsMetadata.read(testKey3).get()).isNull();
+    assertThat(garbageFile.exists()).isFalse();
+
+    for (File file : parentDir.listFiles()) {
+      boolean unused = file.delete();
+    }
+
+    verifyNoErrorInPdsMigration();
+  }
+
+  @Test
+  public void retrieveAllGroups() throws Exception {
+    GroupKey notSetDownloadedGroupKey =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setOwnerPackage(context.getPackageName())
+            .build();
+
+    DataFileGroupInternal fileGroup1 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+    assertThat(fileGroupsMetadata.write(notSetDownloadedGroupKey, fileGroup1).get()).isTrue();
+
+    GroupKey setTrueDownloadedGroupKey =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP_2)
+            .setOwnerPackage(context.getPackageName())
+            .setDownloaded(true)
+            .build();
+    DataFileGroupInternal fileGroup2 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP_2, 2);
+    assertThat(fileGroupsMetadata.write(setTrueDownloadedGroupKey, fileGroup2).get()).isTrue();
+
+    GroupKey setFalseDownloadedGroupKey =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP_3)
+            .setOwnerPackage(context.getPackageName())
+            .setDownloaded(false)
+            .build();
+    DataFileGroupInternal fileGroup3 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP_3, 2);
+    assertThat(fileGroupsMetadata.write(setFalseDownloadedGroupKey, fileGroup3).get()).isTrue();
+
+    if (metadataStoreImpl == MetadataStoreImpl.SP_IMPL) {
+      // Garbage entry that will create null GroupKey
+      SharedPreferences prefs =
+          SharedPreferencesUtil.getSharedPreferences(
+              context, FileGroupsMetadataUtil.MDD_FILE_GROUPS, instanceId);
+      prefs.edit().putString("garbage-key", "garbage-value").commit();
+    }
+
+    List<Pair<GroupKey, DataFileGroupInternal>> allGroups =
+        fileGroupsMetadata.getAllFreshGroups().get();
+    assertThat(allGroups).hasSize(3);
+
+    verifyNoErrorInPdsMigration();
+  }
+
+  @Test
+  public void removeGroups_noGroups() throws Exception {
+    // Newer pending version of this group.
+    DataFileGroupInternal fileGroup1 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+    GroupKey key1 =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setOwnerPackage(context.getPackageName())
+            .setDownloaded(false)
+            .build();
+    writePendingFileGroupToSharedPrefs(key1, fileGroup1);
+
+    // Older downloaded version of the same group
+    DataFileGroupInternal fileGroup2 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1);
+    GroupKey key2 =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setOwnerPackage(context.getPackageName())
+            .setDownloaded(true)
+            .build();
+    writeDownloadedFileGroupToSharedPrefs(key2, fileGroup2);
+
+    assertThat(fileGroupsMetadata.removeAllGroupsWithKeys(ImmutableList.of()).get()).isTrue();
+
+    assertThat(readPendingFileGroupFromSharedPrefs(key1, true /*shouldExist*/))
+        .isEqualTo(fileGroup1);
+    assertThat(readDownloadedFileGroupFromSharedPrefs(key2, true /*shouldExist*/))
+        .isEqualTo(fileGroup2);
+
+    verifyNoErrorInPdsMigration();
+  }
+
+  @Test
+  public void removeGroups_removePendingGroup() throws Exception {
+    // Newer pending version of this group.
+    DataFileGroupInternal fileGroup1 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+    GroupKey key1 =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setOwnerPackage(context.getPackageName())
+            .setDownloaded(false)
+            .build();
+    writePendingFileGroupToSharedPrefs(key1, fileGroup1);
+
+    // Older downloaded version of the same group
+    DataFileGroupInternal fileGroup2 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1);
+    GroupKey key2 =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setOwnerPackage(context.getPackageName())
+            .setDownloaded(true)
+            .build();
+    writeDownloadedFileGroupToSharedPrefs(key2, fileGroup2);
+
+    assertThat(fileGroupsMetadata.removeAllGroupsWithKeys(Arrays.asList(key1)).get()).isTrue();
+
+    readPendingFileGroupFromSharedPrefs(key1, false /*shouldExist*/);
+    assertThat(readDownloadedFileGroupFromSharedPrefs(key2, true /*shouldExist*/))
+        .isEqualTo(fileGroup2);
+
+    verifyNoErrorInPdsMigration();
+  }
+
+  @Test
+  public void removeGroups_removeDownloadedGroup() throws Exception {
+    // Newer pending version of this group.
+    DataFileGroupInternal fileGroup1 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+    GroupKey key1 =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setOwnerPackage(context.getPackageName())
+            .setDownloaded(false)
+            .build();
+    writePendingFileGroupToSharedPrefs(key1, fileGroup1);
+
+    // Older downloaded version of the same group
+    DataFileGroupInternal fileGroup2 = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1);
+    GroupKey key2 =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setOwnerPackage(context.getPackageName())
+            .setDownloaded(true)
+            .build();
+    writeDownloadedFileGroupToSharedPrefs(key2, fileGroup2);
+
+    assertThat(fileGroupsMetadata.removeAllGroupsWithKeys(Arrays.asList(key2)).get()).isTrue();
+
+    assertThat(readPendingFileGroupFromSharedPrefs(key1, true /*shouldExist*/))
+        .isEqualTo(fileGroup1);
+    readDownloadedFileGroupFromSharedPrefs(key2, false /*shouldExist*/);
+
+    verifyNoErrorInPdsMigration();
+  }
+
+  @Test
+  public void addStaleGroup_multipleGroups() throws Exception {
+    long staleExpirationLifetimeSecs = 1000;
+
+    DataFileGroupInternal fileGroup1 =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder()
+            .setStaleLifetimeSecs(staleExpirationLifetimeSecs)
+            .build();
+    DataFileGroupInternal fileGroup2 =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 3).toBuilder()
+            .setStaleLifetimeSecs(staleExpirationLifetimeSecs)
+            .build();
+
+    assertThat(fileGroupsMetadata.getAllStaleGroups().get()).isEmpty();
+
+    testClock.set(15000 /* 15 seconds */);
+    assertThat(fileGroupsMetadata.addStaleGroup(fileGroup1).get()).isTrue();
+    assertThat(fileGroupsMetadata.addStaleGroup(fileGroup2).get()).isTrue();
+
+    List<DataFileGroupInternal> staleGroups = fileGroupsMetadata.getAllStaleGroups().get();
+    assertThat(staleGroups).hasSize(2);
+
+    fileGroup1 = FileGroupUtil.setStaleExpirationDate(fileGroup1, staleExpirationLifetimeSecs + 15);
+    fileGroup2 = FileGroupUtil.setStaleExpirationDate(fileGroup2, staleExpirationLifetimeSecs + 15);
+
+    assertThat(staleGroups.get(0)).isEqualTo(fileGroup1);
+    assertThat(staleGroups.get(1)).isEqualTo(fileGroup2);
+
+    verifyNoErrorInPdsMigration();
+  }
+
+  @Test
+  public void removeAllStaleGroups_multipleGroups() throws Exception {
+    long staleExpirationLifetimeSecs = 1000;
+
+    List<DataFileGroupInternal> fileGroups = new ArrayList<>();
+    DataFileGroupInternal fileGroup1 =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder()
+            .setStaleLifetimeSecs(staleExpirationLifetimeSecs)
+            .build();
+    fileGroups.add(fileGroup1);
+
+    DataFileGroupInternal fileGroup2 =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 3).toBuilder()
+            .setStaleLifetimeSecs(staleExpirationLifetimeSecs)
+            .build();
+    fileGroups.add(fileGroup2);
+
+    assertThat(fileGroupsMetadata.getAllStaleGroups().get()).isEmpty();
+
+    assertThat(fileGroupsMetadata.writeStaleGroups(fileGroups).get()).isTrue();
+    assertThat(fileGroupsMetadata.getAllStaleGroups().get()).hasSize(2);
+
+    fileGroupsMetadata.removeAllStaleGroups().get();
+    assertThat(fileGroupsMetadata.getAllStaleGroups().get()).isEmpty();
+
+    verifyNoErrorInPdsMigration();
+  }
+
+  @Test
+  public void writeStaleGroups_noGroup() throws Exception {
+    List<DataFileGroupInternal> fileGroups = new ArrayList<>();
+    assertThat(fileGroupsMetadata.writeStaleGroups(fileGroups).get()).isTrue();
+    assertThat(fileGroupsMetadata.getAllStaleGroups().get()).isEmpty();
+    verifyNoErrorInPdsMigration();
+  }
+
+  /**
+   * This test mainly exists to ensure that the garbage collector handles IO operations correctly
+   * for large inputs.
+   */
+  @Test
+  public void writeAndReadStaleGroups_onLotsOfFileGroups() throws Exception {
+    long staleExpirationDate = 1000;
+
+    // Create files on device so that the garbage collector can delete them
+    List<DataFileGroupInternal> fileGroups = new ArrayList<>();
+    for (int i = 0; i < 5; ++i) {
+      DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal("group" + i, 1);
+      dataFileGroup = FileGroupUtil.setStaleExpirationDate(dataFileGroup, staleExpirationDate);
+      fileGroups.add(dataFileGroup);
+    }
+
+    assertThat(fileGroupsMetadata.writeStaleGroups(fileGroups).get()).isTrue();
+    assertThat(
+            fileGroupsMetadata
+                .getAllStaleGroups()
+                .get()
+                .get(0)
+                .getBookkeeping()
+                .getStaleExpirationDate())
+        .isEqualTo(1000);
+    assertThat(fileGroupsMetadata.getAllStaleGroups().get()).containsExactlyElementsIn(fileGroups);
+
+    verifyNoErrorInPdsMigration();
+  }
+
+  /**
+   * This test mainly exists to ensure that after migrating the group metadata storage proto from
+   * {@link DataFileGroup} to {@link DataFileGroupInternal}, MDD is still able to parse the group
+   * metadata which was previously written to disk before the migration.
+   */
+  @Test
+  public void writeAndReadGroups_migration_fromDataFileGroup_toDataFileGroupInternal()
+      throws Exception {
+    DataFileGroup fileGroup1 = MddTestUtil.createDataFileGroup(TEST_GROUP, 2);
+    GroupKey key1 =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setOwnerPackage(context.getPackageName())
+            .setDownloaded(false)
+            .build();
+    assertThat(writeDataFileGroup(key1, fileGroup1, instanceId)).isTrue();
+
+    // Older downloaded version of the same group
+    DataFileGroup fileGroup2 = MddTestUtil.createDataFileGroup(TEST_GROUP, 1);
+    GroupKey key2 =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setOwnerPackage(context.getPackageName())
+            .setDownloaded(true)
+            .build();
+    assertThat(writeDataFileGroup(key2, fileGroup2, instanceId)).isTrue();
+
+    // Make sure that parsing DataFileGroup to DataFileGroupInternal produces identical result as
+    // calling proto convert.
+    assertThat(fileGroupsMetadata.read(key1).get())
+        .isEqualTo(ProtoConversionUtil.convert(fileGroup1));
+    assertThat(fileGroupsMetadata.read(key2).get())
+        .isEqualTo(ProtoConversionUtil.convert(fileGroup2));
+
+    verifyNoErrorInPdsMigration();
+  }
+
+  @Test
+  public void garbageCollectorFileSeparation() throws Exception {
+    SharedPreferencesFileGroupsMetadata fileGroupsMetadataAbsent =
+        new SharedPreferencesFileGroupsMetadata(
+            context, testClock, mockSilentFeedback, Optional.absent(), CONTROL_EXECUTOR);
+
+    SharedPreferencesFileGroupsMetadata fileGroupsMetadata2 =
+        new SharedPreferencesFileGroupsMetadata(
+            context, testClock, mockSilentFeedback, Optional.of("instance2"), CONTROL_EXECUTOR);
+
+    SharedPreferencesFileGroupsMetadata fileGroupsMetadata3 =
+        new SharedPreferencesFileGroupsMetadata(
+            context, testClock, mockSilentFeedback, Optional.of("instance3"), CONTROL_EXECUTOR);
+
+    assertThat(fileGroupsMetadataAbsent.getGarbageCollectorFile().getAbsolutePath())
+        .isNotEqualTo(fileGroupsMetadata2.getGarbageCollectorFile().getAbsolutePath());
+
+    assertThat(fileGroupsMetadata2.getGarbageCollectorFile().getAbsolutePath())
+        .isNotEqualTo(fileGroupsMetadata3.getGarbageCollectorFile().getAbsolutePath());
+  }
+
+  /**
+   * Writes {@link DataFileGroup} into disk. The main purpose of this method is for the convenience
+   * of migration tests. Previously, the file group metadata is stored in DataFileGroup with
+   * extensions. We wanted to make sure that after migrating to {@link DataFileGroupInternal}, the
+   * previous metadata can still be parsed.
+   */
+  boolean writeDataFileGroup(
+      GroupKey groupKey, DataFileGroup fileGroup, Optional<String> instanceId) {
+    String serializedGroupKey = FileGroupsMetadataUtil.getSerializedGroupKey(groupKey, context);
+    SharedPreferences prefs =
+        SharedPreferencesUtil.getSharedPreferences(
+            context, FileGroupsMetadataUtil.MDD_FILE_GROUPS, instanceId);
+    return SharedPreferencesUtil.writeProto(prefs, serializedGroupKey, fileGroup);
+  }
+
+  private DataFileGroupInternal readPendingFileGroupFromSharedPrefs(
+      GroupKey key, boolean shouldExist) throws Exception {
+    GroupKey duplicateGroupKey = key.toBuilder().setDownloaded(false).build();
+    return readFileGroupFromSharedPrefs(duplicateGroupKey, shouldExist);
+  }
+
+  private void writePendingFileGroupToSharedPrefs(GroupKey key, DataFileGroupInternal group)
+      throws Exception {
+    GroupKey duplicateGroupKey = key.toBuilder().setDownloaded(false).build();
+    assertThat(fileGroupsMetadata.write(duplicateGroupKey, group).get()).isTrue();
+  }
+
+  private DataFileGroupInternal readDownloadedFileGroupFromSharedPrefs(
+      GroupKey key, boolean shouldExist) throws Exception {
+    GroupKey duplicateGroupKey = key.toBuilder().setDownloaded(true).build();
+    return readFileGroupFromSharedPrefs(duplicateGroupKey, shouldExist);
+  }
+
+  private void writeDownloadedFileGroupToSharedPrefs(GroupKey key, DataFileGroupInternal group)
+      throws Exception {
+    GroupKey duplicateGroupKey = key.toBuilder().setDownloaded(true).build();
+    assertThat(fileGroupsMetadata.write(duplicateGroupKey, group).get()).isTrue();
+  }
+
+  private DataFileGroupInternal readFileGroupFromSharedPrefs(GroupKey key, boolean shouldExist)
+      throws Exception {
+    DataFileGroupInternal group = fileGroupsMetadata.read(key).get();
+    if (shouldExist) {
+      assertWithMessage(String.format("Expected that key %s should exist.", key))
+          .that(group)
+          .isNotNull();
+    } else {
+      assertWithMessage(String.format("Expected that key %s should not exist.", key))
+          .that(group)
+          .isNull();
+    }
+    return group;
+  }
+
+  private void verifyNoErrorInPdsMigration() {}
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/internal/MddIsolatedStructuresTest.java b/javatests/com/google/android/libraries/mobiledatadownload/internal/MddIsolatedStructuresTest.java
new file mode 100644
index 0000000..582f412
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/internal/MddIsolatedStructuresTest.java
@@ -0,0 +1,255 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_16;
+
+import android.content.Context;
+import android.net.Uri;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.android.libraries.mobiledatadownload.SilentFeedback;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.AndroidFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.backends.AndroidUri;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.FakeFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.TemporaryUri;
+import com.google.android.libraries.mobiledatadownload.file.openers.WriteByteArrayOpener;
+import com.google.android.libraries.mobiledatadownload.internal.downloader.MddFileDownloader;
+import com.google.android.libraries.mobiledatadownload.internal.experimentation.NoOpDownloadStageManager;
+import com.google.android.libraries.mobiledatadownload.internal.logging.EventLogger;
+import com.google.android.libraries.mobiledatadownload.internal.util.SymlinkUtil;
+import com.google.android.libraries.mobiledatadownload.testing.FakeTimeSource;
+import com.google.android.libraries.mobiledatadownload.testing.TestFlags;
+import com.google.common.base.Optional;
+import com.google.common.util.concurrent.AsyncFunction;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFile;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal.AllowedReaders;
+import com.google.mobiledatadownload.internal.MetadataProto.DownloadConditions;
+import com.google.mobiledatadownload.internal.MetadataProto.FileStatus;
+import com.google.mobiledatadownload.internal.MetadataProto.GroupKey;
+import com.google.mobiledatadownload.internal.MetadataProto.NewFileKey;
+import com.google.mobiledatadownload.internal.MetadataProto.SharedFile;
+import java.util.Arrays;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+/**
+ * Emulator tests for MDD isolated structures support. This is separate from the other robolectric
+ * tests because android.os.symlink and android.os.readlink do not work with robolectric.
+ */
+@RunWith(AndroidJUnit4.class)
+public final class MddIsolatedStructuresTest {
+
+  private static final String TEST_GROUP = "test-group";
+
+  @Rule public TemporaryUri tempUri = new TemporaryUri();
+
+  private Context context;
+  private FileGroupManager fileGroupManager;
+  private FileGroupsMetadata fileGroupsMetadata;
+  private SharedFileManager sharedFileManager;
+  private SharedFilesMetadata sharedFilesMetadata;
+  private FakeTimeSource testClock;
+  private SynchronousFileStorage fileStorage;
+  private FakeFileBackend fakeAndroidFileBackend;
+  @Mock SilentFeedback mockSilentFeedback;
+
+  GroupKey defaultGroupKey;
+  DataFileGroupInternal defaultFileGroup;
+  DataFile file;
+  NewFileKey newFileKey;
+  SharedFile existingSharedFile;
+
+  @Mock MddFileDownloader mockDownloader;
+  @Mock EventLogger mockLogger;
+  @Rule public final MockitoRule mockito = MockitoJUnit.rule();
+
+  private static final Executor SEQUENTIAL_CONTROL_EXECUTOR =
+      Executors.newSingleThreadScheduledExecutor();
+
+  @Before
+  public void setUp() throws Exception {
+    context = ApplicationProvider.getApplicationContext();
+
+    testClock = new FakeTimeSource();
+
+    TestFlags flags = new TestFlags();
+
+    fakeAndroidFileBackend = new FakeFileBackend(AndroidFileBackend.builder(context).build());
+    fileStorage = new SynchronousFileStorage(Arrays.asList(fakeAndroidFileBackend));
+
+    fileGroupsMetadata =
+        new SharedPreferencesFileGroupsMetadata(
+            context,
+            testClock,
+            mockSilentFeedback,
+            Optional.absent(),
+            MoreExecutors.directExecutor());
+    sharedFilesMetadata =
+        new SharedPreferencesSharedFilesMetadata(
+            context, mockSilentFeedback, Optional.absent(), flags);
+    sharedFileManager =
+        new SharedFileManager(
+            context,
+            mockSilentFeedback,
+            sharedFilesMetadata,
+            fileStorage,
+            mockDownloader,
+            Optional.absent(),
+            Optional.absent(),
+            mockLogger,
+            flags,
+            fileGroupsMetadata,
+            Optional.absent(),
+            MoreExecutors.directExecutor());
+
+    fileGroupManager =
+        new FileGroupManager(
+            context,
+            mockLogger,
+            mockSilentFeedback,
+            fileGroupsMetadata,
+            sharedFileManager,
+            new FakeTimeSource(),
+            Optional.absent(),
+            SEQUENTIAL_CONTROL_EXECUTOR,
+            Optional.absent(),
+            fileStorage,
+            new NoOpDownloadStageManager(),
+            flags);
+
+    defaultGroupKey =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setOwnerPackage(context.getPackageName())
+            .build();
+    defaultFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder()
+            .setPreserveFilenamesAndIsolateFiles(true)
+            .build();
+    file = defaultFileGroup.getFile(0);
+
+    newFileKey = SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+
+    existingSharedFile =
+        SharedFile.newBuilder()
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .setFileName("fileName")
+            .setAndroidShared(false)
+            .build();
+  }
+
+  @Test
+  public void testSymlinkUtil() throws Exception {
+    Uri targetUri = AndroidUri.builder(context).setRelativePath("targetFile").build();
+    // Write some data so the target file exists.
+    fileStorage.open(targetUri, WriteByteArrayOpener.create("some bytes".getBytes(UTF_16)));
+
+    Uri linkUri = AndroidUri.builder(context).setRelativePath("linkFile").build();
+
+    SymlinkUtil.createSymlink(context, linkUri, targetUri);
+
+    // Make sure the symlink points to the original target
+    assertThat(SymlinkUtil.readSymlink(context, linkUri)).isEqualTo(targetUri);
+  }
+
+  @Test
+  public void testFileGroupManager_createsIsolatedStructures() throws Exception {
+    writePendingFileGroup(defaultGroupKey, defaultFileGroup);
+    sharedFilesMetadata.write(newFileKey, existingSharedFile).get();
+
+    Uri onDeviceUri = fileGroupManager.getOnDeviceUri(file, defaultFileGroup).get();
+    // Actually write something to disk so the symlink points to something.
+    fileStorage.open(onDeviceUri, WriteByteArrayOpener.create("some content".getBytes(UTF_16)));
+
+    // Download the file group so MDD creates the structures
+    fileGroupManager
+        .downloadFileGroup(
+            defaultGroupKey, DownloadConditions.getDefaultInstance(), noCustomValidation())
+        .get();
+
+    Uri isolatedFileUri =
+        fileGroupManager.getAndVerifyIsolatedFileUri(onDeviceUri, file, defaultFileGroup);
+
+    assertThat(SymlinkUtil.readSymlink(context, isolatedFileUri)).isEqualTo(onDeviceUri);
+  }
+
+  @Test
+  public void testFileGroupManager_getDownloadedFileGroup_returnsNullIfIsolatedStructuresDontExist()
+      throws Exception {
+    writePendingFileGroup(defaultGroupKey, defaultFileGroup);
+    sharedFilesMetadata.write(newFileKey, existingSharedFile).get();
+
+    fileGroupManager
+        .downloadFileGroup(
+            defaultGroupKey, DownloadConditions.getDefaultInstance(), noCustomValidation())
+        .get();
+
+    Uri onDeviceUri = fileGroupManager.getOnDeviceUri(file, defaultFileGroup).get();
+    Uri isolatedFileUri =
+        fileGroupManager.getAndVerifyIsolatedFileUri(onDeviceUri, file, defaultFileGroup);
+
+    fileStorage.deleteFile(isolatedFileUri);
+
+    assertThat(fileGroupManager.getFileGroup(defaultGroupKey, true).get()).isNull();
+  }
+
+  @Test
+  public void testFileGroupManager_repairsIsolatedStructuresOnMaintenance() throws Exception {
+    writePendingFileGroup(defaultGroupKey, defaultFileGroup);
+    sharedFilesMetadata.write(newFileKey, existingSharedFile).get();
+
+    fileGroupManager
+        .downloadFileGroup(
+            defaultGroupKey, DownloadConditions.getDefaultInstance(), noCustomValidation())
+        .get();
+
+    Uri onDeviceUri = fileGroupManager.getOnDeviceUri(file, defaultFileGroup).get();
+    Uri isolatedFileUri =
+        fileGroupManager.getAndVerifyIsolatedFileUri(onDeviceUri, file, defaultFileGroup);
+
+    assertThat(fileGroupManager.getFileGroup(defaultGroupKey, true).get()).isNotNull();
+
+    fileStorage.deleteFile(isolatedFileUri);
+
+    assertThat(fileGroupManager.getFileGroup(defaultGroupKey, true).get()).isNull();
+
+    fileGroupManager.verifyAndAttemptToRepairIsolatedFiles().get();
+
+    assertThat(fileGroupManager.getFileGroup(defaultGroupKey, true).get()).isNotNull();
+  }
+
+  private void writePendingFileGroup(GroupKey key, DataFileGroupInternal group) throws Exception {
+    GroupKey duplicateGroupKey = key.toBuilder().setDownloaded(false).build();
+    fileGroupsMetadata.write(duplicateGroupKey, group).get();
+  }
+
+  private AsyncFunction<DataFileGroupInternal, Boolean> noCustomValidation() {
+    return unused -> Futures.immediateFuture(true);
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/internal/MddTestUtil.java b/javatests/com/google/android/libraries/mobiledatadownload/internal/MddTestUtil.java
new file mode 100644
index 0000000..0cfa436
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/internal/MddTestUtil.java
@@ -0,0 +1,329 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal;
+
+import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static java.util.concurrent.TimeUnit.DAYS;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+
+import android.content.Context;
+import android.os.Build.VERSION;
+import android.support.test.uiautomator.UiDevice;
+import android.util.Log;
+import com.google.android.apps.common.testing.util.BackdoorTestUtil;
+import com.google.mobiledatadownload.DownloadConfigProto.DataFileGroup;
+import com.google.mobiledatadownload.TransformProto.Transform;
+import com.google.mobiledatadownload.TransformProto.Transforms;
+import com.google.mobiledatadownload.TransformProto.ZipTransform;
+import com.google.mobiledatadownload.internal.MetadataProto.BaseFile;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFile;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal;
+import com.google.mobiledatadownload.internal.MetadataProto.DeltaFile;
+import com.google.mobiledatadownload.internal.MetadataProto.DeltaFile.DiffDecoder;
+import com.google.mobiledatadownload.internal.MetadataProto.FileStatus;
+import com.google.mobiledatadownload.internal.MetadataProto.NewFileKey;
+import com.google.mobiledatadownload.internal.MetadataProto.SharedFile;
+import com.google.protobuf.MessageLite;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.List;
+
+public class MddTestUtil {
+
+  public static final String FILE_URI = "android://file";
+  private static final String TAG = "MddTestUtil";
+
+  /**
+   * Creates a data file group with the given number of files. It only sets the field that are set
+   * in a data file group that we get from the server.
+   */
+  public static DataFileGroup createDataFileGroup(String fileGroupName, int fileCount) {
+    DataFileGroup.Builder dataFileGroup = DataFileGroup.newBuilder().setGroupName(fileGroupName);
+    for (int i = 0; i < fileCount; ++i) {
+      com.google.mobiledatadownload.DownloadConfigProto.DataFile.Builder file =
+          com.google.mobiledatadownload.DownloadConfigProto.DataFile.newBuilder();
+      file.setFileId(String.format("%s_%s", fileGroupName, i));
+      file.setUrlToDownload(String.format("https://%s_%s", fileGroupName, i));
+      file.setByteSize(10 + i);
+      file.setChecksum("123" + i);
+      dataFileGroup.addFile(file.build());
+    }
+    return dataFileGroup.build();
+  }
+
+  /**
+   * Creates an internal data file group with the given number of files. It only sets the field that
+   * are set in a data file group that we get from the server.
+   */
+  public static DataFileGroupInternal createDataFileGroupInternal(
+      String fileGroupName, int fileCount) {
+    DataFileGroupInternal.Builder dataFileGroupInternal =
+        DataFileGroupInternal.newBuilder().setGroupName(fileGroupName);
+    for (int i = 0; i < fileCount; ++i) {
+      dataFileGroupInternal.addFile(createDataFile(fileGroupName, i));
+    }
+    return dataFileGroupInternal.build();
+  }
+
+  /**
+   * Creates an internal data file group with the given number of files. It only sets the field that
+   * are set in a data file group that we get from the server.
+   */
+  public static DataFileGroupInternal createDataFileGroupInternalWithDownloadId(
+      String fileGroupName, int fileCount) {
+    DataFileGroupInternal.Builder dataFileGroupInternal =
+        DataFileGroupInternal.newBuilder().setGroupName(fileGroupName);
+    for (int i = 0; i < fileCount; ++i) {
+      dataFileGroupInternal.addFile(createDataFile(fileGroupName, i));
+    }
+    return dataFileGroupInternal.build();
+  }
+
+  /**
+   * Creates an internal data file group with the given number of files, all configured to be
+   * shared. It only sets the field that are set in a data file group that we get from the server.
+   */
+  public static DataFileGroupInternal createSharedDataFileGroupInternal(
+      String fileGroupName, int fileCount) {
+    DataFileGroupInternal.Builder dataFileGroupInternal =
+        DataFileGroupInternal.newBuilder().setGroupName(fileGroupName);
+    for (int i = 0; i < fileCount; ++i) {
+      dataFileGroupInternal.addFile(createSharedDataFile(fileGroupName, /* fileIndex = */ i));
+    }
+    return dataFileGroupInternal.build();
+  }
+
+  /**
+   * Creates a data file group with the given number of files. It creates a downloaded file, so the
+   * file uri is also set.
+   */
+  public static DataFileGroupInternal createDownloadedDataFileGroupInternal(
+      String fileGroupName, int fileCount) {
+    DataFileGroupInternal.Builder dataFileGroup =
+        DataFileGroupInternal.newBuilder().setGroupName(fileGroupName);
+    for (int i = 0; i < fileCount; ++i) {
+      dataFileGroup.addFile(createDownloadedDataFile(fileGroupName, i));
+    }
+    return dataFileGroup.build();
+  }
+
+  private static DataFile createDownloadedDataFile(String fileId, int fileIndex) {
+    DataFile file = createDataFile(fileId, fileIndex);
+    return file;
+  }
+
+  public static DataFile createDataFile(String fileId, int fileIndex) {
+    DataFile.Builder file = DataFile.newBuilder();
+    file.setFileId(String.format("%s_%s", fileId, fileIndex));
+    file.setUrlToDownload(String.format("https://%s_%s", fileId, fileIndex));
+    file.setByteSize(10 + fileIndex);
+    file.setChecksum("123" + fileIndex);
+    return file.build();
+  }
+
+  /**
+   * Creates a dataFile configured for sharing, i.e. with ChecksumType set to SHA256 and
+   * AndroidSharingType set to ANDROID_BLOB_WHEN_AVAILABLE.
+   */
+  public static DataFile createSharedDataFile(String fileId, int fileIndex) {
+    DataFile.Builder file = DataFile.newBuilder();
+    file.setFileId(String.format("%s_%s", fileId, fileIndex));
+    file.setUrlToDownload(String.format("https://%s_%s", fileId, fileIndex));
+    file.setByteSize(10 + fileIndex);
+    file.setChecksum("123" + fileIndex);
+    file.setChecksumType(DataFile.ChecksumType.DEFAULT);
+    file.setAndroidSharingType(DataFile.AndroidSharingType.ANDROID_BLOB_WHEN_AVAILABLE);
+    file.setAndroidSharingChecksumType(DataFile.AndroidSharingChecksumType.SHA2_256);
+    file.setAndroidSharingChecksum("sha256_123" + fileIndex);
+    return file.build();
+  }
+
+  /** Creates a dataFile with relative path. */
+  public static DataFile createRelativePathDataFile(
+      String fileId, int fileIndex, String relativeFilePath) {
+    return DataFile.newBuilder()
+        .setFileId(String.format("%s_%s", fileId, fileIndex))
+        .setUrlToDownload(String.format("https://%s_%s", fileId, fileIndex))
+        .setByteSize(10 + fileIndex)
+        .setChecksum("123" + fileIndex)
+        .setChecksumType(DataFile.ChecksumType.DEFAULT)
+        .setRelativeFilePath(relativeFilePath)
+        .build();
+  }
+
+  public static DataFile createZipFolderDataFile(String fileId, int fileIndex) {
+    DataFile.Builder file = DataFile.newBuilder();
+    file.setFileId(String.format("%s_%s", fileId, fileIndex));
+    file.setUrlToDownload(String.format("https://%s_%s", fileId, fileIndex));
+    file.setByteSize(10 + fileIndex);
+    file.setDownloadedFileChecksum("123" + fileIndex);
+    file.setDownloadTransforms(
+        Transforms.newBuilder()
+            .addTransform(
+                Transform.newBuilder().setZip(ZipTransform.newBuilder().setTarget("*").build())));
+    return file.build();
+  }
+
+  public static DataFileGroupInternal createFileGroupInternalWithDeltaFile(String fileGroupName) {
+    DataFileGroupInternal.Builder fileGroupBuilder =
+        MddTestUtil.createDataFileGroupInternal(fileGroupName, 1).toBuilder();
+    DataFileGroupInternal dataFileGroup =
+        fileGroupBuilder
+            .setFile(0, fileGroupBuilder.getFile(0).toBuilder().addDeltaFile(0, createDeltaFile()))
+            .build();
+    return dataFileGroup;
+  }
+
+  public static DeltaFile createDeltaFile() {
+    return DeltaFile.newBuilder()
+        .setUrlToDownload("http://abc")
+        .setByteSize(10)
+        .setChecksum("ABC")
+        .setDiffDecoder(DiffDecoder.VC_DIFF)
+        .setBaseFile(createDeltaBaseFile("mychecksum"))
+        .build();
+  }
+
+  public static DeltaFile createDeltaFile(String fileId, int fileIndex) {
+    return DeltaFile.newBuilder()
+        .setUrlToDownload(String.format("https://%s_%s", fileId, fileIndex))
+        .setByteSize(10 + fileIndex)
+        .setChecksum("123" + fileIndex)
+        .setDiffDecoder(DiffDecoder.VC_DIFF)
+        .setBaseFile(createDeltaBaseFile("mychecksum" + fileIndex))
+        .build();
+  }
+
+  public static BaseFile createDeltaBaseFile(String checksum) {
+    return BaseFile.newBuilder().setChecksum(checksum).build();
+  }
+
+  public static NewFileKey[] createFileKeysForDataFileGroupInternal(DataFileGroupInternal group) {
+    NewFileKey[] newFileKeys = new NewFileKey[group.getFileCount()];
+    for (int i = 0; i < group.getFileCount(); ++i) {
+      newFileKeys[i] =
+          SharedFilesMetadata.createKeyFromDataFile(
+              group.getFile(i), group.getAllowedReadersEnum());
+    }
+    return newFileKeys;
+  }
+
+  public static void assertMessageEquals(MessageLite expected, MessageLite actual) {
+    assertWithMessage(String.format("EXPECTED: %s\n ACTUAL: %s\n", expected, actual))
+        .that(expected.equals(actual))
+        .isTrue();
+  }
+
+  public static DataFile createDataFileWithDeltaFile(
+      String fileId, int fileIndex, int deltaFileCount) {
+    DataFile.Builder file =
+        DataFile.newBuilder()
+            .setFileId(String.format("%s_%s", fileId, fileIndex))
+            .setUrlToDownload(String.format("https://%s_%s", fileId, fileIndex))
+            .setByteSize(10 + fileIndex)
+            .setChecksum("123" + fileIndex);
+    for (int i = 0; i < deltaFileCount; i++) {
+      file.addDeltaFile(createDeltaFile(fileId + "_delta_" + i, i));
+    }
+    return file.build();
+  }
+
+  /** Executes the shell command {@code cmd}. */
+  public static String runShellCmd(String cmd) throws IOException {
+    final UiDevice uiDevice = UiDevice.getInstance(getInstrumentation());
+    final String result = uiDevice.executeShellCommand(cmd).trim();
+    Log.i(TAG, "Output of '" + cmd + "': '" + result + "'");
+    return result;
+  }
+
+  /** For API-level 19+, it moves the time forward by {@code timeInMillis} milliseconds. */
+  public static void timeTravel(Context context, long timeInMillis) {
+    if (VERSION.SDK_INT == 18) {
+      throw new UnsupportedOperationException(
+          "Time travel does not work on API-level 18 - b/31132161. "
+              + "You need to disable this test on API-level 18. Example: cl/131498720");
+    }
+
+    final long timestampBeforeTravel = System.currentTimeMillis();
+    if (!BackdoorTestUtil.advanceTime(context, timeInMillis)) {
+      // On some API levels (>23) the call returns false even if the time changed. Have a manual
+      // validation that the time changed instead.
+      if (VERSION.SDK_INT >= 23) {
+        assertThat(System.currentTimeMillis()).isAtLeast(timestampBeforeTravel + timeInMillis);
+      } else {
+        throw new IllegalStateException("Time Travel was not successful");
+      }
+    }
+  }
+
+  /**
+   * @return the time (in seconds) that is n days from the current time
+   */
+  public static long daysFromNow(int days) {
+    long thenMillis = System.currentTimeMillis() + DAYS.toMillis(days);
+    return MILLISECONDS.toSeconds(thenMillis);
+  }
+
+  /**
+   * Writes the SharedFile metadata for all the files stored in the {@code fileGroup}, setting for
+   * each of them the status and whether they are currently android-shared based on the array
+   * position.
+   */
+  public static void writeSharedFiles(
+      SharedFilesMetadata sharedFilesMetadata,
+      DataFileGroupInternal fileGroup,
+      List<FileStatus> statuses,
+      List<Boolean> androidShared)
+      throws Exception {
+    int size = fileGroup.getFileCount();
+    NewFileKey[] keys = createFileKeysForDataFileGroupInternal(fileGroup);
+    assertWithMessage("Created file keys must match the given DataFileGroup's file count")
+        .that(keys.length)
+        .isEqualTo(size);
+    assertWithMessage("Given FileStatus list must match the given DataFileGroup's file count")
+        .that(statuses.size())
+        .isEqualTo(size);
+    assertWithMessage("Given androidShared list must match the given DataFileGroup's file count")
+        .that(androidShared.size())
+        .isEqualTo(size);
+    for (int i = 0; i < fileGroup.getFileCount(); i++) {
+      DataFile file = fileGroup.getFile(i);
+      SharedFile.Builder sharedFileBuilder =
+          SharedFile.newBuilder().setFileName(file.getFileId()).setFileStatus(statuses.get(i));
+      if (androidShared.get(i)) {
+        sharedFileBuilder.setAndroidShared(true).setAndroidSharingChecksum("sha256_123" + i);
+      }
+      sharedFilesMetadata.write(keys[i], sharedFileBuilder.build()).get();
+    }
+  }
+
+  /**
+   * Convenience method for {@link MddTestUtil#writeSharedFiles(SharedFilesMetadata,
+   * DataFileGroupInternal, List<FileStatus>, List<Boolean>)} when android shared status is
+   * unnecessary.
+   */
+  public static void writeSharedFiles(
+      SharedFilesMetadata sharedFilesMetadata,
+      DataFileGroupInternal fileGroup,
+      List<FileStatus> statuses)
+      throws Exception {
+    int size = fileGroup.getFileCount();
+    List<Boolean> androidShared = Collections.nCopies(size, false);
+    writeSharedFiles(sharedFilesMetadata, fileGroup, statuses, androidShared);
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/internal/MigrationsTest.java b/javatests/com/google/android/libraries/mobiledatadownload/internal/MigrationsTest.java
new file mode 100644
index 0000000..071879b
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/internal/MigrationsTest.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.android.libraries.mobiledatadownload.SilentFeedback;
+import com.google.android.libraries.mobiledatadownload.internal.Migrations.FileKeyVersion;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public class MigrationsTest {
+
+  private Context context;
+  private SilentFeedback mockSilentFeedback;
+
+  @Before
+  public void setUp() throws Exception {
+    context = ApplicationProvider.getApplicationContext();
+    mockSilentFeedback =
+        new SilentFeedback() {
+          @Override
+          public void send(Throwable throwable, String messageFormat, Object... args) {}
+        };
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    Migrations.clear(context);
+  }
+
+  @Test
+  public void testDefaultVersion() {
+    // Make sure the default version is FileKeyVersion.NewFileKey
+    assertThat(Migrations.getCurrentVersion(context, mockSilentFeedback))
+        .isEqualTo(FileKeyVersion.NEW_FILE_KEY);
+  }
+
+  @Test
+  public void testSetAndGetVersion() {
+    Migrations.setCurrentVersion(context, FileKeyVersion.ADD_DOWNLOAD_TRANSFORM);
+    assertThat(Migrations.getCurrentVersion(context, mockSilentFeedback))
+        .isEqualTo(FileKeyVersion.ADD_DOWNLOAD_TRANSFORM);
+
+    Migrations.setCurrentVersion(context, FileKeyVersion.NEW_FILE_KEY);
+    assertThat(Migrations.getCurrentVersion(context, mockSilentFeedback))
+        .isEqualTo(FileKeyVersion.NEW_FILE_KEY);
+
+    Migrations.setCurrentVersion(context, FileKeyVersion.USE_CHECKSUM_ONLY);
+    assertThat(Migrations.getCurrentVersion(context, mockSilentFeedback))
+        .isEqualTo(FileKeyVersion.USE_CHECKSUM_ONLY);
+  }
+
+  @Test
+  public void testMddFileKeyEnum() {
+    assertThat(FileKeyVersion.getVersion(0)).isEqualTo(FileKeyVersion.NEW_FILE_KEY);
+    assertThat(FileKeyVersion.getVersion(1)).isEqualTo(FileKeyVersion.ADD_DOWNLOAD_TRANSFORM);
+    assertThat(FileKeyVersion.getVersion(2)).isEqualTo(FileKeyVersion.USE_CHECKSUM_ONLY);
+    assertThrows(RuntimeException.class, () -> FileKeyVersion.getVersion(3));
+  }
+
+  @Test
+  public void testCorruptedVersion() {
+    // Set invalid value to file key migration metadata.
+    SharedPreferences migrationPrefs =
+        context.getSharedPreferences("gms_icing_mdd_migrations", Context.MODE_PRIVATE);
+    migrationPrefs.edit().putInt("mdd_file_key_version", 100).commit();
+    // when(mockSilentFeedback.send(anyString(), an))
+    assertThat(Migrations.getCurrentVersion(context, mockSilentFeedback))
+        .isEqualTo(FileKeyVersion.USE_CHECKSUM_ONLY);
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/internal/MobileDataDownloadManagerTest.java b/javatests/com/google/android/libraries/mobiledatadownload/internal/MobileDataDownloadManagerTest.java
new file mode 100644
index 0000000..416a63a
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/internal/MobileDataDownloadManagerTest.java
@@ -0,0 +1,1071 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.util.concurrent.Futures.immediateVoidFuture;
+import static java.util.concurrent.TimeUnit.DAYS;
+import static org.junit.Assert.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isA;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.net.Uri;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.android.libraries.mobiledatadownload.DownloadException;
+import com.google.android.libraries.mobiledatadownload.DownloadException.DownloadResultCode;
+import com.google.android.libraries.mobiledatadownload.FileSource;
+import com.google.android.libraries.mobiledatadownload.SilentFeedback;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.TemporaryUri;
+import com.google.android.libraries.mobiledatadownload.internal.FileGroupManager.GroupDownloadStatus;
+import com.google.android.libraries.mobiledatadownload.internal.Migrations.FileKeyVersion;
+import com.google.android.libraries.mobiledatadownload.internal.experimentation.DownloadStageManager;
+import com.google.android.libraries.mobiledatadownload.internal.experimentation.NoOpDownloadStageManager;
+import com.google.android.libraries.mobiledatadownload.internal.logging.EventLogger;
+import com.google.android.libraries.mobiledatadownload.internal.logging.FileGroupStatsLogger;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LoggingStateStore;
+import com.google.android.libraries.mobiledatadownload.internal.logging.NetworkLogger;
+import com.google.android.libraries.mobiledatadownload.internal.logging.NoOpLoggingState;
+import com.google.android.libraries.mobiledatadownload.internal.logging.StorageLogger;
+import com.google.android.libraries.mobiledatadownload.internal.util.FileGroupUtil;
+import com.google.android.libraries.mobiledatadownload.internal.util.SharedPreferencesUtil;
+import com.google.android.libraries.mobiledatadownload.testing.FakeTimeSource;
+import com.google.android.libraries.mobiledatadownload.testing.TestFlags;
+import com.google.common.base.Optional;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.labs.concurrent.LabsFutures;
+import com.google.common.util.concurrent.AsyncFunction;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.mobiledatadownload.TransformProto.CompressTransform;
+import com.google.mobiledatadownload.TransformProto.Transform;
+import com.google.mobiledatadownload.TransformProto.Transforms;
+import com.google.mobiledatadownload.TransformProto.ZipTransform;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFile;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFile.ChecksumType;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal;
+import com.google.mobiledatadownload.internal.MetadataProto.DownloadConditions;
+import com.google.mobiledatadownload.internal.MetadataProto.DownloadConditions.DeviceNetworkPolicy;
+import com.google.mobiledatadownload.internal.MetadataProto.DownloadConditions.DeviceStoragePolicy;
+import com.google.mobiledatadownload.internal.MetadataProto.GroupKey;
+import com.google.protobuf.ByteString;
+import java.io.IOException;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.LooperMode;
+
+// The LooperMode Mode.PAUSED fixes buggy behavior in the legacy looper implementation that can lead
+// to deadlock in some cases. See documentation at:
+// http://robolectric.org/javadoc/4.3/org/robolectric/annotation/LooperMode.Mode.html for more
+// information.
+@RunWith(RobolectricTestRunner.class)
+@LooperMode(LooperMode.Mode.PAUSED)
+public class MobileDataDownloadManagerTest {
+
+  private static final String TEST_GROUP = "test-group";
+  private static final GroupKey TEST_KEY =
+      FileGroupUtil.createGroupKey(TEST_GROUP, "com.google.android.gms");
+  private static final Executor CONTROL_EXECUTOR = Executors.newCachedThreadPool();
+
+  private static final int DEFAULT_DAYS_SINCE_LAST_LOG = 1;
+
+  // Note: We can't make those android uris static variable since the Uri.parse will fail
+  // with initialization.
+  private final Uri fileUri1 = Uri.parse(MddTestUtil.FILE_URI + "1");
+  private final Uri fileUri2 = Uri.parse(MddTestUtil.FILE_URI + "2");
+
+  private static final String HOST_APP_LOG_SOURCE = "HOST_APP_LOG_SOURCE";
+  private static final String HOST_APP_PRIMES_LOG_SOURCE = "HOST_APP_PRIMES_LOG_SOURCE";
+
+  private Context context;
+  private MobileDataDownloadManager mddManager;
+  private final TestFlags flags = new TestFlags();
+  @Rule public final TemporaryUri tmpUri = new TemporaryUri();
+  @Rule public final MockitoRule mocks = MockitoJUnit.rule();
+
+  @Mock EventLogger mockLogger;
+  @Mock SharedFileManager mockSharedFileManager;
+  @Mock SharedFilesMetadata mockSharedFilesMetadata;
+  @Mock FileGroupManager mockFileGroupManager;
+  @Mock FileGroupsMetadata mockFileGroupsMetadata;
+  @Mock ExpirationHandler mockExpirationHandler;
+  @Mock SilentFeedback mockSilentFeedback;
+  @Mock StorageLogger mockStorageLogger;
+  @Mock FileGroupStatsLogger mockFileGroupStatsLogger;
+  @Mock NetworkLogger mockNetworkLogger;
+
+  private LoggingStateStore loggingStateStore;
+  private DownloadStageManager downloadStageManager;
+  private FakeTimeSource testClock;
+
+  @Captor ArgumentCaptor<List<GroupKey>> groupKeyListCaptor;
+
+  @Before
+  public void setUp() throws Exception {
+    context = ApplicationProvider.getApplicationContext();
+    this.testClock = new FakeTimeSource();
+    testClock.advance(1, DAYS);
+
+    loggingStateStore = new NoOpLoggingState();
+
+    loggingStateStore.getAndResetDaysSinceLastMaintenance().get();
+    testClock.advance(1, DAYS); // The next call into logging state store will return 1
+
+    downloadStageManager = new NoOpDownloadStageManager();
+
+    mddManager =
+        new MobileDataDownloadManager(
+            context,
+            mockLogger,
+            mockSharedFileManager,
+            mockSharedFilesMetadata,
+            mockFileGroupManager,
+            mockFileGroupsMetadata,
+            mockExpirationHandler,
+            mockSilentFeedback,
+            mockStorageLogger,
+            mockFileGroupStatsLogger,
+            mockNetworkLogger,
+            Optional.absent(),
+            CONTROL_EXECUTOR,
+            flags,
+            loggingStateStore,
+            downloadStageManager);
+
+    // Enable migrations so that init doesn't run all migrations before each test.
+    setMigrationState(MobileDataDownloadManager.MDD_MIGRATED_TO_OFFROAD, true);
+    when(mockSharedFileManager.init()).thenReturn(Futures.immediateFuture(true));
+    when(mockSharedFileManager.clear()).thenReturn(Futures.immediateFuture(null));
+    when(mockSharedFileManager.cancelDownload(any())).thenReturn(Futures.immediateFuture(null));
+    when(mockSharedFileManager.cancelDownloadAndClear()).thenReturn(Futures.immediateFuture(null));
+    when(mockSharedFilesMetadata.init()).thenReturn(Futures.immediateFuture(true));
+    when(mockFileGroupsMetadata.init()).thenReturn(Futures.immediateFuture(null));
+    when(mockFileGroupsMetadata.clear()).thenReturn(Futures.immediateFuture(null));
+    when(mockSharedFilesMetadata.clear()).thenReturn(Futures.immediateFuture(null));
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    mddManager.clear().get();
+  }
+
+  @Test
+  public void init_offroadDownloaderMigration() throws Exception {
+    setMigrationState(MobileDataDownloadManager.MDD_MIGRATED_TO_OFFROAD, false);
+
+    mddManager.init().get();
+
+    verify(mockSharedFileManager).clear();
+  }
+
+  @Test
+  public void init_offroadDownloaderMigration_onlyOnce() throws Exception {
+    setMigrationState(MobileDataDownloadManager.MDD_MIGRATED_TO_OFFROAD, false);
+
+    mddManager.init().get();
+    mddManager.init().get();
+
+    verify(mockSharedFileManager, times(1)).clear();
+  }
+
+  @Test
+  public void initDoesNotClearsIfInternalInitSucceeds() throws Exception {
+    when(mockSharedFileManager.init()).thenReturn(Futures.immediateFuture(true));
+
+    mddManager.init().get();
+
+    verify(mockSharedFileManager, times(0)).clear();
+  }
+
+  @Test
+  public void initClearsIfInternalInitFails() throws Exception {
+    when(mockSharedFileManager.init()).thenReturn(Futures.immediateFuture(false));
+
+    mddManager.init().get();
+
+    verify(mockSharedFileManager).clear();
+  }
+
+  @Test
+  public void testAddGroupForDownload() throws Exception {
+    // This tests that the default value of {allowed_readers, allowed_readers_enum} is to allow
+    // access to all 1p google apps.
+    DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1);
+    when(mockFileGroupManager.addGroupForDownload(TEST_KEY, dataFileGroup))
+        .thenReturn(Futures.immediateFuture(true));
+    when(mockFileGroupManager.verifyPendingGroupDownloaded(eq(TEST_KEY), eq(dataFileGroup), any()))
+        .thenReturn(Futures.immediateFuture(GroupDownloadStatus.PENDING));
+
+    assertThat(mddManager.addGroupForDownload(TEST_KEY, dataFileGroup).get()).isTrue();
+    verify(mockFileGroupManager).addGroupForDownload(TEST_KEY, dataFileGroup);
+    verify(mockFileGroupManager)
+        .verifyPendingGroupDownloaded(eq(TEST_KEY), eq(dataFileGroup), any());
+    verifyNoInteractions(mockLogger);
+
+    assertThat(mddManager.addGroupForDownload(TEST_KEY, dataFileGroup).get()).isTrue();
+  }
+
+  @Test
+  public void testAddGroupForDownload_compressedFile() throws Exception {
+    Migrations.setCurrentVersion(context, FileKeyVersion.USE_CHECKSUM_ONLY);
+    DataFileGroupInternal.Builder fileGroupBuilder =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder();
+    DataFileGroupInternal dataFileGroup =
+        fileGroupBuilder
+            .setFile(
+                0,
+                fileGroupBuilder.getFile(0).toBuilder()
+                    .setDownloadedFileChecksum("downloadchecksum")
+                    .setDownloadTransforms(
+                        Transforms.newBuilder()
+                            .addTransform(
+                                Transform.newBuilder()
+                                    .setCompress(CompressTransform.getDefaultInstance()))))
+            .build();
+    when(mockFileGroupManager.addGroupForDownload(TEST_KEY, dataFileGroup))
+        .thenReturn(Futures.immediateFuture(true));
+    when(mockFileGroupManager.verifyPendingGroupDownloaded(eq(TEST_KEY), eq(dataFileGroup), any()))
+        .thenReturn(Futures.immediateFuture(GroupDownloadStatus.PENDING));
+
+    assertThat(mddManager.addGroupForDownload(TEST_KEY, dataFileGroup).get()).isTrue();
+    verify(mockFileGroupManager).addGroupForDownload(TEST_KEY, dataFileGroup);
+    verify(mockFileGroupManager)
+        .verifyPendingGroupDownloaded(eq(TEST_KEY), eq(dataFileGroup), any());
+    verifyNoInteractions(mockLogger);
+
+    assertThat(mddManager.addGroupForDownload(TEST_KEY, dataFileGroup).get()).isTrue();
+  }
+
+  @Test
+  public void testAddGroupForDownload_deltaFile() throws Exception {
+    Migrations.setCurrentVersion(context, FileKeyVersion.USE_CHECKSUM_ONLY);
+    DataFileGroupInternal dataFileGroup =
+        MddTestUtil.createFileGroupInternalWithDeltaFile(TEST_GROUP);
+    when(mockFileGroupManager.addGroupForDownload(TEST_KEY, dataFileGroup))
+        .thenReturn(Futures.immediateFuture(true));
+    when(mockFileGroupManager.verifyPendingGroupDownloaded(eq(TEST_KEY), eq(dataFileGroup), any()))
+        .thenReturn(Futures.immediateFuture(GroupDownloadStatus.PENDING));
+
+    assertThat(mddManager.addGroupForDownload(TEST_KEY, dataFileGroup).get()).isTrue();
+    verify(mockFileGroupManager).addGroupForDownload(TEST_KEY, dataFileGroup);
+    verify(mockFileGroupManager)
+        .verifyPendingGroupDownloaded(eq(TEST_KEY), eq(dataFileGroup), any());
+    verifyNoInteractions(mockLogger);
+
+    assertThat(mddManager.addGroupForDownload(TEST_KEY, dataFileGroup).get()).isTrue();
+  }
+
+  @Test
+  public void testAddGroupForDownload_downloadImmediate() throws Exception {
+    // This tests that the default value of {allowed_readers, allowed_readers_enum} is to allow
+    // access to all 1p google apps.
+    DataFileGroupInternal dataFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder()
+            .setVariantId("testVariant")
+            .setBuildId(10)
+            .build();
+    when(mockFileGroupManager.addGroupForDownload(TEST_KEY, dataFileGroup))
+        .thenReturn(Futures.immediateFuture(true));
+    when(mockFileGroupManager.verifyPendingGroupDownloaded(eq(TEST_KEY), eq(dataFileGroup), any()))
+        .thenReturn(Futures.immediateFuture(GroupDownloadStatus.DOWNLOADED));
+
+    assertThat(mddManager.addGroupForDownload(TEST_KEY, dataFileGroup).get()).isTrue();
+    verify(mockFileGroupManager).addGroupForDownload(TEST_KEY, dataFileGroup);
+    verify(mockFileGroupManager)
+        .verifyPendingGroupDownloaded(eq(TEST_KEY), eq(dataFileGroup), any());
+    verify(mockLogger)
+        .logEventSampled(
+            0,
+            TEST_GROUP,
+            /* fileGroupVersionNumber= */ 0,
+            /* buildId= */ dataFileGroup.getBuildId(),
+            /* variantId= */ dataFileGroup.getVariantId());
+
+    assertThat(mddManager.addGroupForDownload(TEST_KEY, dataFileGroup).get()).isTrue();
+  }
+
+  @Test
+  public void testAddGroupForDownload_throwsIOException() throws Exception {
+    DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1);
+    when(mockFileGroupManager.addGroupForDownload(TEST_KEY, dataFileGroup))
+        .thenThrow(new IOException());
+
+    // assertThat(mddManager.addGroupForDownload(TEST_KEY, dataFileGroup).get()).isFalse();
+    ExecutionException exception =
+        assertThrows(
+            ExecutionException.class,
+            () -> mddManager.addGroupForDownload(TEST_KEY, dataFileGroup).get());
+    assertThat(exception).hasCauseThat().isInstanceOf(IOException.class);
+    verify(mockFileGroupManager).addGroupForDownload(TEST_KEY, dataFileGroup);
+    verify(mockSilentFeedback).send(isA(IOException.class), isA(String.class));
+    verifyNoInteractions(mockLogger);
+  }
+
+  @Test
+  public void testAddGroupForDownload_throwsUninstalledAppException() throws Exception {
+    DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1);
+    when(mockFileGroupManager.addGroupForDownload(TEST_KEY, dataFileGroup))
+        .thenThrow(new UninstalledAppException());
+
+    // assertThat(mddManager.addGroupForDownload(TEST_KEY, dataFileGroup).get()).isFalse();
+    ExecutionException exception =
+        assertThrows(
+            ExecutionException.class,
+            () -> mddManager.addGroupForDownload(TEST_KEY, dataFileGroup).get());
+    assertThat(exception).hasCauseThat().isInstanceOf(UninstalledAppException.class);
+    verify(mockFileGroupManager).addGroupForDownload(TEST_KEY, dataFileGroup);
+    verifyNoInteractions(mockLogger);
+  }
+
+  @Test
+  public void testAddGroupForDownload_throwsExpiredFileGroupException() throws Exception {
+    DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1);
+    when(mockFileGroupManager.addGroupForDownload(TEST_KEY, dataFileGroup))
+        .thenThrow(new ExpiredFileGroupException());
+
+    // assertThat(mddManager.addGroupForDownload(TEST_KEY, dataFileGroup).get()).isFalse();
+    ExecutionException exception =
+        assertThrows(
+            ExecutionException.class,
+            () -> mddManager.addGroupForDownload(TEST_KEY, dataFileGroup).get());
+    assertThat(exception).hasCauseThat().isInstanceOf(ExpiredFileGroupException.class);
+    verify(mockFileGroupManager).addGroupForDownload(TEST_KEY, dataFileGroup);
+    verifyNoInteractions(mockLogger);
+  }
+
+  @Test
+  public void testAddGroupForDownload_multipleCallsSameGroup() throws Exception {
+    DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1);
+    when(mockFileGroupManager.addGroupForDownload(TEST_KEY, dataFileGroup))
+        .thenReturn(Futures.immediateFuture(true), Futures.immediateFuture(false));
+    when(mockFileGroupManager.verifyPendingGroupDownloaded(
+            eq(TEST_KEY), any(DataFileGroupInternal.class), any()))
+        .thenReturn(Futures.immediateFuture(GroupDownloadStatus.PENDING));
+
+    assertThat(mddManager.addGroupForDownload(TEST_KEY, dataFileGroup).get()).isTrue();
+    assertThat(mddManager.addGroupForDownload(TEST_KEY, dataFileGroup).get()).isTrue();
+    verify(mockFileGroupManager, times(2)).addGroupForDownload(TEST_KEY, dataFileGroup);
+    verify(mockFileGroupManager, times(1))
+        .verifyPendingGroupDownloaded(eq(TEST_KEY), eq(dataFileGroup), any());
+    verifyNoInteractions(mockExpirationHandler);
+    verifyNoInteractions(mockLogger);
+  }
+
+  @Test
+  public void testAddGroupForDownload_isValidGroup() throws Exception {
+    DataFileGroupInternal dataFileGroup =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder()
+            .setGroupName("")
+            .setVariantId("testVariant")
+            .setBuildId(10)
+            .build();
+    assertThat(mddManager.addGroupForDownload(TEST_KEY, dataFileGroup).get()).isFalse();
+    verifyNoInteractions(mockFileGroupManager);
+
+    verify(mockLogger)
+        .logEventSampled(
+            0,
+            "",
+            /* fileGroupVersionNumber= */ 0,
+            /* buildId= */ dataFileGroup.getBuildId(),
+            /* variantId= */ dataFileGroup.getVariantId());
+  }
+
+  @Test
+  public void testAddGroupForDownload_noChecksum() throws Exception {
+    DataFileGroupInternal.Builder fileGroupBuilder =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder();
+    DataFileGroupInternal dataFileGroup =
+        fileGroupBuilder
+            .setFile(
+                0,
+                fileGroupBuilder.getFile(0).toBuilder()
+                    .setChecksumType(ChecksumType.NONE)
+                    .setChecksum(""))
+            .build();
+
+    ArgumentCaptor<DataFileGroupInternal> dataFileGroupCaptor =
+        ArgumentCaptor.forClass(DataFileGroupInternal.class);
+
+    when(mockFileGroupManager.addGroupForDownload(eq(TEST_KEY), dataFileGroupCaptor.capture()))
+        .thenReturn(Futures.immediateFuture(true));
+    when(mockFileGroupManager.verifyPendingGroupDownloaded(
+            eq(TEST_KEY), any(DataFileGroupInternal.class), any()))
+        .thenReturn(Futures.immediateFuture(GroupDownloadStatus.PENDING));
+
+    assertThat(mddManager.addGroupForDownload(TEST_KEY, dataFileGroup).get()).isTrue();
+    verifyNoInteractions(mockLogger);
+
+    DataFileGroupInternal capturedDataFileGroup = dataFileGroupCaptor.getValue();
+
+    assertThat(capturedDataFileGroup.getFileCount()).isEqualTo(1);
+    DataFile dataFile = capturedDataFileGroup.getFile(0);
+    // Checksum of the Url.
+    assertThat(dataFile.getChecksum()).isEqualTo("0d79849a839d83fbc53e3bfe794ec38a305b7220");
+  }
+
+  @Test
+  public void testAddGroupForDownload_noChecksumWithZipTransform() throws Exception {
+    DataFileGroupInternal.Builder fileGroupBuilder =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder();
+    DataFileGroupInternal dataFileGroup =
+        fileGroupBuilder
+            .setFile(
+                0,
+                fileGroupBuilder.getFile(0).toBuilder()
+                    .setChecksumType(ChecksumType.NONE)
+                    .setChecksum("")
+                    .setDownloadedFileChecksum("")
+                    .setDownloadTransforms(
+                        Transforms.newBuilder()
+                            .addTransform(
+                                Transform.newBuilder()
+                                    .setZip(ZipTransform.newBuilder().setTarget("*")))))
+            .build();
+
+    ArgumentCaptor<DataFileGroupInternal> dataFileGroupCaptor =
+        ArgumentCaptor.forClass(DataFileGroupInternal.class);
+
+    when(mockFileGroupManager.addGroupForDownload(eq(TEST_KEY), dataFileGroupCaptor.capture()))
+        .thenReturn(Futures.immediateFuture(true));
+    when(mockFileGroupManager.verifyPendingGroupDownloaded(
+            eq(TEST_KEY), any(DataFileGroupInternal.class), any()))
+        .thenReturn(Futures.immediateFuture(GroupDownloadStatus.PENDING));
+
+    assertThat(mddManager.addGroupForDownload(TEST_KEY, dataFileGroup).get()).isTrue();
+    verifyNoInteractions(mockLogger);
+
+    DataFileGroupInternal capturedDataFileGroup = dataFileGroupCaptor.getValue();
+
+    assertThat(capturedDataFileGroup.getFileCount()).isEqualTo(1);
+    DataFile dataFile = capturedDataFileGroup.getFile(0);
+    // Checksum of url is propagated to downloaded file checksum if data file has zip transform.
+    assertThat(dataFile.getChecksum()).isEmpty();
+    assertThat(dataFile.getDownloadedFileChecksum())
+        .isEqualTo("0d79849a839d83fbc53e3bfe794ec38a305b7220");
+  }
+
+  @Test
+  public void testAddGroupForDownload_noChecksumAndNotSetChecksumType() throws Exception {
+    DataFileGroupInternal.Builder fileGroupBuilder =
+        MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder();
+    // Not setting ChecksumType.NONE
+    DataFileGroupInternal dataFileGroup =
+        fileGroupBuilder
+            .setFile(0, fileGroupBuilder.getFile(0).toBuilder().setChecksum(""))
+            .build();
+
+    assertThat(mddManager.addGroupForDownload(TEST_KEY, dataFileGroup).get()).isFalse();
+    verify(mockLogger)
+        .logEventSampled(
+            0, TEST_GROUP, /* fileGroupVersionNumber= */ 0, /* buildId= */ 0, /* variantId= */ "");
+    verifyNoInteractions(mockFileGroupManager);
+  }
+
+  @Test
+  public void testAddGroupForDownload_sideloadedFile_onlyWhenSideloadingIsEnabled()
+      throws Exception {
+    // Create sideloaded group
+    DataFileGroupInternal sideloadedGroup =
+        DataFileGroupInternal.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .addFile(
+                DataFile.newBuilder()
+                    .setFileId("sideloaded_file")
+                    .setUrlToDownload("file:/test")
+                    .setChecksumType(DataFile.ChecksumType.NONE)
+                    .build())
+            .build();
+
+    when(mockFileGroupManager.addGroupForDownload(eq(TEST_KEY), any()))
+        .thenReturn(Futures.immediateFuture(true));
+    when(mockFileGroupManager.verifyPendingGroupDownloaded(eq(TEST_KEY), any(), any()))
+        .thenReturn(Futures.immediateFuture(GroupDownloadStatus.DOWNLOADED));
+
+    {
+      // Force sideloading off
+      flags.enableSideloading = Optional.of(false);
+
+      assertThat(mddManager.addGroupForDownload(TEST_KEY, sideloadedGroup).get()).isFalse();
+    }
+
+    {
+      // Force sideloading on
+      flags.enableSideloading = Optional.of(true);
+
+      assertThat(mddManager.addGroupForDownload(TEST_KEY, sideloadedGroup).get()).isTrue();
+    }
+  }
+
+  @Test
+  public void testRemoveFileGroup() throws Exception {
+    GroupKey groupKey =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setOwnerPackage(context.getPackageName())
+            .build();
+    when(mockFileGroupManager.removeFileGroup(eq(groupKey), eq(false)))
+        .thenReturn(Futures.immediateFuture(null /* Void */));
+
+    mddManager.removeFileGroup(groupKey, /* pendingOnly= */ false).get();
+
+    verify(mockFileGroupManager).removeFileGroup(groupKey, /* pendingOnly= */ false);
+    verifyNoMoreInteractions(mockFileGroupManager);
+    verifyNoInteractions(mockLogger);
+  }
+
+  @Test
+  public void testRemoveFileGroup_onFailure() throws Exception {
+    GroupKey groupKey =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setOwnerPackage(context.getPackageName())
+            .build();
+    doThrow(new IOException())
+        .when(mockFileGroupManager)
+        .removeFileGroup(groupKey, /* pendingOnly= */ false);
+
+    ExecutionException ex =
+        assertThrows(
+            ExecutionException.class,
+            mddManager.removeFileGroup(groupKey, /* pendingOnly= */ false)::get);
+    assertThat(ex).hasCauseThat().isInstanceOf(IOException.class);
+
+    verify(mockFileGroupManager).removeFileGroup(groupKey, /* pendingOnly= */ false);
+    verifyNoMoreInteractions(mockFileGroupManager);
+    verifyNoInteractions(mockLogger);
+  }
+
+  @Test
+  public void testRemoveFileGroups() throws Exception {
+    GroupKey groupKey1 =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setOwnerPackage(context.getPackageName())
+            .build();
+    GroupKey groupKey2 =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP + "_2")
+            .setOwnerPackage(context.getPackageName())
+            .build();
+
+    when(mockFileGroupManager.removeFileGroups(groupKeyListCaptor.capture()))
+        .thenReturn(Futures.immediateVoidFuture());
+
+    mddManager.removeFileGroups(ImmutableList.of(groupKey1, groupKey2)).get();
+
+    verify(mockFileGroupManager).removeFileGroups(anyList());
+    List<GroupKey> groupKeyListCapture = groupKeyListCaptor.getValue();
+    assertThat(groupKeyListCapture).hasSize(2);
+    assertThat(groupKeyListCapture).contains(groupKey1);
+    assertThat(groupKeyListCapture).contains(groupKey2);
+  }
+
+  @Test
+  public void testRemoveFileGroups_onFailure() throws Exception {
+    GroupKey groupKey1 =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setOwnerPackage(context.getPackageName())
+            .build();
+    GroupKey groupKey2 =
+        GroupKey.newBuilder()
+            .setGroupName(TEST_GROUP + "_2")
+            .setOwnerPackage(context.getPackageName())
+            .build();
+
+    when(mockFileGroupManager.removeFileGroups(groupKeyListCaptor.capture()))
+        .thenReturn(Futures.immediateFailedFuture(new Exception("Test failure")));
+
+    ExecutionException ex =
+        assertThrows(
+            ExecutionException.class,
+            () -> mddManager.removeFileGroups(ImmutableList.of(groupKey1, groupKey2)).get());
+    assertThat(ex).hasMessageThat().contains("Test failure");
+
+    verify(mockFileGroupManager).removeFileGroups(anyList());
+    List<GroupKey> groupKeyListCapture = groupKeyListCaptor.getValue();
+    assertThat(groupKeyListCapture).hasSize(2);
+    assertThat(groupKeyListCapture).contains(groupKey1);
+    assertThat(groupKeyListCapture).contains(groupKey2);
+  }
+
+  @Test
+  public void testGetDownloadedGroup() throws Exception {
+    DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1);
+    when(mockFileGroupManager.getFileGroup(TEST_KEY, true))
+        .thenReturn(Futures.immediateFuture(dataFileGroup));
+
+    DataFileGroupInternal completedDataFileGroup = mddManager.getFileGroup(TEST_KEY, true).get();
+    MddTestUtil.assertMessageEquals(dataFileGroup, completedDataFileGroup);
+    verifyNoInteractions(mockLogger);
+  }
+
+  @Test
+  public void testGetDataFileUri() throws Exception {
+    DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+
+    when(mockFileGroupManager.getOnDeviceUri(dataFileGroup.getFile(0), dataFileGroup))
+        .thenReturn(Futures.immediateFuture(fileUri1));
+    when(mockFileGroupManager.getOnDeviceUri(dataFileGroup.getFile(1), dataFileGroup))
+        .thenReturn(Futures.immediateFuture(fileUri2));
+
+    assertThat(mddManager.getDataFileUri(dataFileGroup.getFile(0), dataFileGroup).get())
+        .isEqualTo(fileUri1);
+    assertThat(mddManager.getDataFileUri(dataFileGroup.getFile(1), dataFileGroup).get())
+        .isEqualTo(fileUri2);
+  }
+
+  @Test
+  public void testGetDataFileUri_readTransform() throws Exception {
+    DataFileGroupInternal dataFileGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 2);
+
+    Transforms compressTransform =
+        Transforms.newBuilder()
+            .addTransform(
+                Transform.newBuilder().setCompress(CompressTransform.getDefaultInstance()))
+            .build();
+    dataFileGroup =
+        dataFileGroup.toBuilder()
+            .setFile(0, dataFileGroup.getFile(0).toBuilder().setReadTransforms(compressTransform))
+            .build();
+
+    when(mockFileGroupManager.getOnDeviceUri(dataFileGroup.getFile(0), dataFileGroup))
+        .thenReturn(Futures.immediateFuture(fileUri1));
+    when(mockFileGroupManager.getOnDeviceUri(dataFileGroup.getFile(1), dataFileGroup))
+        .thenReturn(Futures.immediateFuture(fileUri2));
+
+    assertThat(mddManager.getDataFileUri(dataFileGroup.getFile(0), dataFileGroup).get())
+        .isEqualTo(fileUri1.buildUpon().encodedFragment("transform=compress").build());
+    assertThat(mddManager.getDataFileUri(dataFileGroup.getFile(1), dataFileGroup).get())
+        .isEqualTo(fileUri2);
+  }
+
+  @Test
+  public void testGetDataFileUri_relativeFilePaths() throws Exception {
+    DataFile relativePathFile = MddTestUtil.createRelativePathDataFile("file", 1, "test");
+    DataFileGroupInternal testFileGroup =
+        DataFileGroupInternal.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setPreserveFilenamesAndIsolateFiles(true)
+            .addFile(relativePathFile)
+            .build();
+
+    Uri symlinkedUri =
+        FileGroupUtil.getIsolatedFileUri(
+            context, Optional.absent(), relativePathFile, testFileGroup);
+
+    when(mockFileGroupManager.getOnDeviceUri(testFileGroup.getFile(0), testFileGroup))
+        .thenReturn(Futures.immediateFuture(fileUri1));
+    when(mockFileGroupManager.getAndVerifyIsolatedFileUri(
+            fileUri1, relativePathFile, testFileGroup))
+        .thenReturn(symlinkedUri);
+
+    assertThat(mddManager.getDataFileUri(relativePathFile, testFileGroup).get())
+        .isEqualTo(symlinkedUri);
+  }
+
+  @Test
+  public void testGetDataFileUri_whenSymlinkRequiredButNotPresent_returnsNull() throws Exception {
+    DataFile relativePathFile = MddTestUtil.createRelativePathDataFile("file", 1, "test");
+    DataFileGroupInternal testFileGroup =
+        DataFileGroupInternal.newBuilder()
+            .setGroupName(TEST_GROUP)
+            .setPreserveFilenamesAndIsolateFiles(true)
+            .addFile(relativePathFile)
+            .build();
+
+    when(mockFileGroupManager.getOnDeviceUri(testFileGroup.getFile(0), testFileGroup))
+        .thenReturn(Futures.immediateFuture(fileUri1));
+    when(mockFileGroupManager.getAndVerifyIsolatedFileUri(
+            fileUri1, relativePathFile, testFileGroup))
+        .thenThrow(new IOException("test failure"));
+
+    assertThat(mddManager.getDataFileUri(relativePathFile, testFileGroup).get()).isNull();
+  }
+
+  @Test
+  public void testImportFiles_failed() throws Exception {
+    ImmutableList<DataFile> updatedDataFileList =
+        ImmutableList.of(
+            MddTestUtil.createDataFile("inline-file", 0).toBuilder()
+                .setUrlToDownload("inlinefile:sha1:abcdef")
+                .build());
+    ImmutableMap<String, FileSource> inlineFileMap =
+        ImmutableMap.of(
+            "inline-file", FileSource.ofByteString(ByteString.copyFromUtf8("TEST_CONTENT")));
+    when(mockFileGroupManager.importFilesIntoFileGroup(
+            eq(TEST_KEY), anyLong(), any(), any(), any(), any(), any()))
+        .thenReturn(Futures.immediateFailedFuture(new Exception("Test failure")));
+
+    ExecutionException ex =
+        assertThrows(
+            ExecutionException.class,
+            () ->
+                mddManager
+                    .importFiles(
+                        TEST_KEY,
+                        1,
+                        "testvariant",
+                        updatedDataFileList,
+                        inlineFileMap,
+                        Optional.absent(),
+                        noCustomValidation())
+                    .get());
+
+    assertThat(ex).hasMessageThat().contains("Test failure");
+    verify(mockFileGroupManager)
+        .importFilesIntoFileGroup(
+            eq(TEST_KEY),
+            anyLong(),
+            any(),
+            eq(updatedDataFileList),
+            eq(inlineFileMap),
+            any(),
+            any());
+  }
+
+  @Test
+  public void testImportFiles_succeeds() throws Exception {
+    ImmutableList<DataFile> updatedDataFileList =
+        ImmutableList.of(
+            MddTestUtil.createDataFile("inline-file", 0).toBuilder()
+                .setUrlToDownload("inlinefile:sha1:abcdef")
+                .build());
+    ImmutableMap<String, FileSource> inlineFileMap =
+        ImmutableMap.of(
+            "inline-file", FileSource.ofByteString(ByteString.copyFromUtf8("TEST_CONTENT")));
+
+    when(mockFileGroupManager.importFilesIntoFileGroup(
+            eq(TEST_KEY), anyLong(), any(), any(), any(), any(), any()))
+        .thenReturn(immediateVoidFuture());
+
+    mddManager
+        .importFiles(
+            TEST_KEY,
+            1,
+            "testvariant",
+            updatedDataFileList,
+            inlineFileMap,
+            Optional.absent(),
+            noCustomValidation())
+        .get();
+
+    verify(mockFileGroupManager)
+        .importFilesIntoFileGroup(
+            eq(TEST_KEY),
+            anyLong(),
+            any(),
+            eq(updatedDataFileList),
+            eq(inlineFileMap),
+            any(),
+            any());
+  }
+
+  @Test
+  public void testDownloadPendingGroup_failed() {
+    when(mockFileGroupManager.downloadFileGroup(eq(TEST_KEY), isNull(), any()))
+        .thenReturn(
+            Futures.immediateFailedFuture(
+                DownloadException.builder()
+                    .setDownloadResultCode(DownloadResultCode.UNKNOWN_ERROR)
+                    .setMessage("Fail")
+                    .build()));
+
+    ListenableFuture<DataFileGroupInternal> downloadFuture =
+        mddManager.downloadFileGroup(TEST_KEY, Optional.absent(), noCustomValidation());
+    assertThrows(ExecutionException.class, downloadFuture::get);
+    DownloadException unused =
+        LabsFutures.getFailureCauseAs(downloadFuture, DownloadException.class);
+
+    verify(mockFileGroupManager).downloadFileGroup(eq(TEST_KEY), isNull(), any());
+  }
+
+  @Test
+  public void testDownloadPendingGroup_downloadCondition_absent() throws Exception {
+    DataFileGroupInternal pendingGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1);
+
+    when(mockFileGroupManager.downloadFileGroup(eq(TEST_KEY), isNull(), any()))
+        .thenReturn(Futures.immediateFuture(pendingGroup));
+
+    assertThat(
+            mddManager.downloadFileGroup(TEST_KEY, Optional.absent(), noCustomValidation()).get())
+        .isEqualTo(pendingGroup);
+
+    verify(mockFileGroupManager).downloadFileGroup(eq(TEST_KEY), isNull(), any());
+  }
+
+  @Test
+  public void testDownloadPendingGroup_downloadCondition_present() throws Exception {
+    DataFileGroupInternal pendingGroup = MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1);
+
+    Optional<DownloadConditions> downloadConditionsOptional =
+        Optional.of(
+            DownloadConditions.newBuilder()
+                .setDeviceNetworkPolicy(DeviceNetworkPolicy.DOWNLOAD_ON_ANY_NETWORK)
+                .setDeviceStoragePolicy(DeviceStoragePolicy.BLOCK_DOWNLOAD_IN_LOW_STORAGE)
+                .build());
+
+    when(mockFileGroupManager.downloadFileGroup(
+            eq(TEST_KEY), eq(downloadConditionsOptional.get()), any()))
+        .thenReturn(Futures.immediateFuture(pendingGroup));
+
+    assertThat(
+            mddManager
+                .downloadFileGroup(TEST_KEY, downloadConditionsOptional, noCustomValidation())
+                .get())
+        .isEqualTo(pendingGroup);
+
+    verify(mockFileGroupManager)
+        .downloadFileGroup(eq(TEST_KEY), eq(downloadConditionsOptional.get()), any());
+  }
+
+  @Test
+  public void testDownloadAllPendingGroups() throws Exception {
+    when(mockFileGroupManager.scheduleAllPendingGroupsForDownload(eq(true), any()))
+        .thenReturn(Futures.immediateFuture(null));
+
+    mddManager.downloadAllPendingGroups(true, noCustomValidation()).get();
+
+    verify(mockLogger).logEventSampled(0);
+    verify(mockFileGroupManager).scheduleAllPendingGroupsForDownload(eq(true), any());
+    verifyNoMoreInteractions(mockLogger);
+  }
+
+  @Test
+  public void testVerifyPendingGroups() throws Exception {
+    when(mockFileGroupManager.verifyAllPendingGroupsDownloaded(any()))
+        .thenReturn(Futures.immediateFuture(null));
+
+    mddManager.verifyAllPendingGroups(noCustomValidation()).get();
+
+    verify(mockFileGroupManager).verifyAllPendingGroupsDownloaded(any());
+    verify(mockLogger).logEventSampled(0);
+    verifyNoMoreInteractions(mockLogger);
+  }
+
+  @Test
+  public void testMaintenance_mddFileExpiration() throws Exception {
+    setupMaintenanceTasks();
+
+    mddManager.maintenance().get();
+
+    verify(mockFileGroupManager).deleteUninstalledAppGroups();
+
+    verify(mockExpirationHandler).updateExpiration();
+
+    verify(mockFileGroupStatsLogger).log(anyInt());
+    verify(mockLogger).logEventSampled(0);
+  }
+
+  @Test
+  public void testMaintenance_logStorage() throws Exception {
+    setupMaintenanceTasks();
+
+    mddManager.maintenance().get();
+
+    verify(mockFileGroupStatsLogger).log(anyInt());
+  }
+
+  @Test
+  public void testMaintenance_logNetwork() throws Exception {
+    setupMaintenanceTasks();
+
+    mddManager.maintenance().get();
+    verify(mockNetworkLogger).log();
+  }
+
+  @Test
+  public void maintenance_triggerSync_absentSpe() throws Exception {
+    mddManager =
+        new MobileDataDownloadManager(
+            context,
+            mockLogger,
+            mockSharedFileManager,
+            mockSharedFilesMetadata,
+            mockFileGroupManager,
+            mockFileGroupsMetadata,
+            mockExpirationHandler,
+            mockSilentFeedback,
+            mockStorageLogger,
+            mockFileGroupStatsLogger,
+            mockNetworkLogger,
+            Optional.absent(),
+            CONTROL_EXECUTOR,
+            flags,
+            loggingStateStore,
+            downloadStageManager);
+
+    setupMaintenanceTasks();
+
+    mddManager.maintenance().get();
+
+    // With absent SPE, no triggerSync was called.
+    verify(mockFileGroupManager, never()).triggerSyncAllPendingGroups();
+  }
+
+  @Test
+  public void testMaintenance_deleteRemovedAccountGroups() throws Exception {
+    setupMaintenanceTasks();
+
+    flags.mddDeleteGroupsRemovedAccounts = Optional.of(true);
+
+    mddManager.maintenance().get();
+    verify(mockFileGroupManager).deleteRemovedAccountGroups();
+  }
+
+  void setupMaintenanceTasks() {
+    flags.enableDaysSinceLastMaintenanceTracking = Optional.of(true);
+
+    when(mockStorageLogger.logStorageStats(anyInt())).thenReturn(Futures.immediateVoidFuture());
+    when(mockExpirationHandler.updateExpiration()).thenReturn(Futures.immediateVoidFuture());
+    when(mockFileGroupStatsLogger.log(anyInt())).thenReturn(Futures.immediateVoidFuture());
+    when(mockNetworkLogger.log()).thenReturn(Futures.immediateVoidFuture());
+    when(mockFileGroupManager.logAndDeleteForMissingSharedFiles())
+        .thenReturn(Futures.immediateVoidFuture());
+    when(mockFileGroupManager.deleteUninstalledAppGroups())
+        .thenReturn(Futures.immediateVoidFuture());
+    when(mockFileGroupManager.deleteRemovedAccountGroups())
+        .thenReturn(Futures.immediateVoidFuture());
+    when(mockFileGroupManager.triggerSyncAllPendingGroups()).thenReturn(immediateVoidFuture());
+
+    when(mockFileGroupManager.verifyAndAttemptToRepairIsolatedFiles())
+        .thenReturn(immediateVoidFuture());
+  }
+
+  @Test
+  public void testClear() throws Exception {
+    mddManager.clear().get();
+
+    verify(mockSharedFileManager).cancelDownloadAndClear();
+    verifyNoInteractions(mockLogger);
+  }
+
+  @Test
+  public void testCheckResetTrigger_resetTrigger_noIncrement() throws Exception {
+    setSavedResetValue(1);
+    flags.mddResetTrigger = Optional.of(1);
+
+    mddManager.checkResetTrigger().get();
+    verify(mockSharedFileManager, never()).clear();
+    verifyNoInteractions(mockLogger);
+    // saved reset value should not have changed
+    checkSavedResetValue(1);
+    verifyNoInteractions(mockLogger);
+  }
+
+  @Test
+  public void testCheckResetTrigger_resetTrigger_singleIncrement() throws Exception {
+    setSavedResetValue(1);
+    flags.mddResetTrigger = Optional.of(2);
+
+    mddManager.checkResetTrigger().get();
+    verify(mockSharedFileManager).cancelDownloadAndClear();
+    verify(mockLogger).logEventSampled(0);
+    // saved reset value should be set to 2
+    checkSavedResetValue(2);
+    verifyNoMoreInteractions(mockLogger);
+  }
+
+  @Test
+  public void testCheckResetTrigger_resetTrigger_singleIncrementMultipleChecks() throws Exception {
+    setSavedResetValue(1);
+    flags.mddResetTrigger = Optional.of(2);
+
+    mddManager.checkResetTrigger().get();
+    // The second check should have no effect - clear should only be called once.
+    mddManager.checkResetTrigger().get();
+    verify(mockSharedFileManager).cancelDownloadAndClear();
+    verify(mockLogger).logEventSampled(0);
+    // saved reset value should be set to 2
+    checkSavedResetValue(2);
+    verifyNoMoreInteractions(mockLogger);
+  }
+
+  @Test
+  public void testCheckResetTrigger_resetTrigger_multipleIncrementMultipleChecks()
+      throws Exception {
+    setSavedResetValue(1);
+    flags.mddResetTrigger = Optional.of(2);
+
+    mddManager.checkResetTrigger().get();
+
+    flags.mddResetTrigger = Optional.of(3);
+
+    mddManager.checkResetTrigger().get();
+
+    verify(mockSharedFileManager, times(2)).cancelDownloadAndClear();
+    verify(mockLogger, times(2)).logEventSampled(0);
+    // saved reset value should be set to 2
+    checkSavedResetValue(3);
+    verifyNoMoreInteractions(mockLogger);
+  }
+
+  private void setMigrationState(String key, boolean value) {
+    SharedPreferences sharedPreferences =
+        SharedPreferencesUtil.getSharedPreferences(
+            context, MobileDataDownloadManager.MDD_MANAGER_METADATA, Optional.absent());
+    sharedPreferences.edit().putBoolean(key, value).commit();
+  }
+
+  private void setSavedResetValue(int value) {
+    SharedPreferences prefs =
+        SharedPreferencesUtil.getSharedPreferences(
+            context, MobileDataDownloadManager.MDD_MANAGER_METADATA, Optional.absent());
+    SharedPreferences.Editor editor = prefs.edit();
+    editor.putInt(MobileDataDownloadManager.RESET_TRIGGER, value);
+    editor.commit();
+  }
+
+  private void checkSavedResetValue(int expected) {
+    SharedPreferences prefs =
+        SharedPreferencesUtil.getSharedPreferences(
+            context, MobileDataDownloadManager.MDD_MANAGER_METADATA, Optional.absent());
+    assertThat(prefs.getInt(MobileDataDownloadManager.RESET_TRIGGER, expected - 1))
+        .isEqualTo(expected);
+  }
+
+  private AsyncFunction<DataFileGroupInternal, Boolean> noCustomValidation() {
+    return unused -> Futures.immediateFuture(true);
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/internal/SharedFileManagerTest.java b/javatests/com/google/android/libraries/mobiledatadownload/internal/SharedFileManagerTest.java
new file mode 100644
index 0000000..30d5682
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/internal/SharedFileManagerTest.java
@@ -0,0 +1,1000 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal;
+
+import static com.google.android.libraries.mobiledatadownload.internal.SharedFileManager.MDD_SHARED_FILE_MANAGER_METADATA;
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isA;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.net.Uri;
+import android.os.Build;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.android.libraries.mobiledatadownload.DownloadException;
+import com.google.android.libraries.mobiledatadownload.DownloadException.DownloadResultCode;
+import com.google.android.libraries.mobiledatadownload.FileSource;
+import com.google.android.libraries.mobiledatadownload.SilentFeedback;
+import com.google.android.libraries.mobiledatadownload.delta.DeltaDecoder;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.AndroidFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.backends.AndroidUri;
+import com.google.android.libraries.mobiledatadownload.file.backends.BlobUri;
+import com.google.android.libraries.mobiledatadownload.file.spi.Backend;
+import com.google.android.libraries.mobiledatadownload.file.transforms.CompressTransform;
+import com.google.android.libraries.mobiledatadownload.internal.Migrations.FileKeyVersion;
+import com.google.android.libraries.mobiledatadownload.internal.downloader.DownloaderCallbackImpl;
+import com.google.android.libraries.mobiledatadownload.internal.downloader.MddFileDownloader;
+import com.google.android.libraries.mobiledatadownload.internal.logging.EventLogger;
+import com.google.android.libraries.mobiledatadownload.internal.util.DirectoryUtil;
+import com.google.android.libraries.mobiledatadownload.internal.util.FileGroupUtil;
+import com.google.android.libraries.mobiledatadownload.internal.util.SharedPreferencesUtil;
+import com.google.android.libraries.mobiledatadownload.monitor.DownloadProgressMonitor;
+import com.google.android.libraries.mobiledatadownload.testing.TestFlags;
+import com.google.common.base.Optional;
+import com.google.common.collect.ImmutableList;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFile;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal.AllowedReaders;
+import com.google.mobiledatadownload.internal.MetadataProto.DeltaFile;
+import com.google.mobiledatadownload.internal.MetadataProto.DeltaFile.DiffDecoder;
+import com.google.mobiledatadownload.internal.MetadataProto.DownloadConditions;
+import com.google.mobiledatadownload.internal.MetadataProto.FileStatus;
+import com.google.mobiledatadownload.internal.MetadataProto.GroupKey;
+import com.google.mobiledatadownload.internal.MetadataProto.NewFileKey;
+import com.google.mobiledatadownload.internal.MetadataProto.SharedFile;
+import com.google.protobuf.ByteString;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.ParameterizedRobolectricTestRunner;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameter;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameters;
+import org.robolectric.annotation.Config;
+import org.robolectric.util.ReflectionHelpers;
+
+@RunWith(ParameterizedRobolectricTestRunner.class)
+@Config(shadows = {})
+public class SharedFileManagerTest {
+
+  @Parameters(
+      name =
+          "runAfterMigratedToAddDownloadTransform = {0}, runAfterMigratedToUseChecksumOnly = {1}")
+  public static Collection<Object[]> parameters() {
+    return Arrays.asList(new Object[][] {{false, false}, {true, false}, {true, true}});
+  }
+
+  @Parameter(value = 0)
+  public boolean runAfterMigratedToAddDownloadTransform;
+
+  @Parameter(value = 1)
+  public boolean runAfterMigratedToUseChecksumOnly;
+
+  private static final DownloadConditions DOWNLOAD_CONDITIONS =
+      DownloadConditions.getDefaultInstance();
+
+  private static final int TRAFFIC_TAG = 1000;
+
+  private Context context;
+  private SynchronousFileStorage fileStorage;
+  private static final long FILE_GROUP_EXPIRATION_DATE_SECS = 10;
+  private static final String TEST_GROUP = "test-group";
+  private static final int VERSION_NUMBER = 7;
+  private static final long BUILD_ID = 0;
+  private static final DataFileGroupInternal FILE_GROUP =
+      MddTestUtil.createDataFileGroupInternal(TEST_GROUP, 1).toBuilder()
+          .setFileGroupVersionNumber(VERSION_NUMBER)
+          .build();
+  private static final GroupKey GROUP_KEY =
+      FileGroupUtil.createGroupKey(FILE_GROUP.getGroupName(), FILE_GROUP.getOwnerPackage());
+  private static final Executor CONTROL_EXECUTOR =
+      MoreExecutors.newSequentialExecutor(Executors.newCachedThreadPool());
+  private SharedFileManager sfm;
+
+  // This is currently not mocked as the class was split from SharedFileManager, and this ensures
+  // that all tests still run the same way.
+  private SharedFilesMetadata sharedFilesMetadata;
+  private File publicDirectory;
+  private File privateDirectory;
+  private Optional<DeltaDecoder> deltaDecoder;
+  private final TestFlags flags = new TestFlags();
+  @Mock SilentFeedback mockSilentFeedback;
+  @Mock MddFileDownloader mockDownloader;
+  @Mock DownloadProgressMonitor mockDownloadMonitor;
+  @Mock EventLogger eventLogger;
+  @Mock FileGroupsMetadata fileGroupsMetadata;
+  @Mock Backend mockBackend;
+
+  @Rule public final MockitoRule mocks = MockitoJUnit.rule();
+
+  @Before
+  public void setUp() throws Exception {
+
+    context = ApplicationProvider.getApplicationContext();
+
+    when(mockBackend.name()).thenReturn("blobstore");
+    fileStorage =
+        new SynchronousFileStorage(
+            Arrays.asList(AndroidFileBackend.builder(context).build(), mockBackend),
+            ImmutableList.of(new CompressTransform()));
+
+    sharedFilesMetadata =
+        new SharedPreferencesSharedFilesMetadata(
+            context, mockSilentFeedback, Optional.absent(), flags);
+
+    deltaDecoder = Optional.absent();
+    sfm =
+        new SharedFileManager(
+            context,
+            mockSilentFeedback,
+            sharedFilesMetadata,
+            fileStorage,
+            mockDownloader,
+            deltaDecoder,
+            Optional.of(mockDownloadMonitor),
+            eventLogger,
+            flags,
+            fileGroupsMetadata,
+            Optional.absent(),
+            CONTROL_EXECUTOR);
+
+    // TODO(b/117571083): Replace with fileStorage API.
+    File downloadDirectory =
+        new File(context.getFilesDir(), DirectoryUtil.MDD_STORAGE_MODULE + "/" + "shared");
+    publicDirectory = new File(downloadDirectory, DirectoryUtil.MDD_STORAGE_ALL_GOOGLE_APPS);
+    privateDirectory =
+        new File(downloadDirectory, DirectoryUtil.MDD_STORAGE_ONLY_GOOGLE_PLAY_SERVICES);
+    publicDirectory.mkdirs();
+    privateDirectory.mkdirs();
+
+    if (runAfterMigratedToUseChecksumOnly) {
+      Migrations.setCurrentVersion(context, FileKeyVersion.USE_CHECKSUM_ONLY);
+    } else if (runAfterMigratedToAddDownloadTransform) {
+      Migrations.setCurrentVersion(context, FileKeyVersion.ADD_DOWNLOAD_TRANSFORM);
+    }
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    SharedPreferencesUtil.getSharedPreferences(
+            context, MDD_SHARED_FILE_MANAGER_METADATA, Optional.absent())
+        .edit()
+        .clear()
+        .commit();
+
+    // Reset to avoid exception in the call below.
+    fileStorage.deleteRecursively(
+        DirectoryUtil.getBaseDownloadDirectory(context, Optional.absent()));
+  }
+
+  @Test
+  public void init_migrateToNewKey_enabled_v23ToV24() throws Exception {
+    Migrations.setMigratedToNewFileKey(context, false);
+
+    assertThat(Migrations.isMigratedToNewFileKey(context)).isFalse();
+
+    SharedPreferences sfmMetadata =
+        SharedPreferencesUtil.getSharedPreferences(
+            context, MDD_SHARED_FILE_MANAGER_METADATA, Optional.absent());
+    sfmMetadata
+        .edit()
+        .putBoolean(SharedFileManager.PREFS_KEY_MIGRATED_TO_NEW_FILE_KEY, true)
+        .commit();
+
+    assertThat(sfm.init().get()).isTrue();
+
+    assertThat(Migrations.isMigratedToNewFileKey(context)).isTrue();
+  }
+
+  @Test
+  public void testSubscribeAndUnsubscribeSingleFile() throws Exception {
+    DataFile file = MddTestUtil.createDataFile("fileId", 0);
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+
+    assertThat(sfm.reserveFileEntry(newFileKey).get()).isTrue();
+
+    // Make sure the file entry was stored.
+    assertThat(sharedFilesMetadata.read(newFileKey)).isNotNull();
+
+    // Unsubscribe and ensure entry for file was deleted.
+    assertThat(sfm.removeFileEntry(newFileKey).get()).isTrue();
+    assertThat(sharedFilesMetadata.read(newFileKey).get()).isNull();
+  }
+
+  @Test
+  public void testMultipleSubscribes() throws Exception {
+    DataFile file = MddTestUtil.createDataFile("fileId", 0);
+
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+    assertThat(sfm.reserveFileEntry(newFileKey).get()).isTrue();
+    assertThat(sfm.reserveFileEntry(newFileKey).get()).isTrue();
+
+    // Unsubscribe once. It should not matter how many subscribes were previously called. An
+    // unsubscribe should remove the entry.
+    assertThat(sfm.removeFileEntry(newFileKey).get()).isTrue();
+    assertThat(sharedFilesMetadata.read(newFileKey).get()).isNull();
+  }
+
+  @Test
+  public void testRemoveFileEntry_nonexistentFile() throws Exception {
+    DataFile file = MddTestUtil.createDataFile("fileId", 0);
+
+    // Try to unsubscribe from a file that was never subscribed to and ensure that this won't add
+    // an entry for the file.
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+    assertThat(sfm.removeFileEntry(newFileKey).get()).isFalse();
+    assertThat(sharedFilesMetadata.read(newFileKey).get()).isNull();
+
+    verifyNoInteractions(mockDownloader);
+  }
+
+  @Test
+  public void testRemoveFileEntry_partialDownloadFileNotDeleted() throws Exception {
+    DataFile file = MddTestUtil.createDataFile("fileId", 0);
+
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+    assertThat(sfm.reserveFileEntry(newFileKey).get()).isTrue();
+
+    // Download the file, but do not update shared prefs to say it is downloaded.
+    File onDeviceFile = simulateDownload(file, getLastFileName(), AllowedReaders.ALL_GOOGLE_APPS);
+
+    assertThat(onDeviceFile.exists()).isTrue();
+
+    Uri uri = sfm.getOnDeviceUri(newFileKey).get();
+
+    // Ensure that deregister has actually deleted the file on disk.
+    assertThat(sfm.removeFileEntry(newFileKey).get()).isTrue();
+    assertThat(sharedFilesMetadata.read(newFileKey).get()).isNull();
+    // The partial download file should be deleted
+    assertThat(onDeviceFile.exists()).isTrue();
+
+    verify(mockDownloader).stopDownloading(uri);
+  }
+
+  @Test
+  public void testStartImport_startsInlineFileCopy() throws Exception {
+    FileSource inlineSource = FileSource.ofByteString(ByteString.copyFromUtf8("TEST_CONTENT"));
+    DataFile file =
+        MddTestUtil.createDataFile("fileId", 0).toBuilder()
+            .setUrlToDownload("inlinefile:123")
+            .build();
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+
+    assertThat(sfm.reserveFileEntry(newFileKey).get()).isTrue();
+    Uri fileUri = sfm.getOnDeviceUri(newFileKey).get();
+    when(fileGroupsMetadata.read(GROUP_KEY)).thenReturn(Futures.immediateFuture(FILE_GROUP));
+    when(mockDownloader.startCopying(
+            eq(fileUri),
+            eq(file.getUrlToDownload()),
+            eq(file.getByteSize()),
+            eq(DOWNLOAD_CONDITIONS),
+            isA(DownloaderCallbackImpl.class),
+            any()))
+        .thenReturn(Futures.immediateVoidFuture());
+
+    sfm.startImport(GROUP_KEY, file, newFileKey, DOWNLOAD_CONDITIONS, inlineSource).get();
+
+    SharedFile sharedFile = sharedFilesMetadata.read(newFileKey).get();
+    assertThat(sharedFile.getFileStatus()).isEqualTo(FileStatus.DOWNLOAD_IN_PROGRESS);
+  }
+
+  @Test
+  public void testStartImport_whenFileAlreadyDownloaded_returnsEarly() throws Exception {
+    FileSource inlineSource = FileSource.ofByteString(ByteString.copyFromUtf8("TEST_CONTENT"));
+    DataFile file =
+        MddTestUtil.createDataFile("fileId", 0).toBuilder()
+            .setUrlToDownload("inlinefile:123")
+            .build();
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+
+    assertThat(sfm.reserveFileEntry(newFileKey).get()).isTrue();
+    File onDeviceFile = simulateDownload(file, getLastFileName(), AllowedReaders.ALL_GOOGLE_APPS);
+    changeFileStatusAs(newFileKey, FileStatus.DOWNLOAD_COMPLETE);
+
+    // File is already downloaded, so we should return early
+    sfm.startImport(GROUP_KEY, file, newFileKey, DOWNLOAD_CONDITIONS, inlineSource).get();
+    onDeviceFile.delete();
+
+    verify(mockDownloader, times(0)).startCopying(any(), any(), anyInt(), any(), any(), any());
+  }
+
+  @Test
+  public void testStartImport_whenUnreservedEntry_throws() throws Exception {
+    FileSource inlineSource = FileSource.ofByteString(ByteString.copyFromUtf8("TEST_CONTENT"));
+    DataFile file =
+        MddTestUtil.createDataFile("fileId", 0).toBuilder()
+            .setUrlToDownload("inlinefile:123")
+            .build();
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+
+    ExecutionException ex =
+        Assert.assertThrows(
+            ExecutionException.class,
+            () ->
+                sfm.startImport(GROUP_KEY, file, newFileKey, DOWNLOAD_CONDITIONS, inlineSource)
+                    .get());
+
+    assertThat(ex).hasCauseThat().isInstanceOf(DownloadException.class);
+    DownloadException dex = (DownloadException) ex.getCause();
+
+    assertThat(dex.getDownloadResultCode())
+        .isEqualTo(DownloadResultCode.SHARED_FILE_NOT_FOUND_ERROR);
+  }
+
+  @Test
+  public void testStartImport_whenNotInlineFileUrlScheme_throws() throws Exception {
+    FileSource inlineSource = FileSource.ofByteString(ByteString.copyFromUtf8("TEST_CONTENT"));
+    DataFile file = MddTestUtil.createDataFile("fileId", 0);
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+
+    ExecutionException ex =
+        Assert.assertThrows(
+            ExecutionException.class,
+            () ->
+                sfm.startImport(GROUP_KEY, file, newFileKey, DOWNLOAD_CONDITIONS, inlineSource)
+                    .get());
+
+    assertThat(ex).hasCauseThat().isInstanceOf(DownloadException.class);
+    DownloadException dex = (DownloadException) ex.getCause();
+    assertThat(dex.getDownloadResultCode())
+        .isEqualTo(DownloadResultCode.INVALID_INLINE_FILE_URL_SCHEME);
+  }
+
+  @Test
+  public void testNotifyCurrentSize_partialDownloadFile() throws Exception {
+    DataFile file = MddTestUtil.createDataFile("fileId", 0);
+
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+    assertThat(sfm.reserveFileEntry(newFileKey).get()).isTrue();
+
+    // Download the file, but do not update shared prefs to say it is downloaded.
+    File onDeviceFile = simulateDownload(file, getLastFileName(), AllowedReaders.ALL_GOOGLE_APPS);
+    Uri fileUri = sfm.getOnDeviceUri(newFileKey).get();
+
+    when(fileGroupsMetadata.read(GROUP_KEY)).thenReturn(Futures.immediateFuture(FILE_GROUP));
+    when(mockDownloader.startDownloading(
+            eq(GROUP_KEY),
+            eq(VERSION_NUMBER),
+            eq(BUILD_ID),
+            eq(fileUri),
+            eq(file.getUrlToDownload()),
+            eq(file.getByteSize()),
+            eq(DOWNLOAD_CONDITIONS),
+            isA(DownloaderCallbackImpl.class),
+            anyInt(),
+            anyList()))
+        .thenReturn(Futures.immediateFuture(null));
+
+    sfm.startDownload(
+            GROUP_KEY,
+            file,
+            newFileKey,
+            DOWNLOAD_CONDITIONS,
+            TRAFFIC_TAG,
+            /*extraHttpHeaders = */ ImmutableList.of())
+        .get();
+
+    SharedFile sharedFile = sharedFilesMetadata.read(newFileKey).get();
+    assertThat(sharedFile.getFileStatus()).isEqualTo(FileStatus.DOWNLOAD_IN_PROGRESS);
+    verify(mockDownloadMonitor).notifyCurrentFileSize(TEST_GROUP, onDeviceFile.length());
+  }
+
+  @Test
+  public void testDontDeleteUnsubscribedFiles() throws Exception {
+    DataFile datafile = MddTestUtil.createDataFile("fileId", 0);
+
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(datafile, AllowedReaders.ALL_GOOGLE_APPS);
+    assertThat(sfm.reserveFileEntry(newFileKey).get()).isTrue();
+
+    // "download" the file and update sharedPrefs
+    File onDeviceFile =
+        simulateDownload(datafile, getLastFileName(), AllowedReaders.ALL_GOOGLE_APPS);
+    changeFileStatusAs(newFileKey, FileStatus.DOWNLOAD_COMPLETE);
+
+    assertThat(onDeviceFile.exists()).isTrue();
+    Uri uri = sfm.getOnDeviceUri(newFileKey).get();
+
+    // Ensure that deregister has actually deleted the file on disk.
+    assertThat(sfm.removeFileEntry(newFileKey).get()).isTrue();
+    assertThat(sharedFilesMetadata.read(newFileKey).get()).isNull();
+    // The file should not be deleted by the SFM because deletion is handled by ExpirationHandler.
+    assertThat(onDeviceFile.exists()).isTrue();
+
+    verify(mockDownloader).stopDownloading(uri);
+  }
+
+  @Test
+  public void testStartDownload_whenInlineFileUrlScheme_fails() throws Exception {
+    DataFile inlineFile =
+        MddTestUtil.createDataFile("inlineFileId", 0).toBuilder()
+            .setUrlToDownload("inlinefile:abc")
+            .setChecksum("abc")
+            .build();
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(inlineFile, AllowedReaders.ALL_GOOGLE_APPS);
+
+    ExecutionException ex =
+        Assert.assertThrows(
+            ExecutionException.class,
+            () ->
+                sfm.startDownload(
+                        GROUP_KEY,
+                        inlineFile,
+                        newFileKey,
+                        DOWNLOAD_CONDITIONS,
+                        TRAFFIC_TAG,
+                        /* extraHttpHeaders = */ ImmutableList.of())
+                    .get());
+    assertThat(ex).hasCauseThat().isInstanceOf(DownloadException.class);
+    DownloadException dex = (DownloadException) ex.getCause();
+    assertThat(dex.getDownloadResultCode())
+        .isEqualTo(DownloadResultCode.INVALID_INLINE_FILE_URL_SCHEME);
+  }
+
+  @Test
+  public void testStartDownload_unsubscribedFile() {
+    DataFile file = MddTestUtil.createDataFile("fileId", 0);
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+
+    ExecutionException ex =
+        Assert.assertThrows(
+            ExecutionException.class,
+            () ->
+                sfm.startDownload(
+                        GROUP_KEY,
+                        file,
+                        newFileKey,
+                        DOWNLOAD_CONDITIONS,
+                        TRAFFIC_TAG,
+                        /*extraHttpHeaders = */ ImmutableList.of())
+                    .get());
+    assertThat(ex).hasCauseThat().isInstanceOf(DownloadException.class);
+    assertThat(ex).hasMessageThat().contains("SHARED_FILE_NOT_FOUND_ERROR");
+
+    verifyNoInteractions(mockDownloader);
+  }
+
+  @Test
+  public void testStartDownload_newFile() throws Exception {
+    DataFile file = MddTestUtil.createDataFile("fileId", 0);
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+
+    assertThat(sfm.reserveFileEntry(newFileKey).get()).isTrue();
+    Uri fileUri = sfm.getOnDeviceUri(newFileKey).get();
+    when(fileGroupsMetadata.read(GROUP_KEY)).thenReturn(Futures.immediateFuture(FILE_GROUP));
+    when(mockDownloader.startDownloading(
+            eq(GROUP_KEY),
+            eq(VERSION_NUMBER),
+            eq(BUILD_ID),
+            eq(fileUri),
+            eq(file.getUrlToDownload()),
+            eq(file.getByteSize()),
+            eq(DOWNLOAD_CONDITIONS),
+            isA(DownloaderCallbackImpl.class),
+            anyInt(),
+            anyList()))
+        .thenReturn(Futures.immediateFuture(null));
+
+    sfm.startDownload(
+            GROUP_KEY,
+            file,
+            newFileKey,
+            DOWNLOAD_CONDITIONS,
+            TRAFFIC_TAG,
+            /* extraHttpHeaders = */ ImmutableList.of())
+        .get();
+
+    SharedFile sharedFile = sharedFilesMetadata.read(newFileKey).get();
+    assertThat(sharedFile.getFileStatus()).isEqualTo(FileStatus.DOWNLOAD_IN_PROGRESS);
+  }
+
+  @Test
+  public void testStartDownload_downloadedFile() throws Exception {
+    DataFile file = MddTestUtil.createDataFile("fileId", 0);
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+
+    assertThat(sfm.reserveFileEntry(newFileKey).get()).isTrue();
+    File onDeviceFile = simulateDownload(file, getLastFileName(), AllowedReaders.ALL_GOOGLE_APPS);
+    changeFileStatusAs(newFileKey, FileStatus.DOWNLOAD_COMPLETE);
+
+    // The file is already downloaded, so we should just return DOWNLOADED.
+    sfm.startDownload(
+            GROUP_KEY,
+            file,
+            newFileKey,
+            DOWNLOAD_CONDITIONS,
+            TRAFFIC_TAG,
+            /* extraHttpHeaders = */ ImmutableList.of())
+        .get();
+    onDeviceFile.delete();
+
+    verify(mockDownloadMonitor).notifyCurrentFileSize(TEST_GROUP, file.getByteSize());
+    verifyNoInteractions(mockDownloader);
+  }
+
+  @Test
+  public void testVerifyDownload_nonExistentFile() throws Exception {
+    DataFile file = MddTestUtil.createDataFile("fileId", 0);
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+
+    ExecutionException ex =
+        Assert.assertThrows(ExecutionException.class, () -> sfm.getFileStatus(newFileKey).get());
+    assertThat(ex).hasCauseThat().isInstanceOf(SharedFileMissingException.class);
+    ex = Assert.assertThrows(ExecutionException.class, () -> sfm.getOnDeviceUri(newFileKey).get());
+    assertThat(ex).hasCauseThat().isInstanceOf(SharedFileMissingException.class);
+
+    verifyNoInteractions(mockDownloader);
+  }
+
+  @Test
+  public void testVerifyDownload_fileDownloaded() throws Exception {
+    DataFile file = MddTestUtil.createDataFile("fileId", 0);
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+
+    assertThat(sfm.reserveFileEntry(newFileKey).get()).isTrue();
+    simulateDownload(file, getLastFileName(), AllowedReaders.ALL_GOOGLE_APPS);
+    changeFileStatusAs(newFileKey, FileStatus.DOWNLOAD_COMPLETE);
+
+    // VerifyDownload should update the onDeviceUri fields for storedFile.
+    assertThat(sfm.getFileStatus(newFileKey).get()).isEqualTo(FileStatus.DOWNLOAD_COMPLETE);
+  }
+
+  @Test
+  public void testVerifyDownload_downloadNotAttempted() throws Exception {
+    DataFile file = MddTestUtil.createDataFile("fileId", 0);
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+
+    assertThat(sfm.reserveFileEntry(newFileKey).get()).isTrue();
+
+    assertThat(sfm.getFileStatus(newFileKey).get()).isEqualTo(FileStatus.SUBSCRIBED);
+
+    // getOnDeviceUri will populate the onDeviceUri even download was not attempted.
+    assertThat(sfm.getOnDeviceUri(newFileKey).toString()).isNotEmpty();
+
+    verifyNoInteractions(mockDownloader);
+  }
+
+  @Test
+  public void testVerifyDownload_alreadyDownloaded() throws Exception {
+    DataFile file = MddTestUtil.createDataFile("fileId", 0);
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+
+    assertThat(sfm.reserveFileEntry(newFileKey).get()).isTrue();
+    File onDeviceFile = simulateDownload(file, getLastFileName(), AllowedReaders.ALL_GOOGLE_APPS);
+    changeFileStatusAs(newFileKey, FileStatus.DOWNLOAD_COMPLETE);
+
+    assertThat(sfm.getFileStatus(newFileKey).get()).isEqualTo(FileStatus.DOWNLOAD_COMPLETE);
+    assertThat(sfm.getOnDeviceUri(newFileKey).get())
+        .isEqualTo(AndroidUri.builder(context).fromFile(onDeviceFile).build());
+
+    onDeviceFile.delete();
+    verifyNoInteractions(mockDownloader);
+  }
+
+  @Test
+  public void findNoDeltaFile_withNoBaseFileOnDevice() throws Exception {
+    DataFile file = MddTestUtil.createDataFileWithDeltaFile("fileId", 0, 3);
+    assertThat(
+            sfm.findFirstDeltaFileWithBaseFileDownloaded(file, AllowedReaders.ALL_GOOGLE_APPS)
+                .get())
+        .isNull();
+  }
+
+  @Test
+  public void findExpectedDeltaFile_withDifferentReaderBaseFile() throws Exception {
+    DataFile file = MddTestUtil.createDataFileWithDeltaFile("fileId", 0, 3);
+    markBaseFileDownloaded(
+        file.getDeltaFile(1).getBaseFile().getChecksum(), AllowedReaders.ALL_GOOGLE_APPS);
+    assertThat(
+            sfm.findFirstDeltaFileWithBaseFileDownloaded(
+                    file, AllowedReaders.ONLY_GOOGLE_PLAY_SERVICES)
+                .get())
+        .isNull();
+  }
+
+  @Test
+  public void findNoDeltaFile_whenDecoderNotSupported() throws Exception {
+    deltaDecoder =
+        Optional.of(
+            new DeltaDecoder() {
+              @Override
+              public void decode(Uri baseUri, Uri deltaUri, Uri targetUri) {
+                throw new UnsupportedOperationException("No delta decoder provided.");
+              }
+
+              @Override
+              public DiffDecoder getDecoderName() {
+                return DiffDecoder.UNSPECIFIED;
+              }
+            });
+    sfm =
+        new SharedFileManager(
+            context,
+            mockSilentFeedback,
+            sharedFilesMetadata,
+            fileStorage,
+            mockDownloader,
+            deltaDecoder,
+            Optional.of(mockDownloadMonitor),
+            eventLogger,
+            flags,
+            fileGroupsMetadata,
+            Optional.absent(),
+            CONTROL_EXECUTOR);
+
+    DataFile file = MddTestUtil.createDataFileWithDeltaFile("fileId", 0, 3);
+    markBaseFileDownloaded(
+        file.getDeltaFile(1).getBaseFile().getChecksum(), AllowedReaders.ALL_GOOGLE_APPS);
+    DeltaFile deltaFile =
+        sfm.findFirstDeltaFileWithBaseFileDownloaded(file, AllowedReaders.ALL_GOOGLE_APPS).get();
+    assertThat(deltaFile).isNull();
+  }
+
+  private void markBaseFileDownloaded(String checksum, AllowedReaders allowedReaders)
+      throws Exception {
+    NewFileKey fileKey =
+        NewFileKey.newBuilder().setChecksum(checksum).setAllowedReaders(allowedReaders).build();
+    assertThat(sfm.reserveFileEntry(fileKey).get()).isTrue();
+    changeFileStatusAs(fileKey, FileStatus.DOWNLOAD_COMPLETE);
+  }
+
+  @Test
+  public void testClear() throws Exception {
+    // Create two files, one downloaded and the other currently being downloaded.
+    DataFile downloadedFile = MddTestUtil.createDataFile("file", 0);
+    DataFile registeredFile = MddTestUtil.createDataFile("registered-file", 0);
+
+    NewFileKey downloadedKey =
+        SharedFilesMetadata.createKeyFromDataFile(downloadedFile, AllowedReaders.ALL_GOOGLE_APPS);
+    NewFileKey registeredKey =
+        SharedFilesMetadata.createKeyFromDataFile(registeredFile, AllowedReaders.ALL_GOOGLE_APPS);
+
+    assertThat(sfm.reserveFileEntry(downloadedKey).get()).isTrue();
+    File onDevicePublicFile =
+        simulateDownload(downloadedFile, getLastFileName(), AllowedReaders.ALL_GOOGLE_APPS);
+    changeFileStatusAs(downloadedKey, FileStatus.DOWNLOAD_COMPLETE);
+
+    assertThat(sfm.reserveFileEntry(registeredKey).get()).isTrue();
+
+    assertThat(sfm.getOnDeviceUri(downloadedKey).get())
+        .isEqualTo(AndroidUri.builder(context).fromFile(onDevicePublicFile).build());
+    assertThat(onDevicePublicFile.exists()).isTrue();
+
+    // Clear should delete all files in our directories.
+    sfm.clear().get();
+
+    assertThat(onDevicePublicFile.exists()).isFalse();
+  }
+
+  @Test
+  public void testClear_sdkLessthanR() throws Exception {
+    // Set scenario: SDK < R, enableAndroidFileSharing flag ON
+    ReflectionHelpers.setStaticField(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.Q);
+
+    // Create two files, one downloaded and the other currently being downloaded.
+    DataFile downloadedFile = MddTestUtil.createDataFile("file", 0);
+    DataFile registeredFile = MddTestUtil.createDataFile("registered-file", 0);
+
+    NewFileKey downloadedKey =
+        SharedFilesMetadata.createKeyFromDataFile(downloadedFile, AllowedReaders.ALL_GOOGLE_APPS);
+    NewFileKey registeredKey =
+        SharedFilesMetadata.createKeyFromDataFile(registeredFile, AllowedReaders.ALL_GOOGLE_APPS);
+
+    assertThat(sfm.reserveFileEntry(downloadedKey).get()).isTrue();
+    File onDevicePublicFile =
+        simulateDownload(downloadedFile, getLastFileName(), AllowedReaders.ALL_GOOGLE_APPS);
+    changeFileStatusAs(downloadedKey, FileStatus.DOWNLOAD_COMPLETE);
+
+    assertThat(sfm.reserveFileEntry(registeredKey).get()).isTrue();
+
+    assertThat(sfm.getOnDeviceUri(downloadedKey).get())
+        .isEqualTo(AndroidUri.builder(context).fromFile(onDevicePublicFile).build());
+    assertThat(onDevicePublicFile.exists()).isTrue();
+
+    // Clear should delete all files in our directories.
+    sfm.clear().get();
+
+    assertThat(onDevicePublicFile.exists()).isFalse();
+    verify(mockBackend, never()).deleteFile(any());
+    verify(eventLogger, never()).logEventSampled(0);
+  }
+
+  @Test
+  public void testClear_withAndroidSharedFiles() throws Exception {
+    // Set scenario: SDK >= R
+    ReflectionHelpers.setStaticField(Build.VERSION.class, "SDK_INT", Build.VERSION_CODES.R);
+
+    // Create three files, one downloaded, the other currently being downloaded and one shared with
+    // the Android Blob Sharing Service.
+    DataFile downloadedFile = MddTestUtil.createDataFile("file", /* fileIndex = */ 0);
+    DataFile registeredFile = MddTestUtil.createDataFile("registered-file", /* fileIndex = */ 1);
+    DataFile sharedFile = MddTestUtil.createSharedDataFile("shared-file", /* fileIndex = */ 2);
+
+    NewFileKey downloadedKey =
+        SharedFilesMetadata.createKeyFromDataFile(downloadedFile, AllowedReaders.ALL_GOOGLE_APPS);
+    NewFileKey registeredKey =
+        SharedFilesMetadata.createKeyFromDataFile(registeredFile, AllowedReaders.ALL_GOOGLE_APPS);
+    NewFileKey sharedFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(sharedFile, AllowedReaders.ALL_GOOGLE_APPS);
+
+    assertThat(sfm.reserveFileEntry(downloadedKey).get()).isTrue();
+    File onDevicePublicFile =
+        simulateDownload(downloadedFile, getLastFileName(), AllowedReaders.ALL_GOOGLE_APPS);
+    changeFileStatusAs(downloadedKey, FileStatus.DOWNLOAD_COMPLETE);
+
+    assertThat(sfm.reserveFileEntry(registeredKey).get()).isTrue();
+
+    assertThat(sfm.reserveFileEntry(sharedFileKey).get()).isTrue();
+    assertThat(
+            sfm.setAndroidSharedDownloadedFileEntry(
+                    sharedFileKey,
+                    sharedFile.getAndroidSharingChecksum(),
+                    FILE_GROUP_EXPIRATION_DATE_SECS)
+                .get())
+        .isTrue();
+    Uri allLeasesUri = DirectoryUtil.getBlobStoreAllLeasesUri(context);
+
+    assertThat(sfm.getOnDeviceUri(downloadedKey).get())
+        .isEqualTo(AndroidUri.builder(context).fromFile(onDevicePublicFile).build());
+    assertThat(onDevicePublicFile.exists()).isTrue();
+
+    // Clear should delete all files in our directories.
+    sfm.clear().get();
+
+    assertThat(onDevicePublicFile.exists()).isFalse();
+    verify(mockBackend).deleteFile(allLeasesUri);
+
+    verify(eventLogger).logEventSampled(0);
+  }
+
+  @Test
+  public void cancelDownload_onDownloadedFile() throws Exception {
+    DataFile downloadedFile = MddTestUtil.createDataFile("downloaded-file", 0);
+    NewFileKey downloadedKey =
+        SharedFilesMetadata.createKeyFromDataFile(downloadedFile, AllowedReaders.ALL_GOOGLE_APPS);
+
+    assertThat(sfm.reserveFileEntry(downloadedKey).get()).isTrue();
+    changeFileStatusAs(downloadedKey, FileStatus.DOWNLOAD_COMPLETE);
+
+    // Calling cancelDownload on downloaded file is a no-op.
+    sfm.cancelDownload(downloadedKey).get();
+
+    verifyNoInteractions(mockDownloader);
+  }
+
+  @Test
+  public void cancelDownload_onRegisteredFile() throws Exception {
+    DataFile registeredFile = MddTestUtil.createDataFile("registered-file", 0);
+    NewFileKey registeredKey =
+        SharedFilesMetadata.createKeyFromDataFile(registeredFile, AllowedReaders.ALL_GOOGLE_APPS);
+
+    assertThat(sfm.reserveFileEntry(registeredKey).get()).isTrue();
+
+    // Calling cancelDownload on registered file will stop the download.
+    sfm.cancelDownload(registeredKey).get();
+
+    SharedFile sharedFile = sharedFilesMetadata.read(registeredKey).get();
+    assertThat(sharedFile).isNotNull();
+    Uri onDeviceUri =
+        DirectoryUtil.getOnDeviceUri(
+            context,
+            registeredKey.getAllowedReaders(),
+            sharedFile.getFileName(),
+            registeredFile.getChecksum(),
+            mockSilentFeedback,
+            /* instanceId= */ Optional.absent(),
+            false);
+    verify(mockDownloader).stopDownloading(onDeviceUri);
+  }
+
+  @Test
+  public void testGetSharedFile() throws Exception {
+    DataFile file = MddTestUtil.createDataFile("fileId", /* fileIndex = */ 0);
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+
+    assertThat(sfm.reserveFileEntry(newFileKey).get()).isTrue();
+
+    SharedFile sharedFile = sfm.getSharedFile(newFileKey).get();
+    SharedFile expectedSharedFile = sharedFilesMetadata.read(newFileKey).get();
+
+    assertThat(sharedFile).isNotNull();
+    assertThat(sharedFile).isEqualTo(expectedSharedFile);
+  }
+
+  @Test
+  public void testGetSharedFile_nonExistentFile() throws Exception {
+    DataFile file = MddTestUtil.createDataFile("fileId", 0);
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+
+    ExecutionException ex =
+        Assert.assertThrows(ExecutionException.class, () -> sfm.getSharedFile(newFileKey).get());
+    assertThat(ex).hasCauseThat().isInstanceOf(SharedFileMissingException.class);
+  }
+
+  @Test
+  public void testUpdateMaxExpirationDateSecs() throws Exception {
+    DataFile file = MddTestUtil.createDataFile("fileId", 0);
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+
+    assertThat(sfm.reserveFileEntry(newFileKey).get()).isTrue();
+    SharedFile sharedFileBeforeUpdate = sharedFilesMetadata.read(newFileKey).get();
+    SharedFile expectedSharedFileAfterUpdate =
+        SharedFile.newBuilder(sharedFileBeforeUpdate)
+            .setMaxExpirationDateSecs(FILE_GROUP_EXPIRATION_DATE_SECS)
+            .build();
+
+    assertThat(sharedFileBeforeUpdate).isNotNull();
+    assertThat(sharedFileBeforeUpdate).isNotEqualTo(expectedSharedFileAfterUpdate);
+
+    // updateMaxExpirationDateSecs updates maxExpirationDateSecs
+    assertThat(sfm.updateMaxExpirationDateSecs(newFileKey, FILE_GROUP_EXPIRATION_DATE_SECS).get())
+        .isTrue();
+    SharedFile sharedFileAfterUpdate = sharedFilesMetadata.read(newFileKey).get();
+    assertThat(sharedFileAfterUpdate).isNotNull();
+    assertThat(sharedFileAfterUpdate).isEqualTo(expectedSharedFileAfterUpdate);
+
+    // updateMaxExpirationDateSecs doesn't update maxExpirationDateSecs
+    assertThat(
+            sfm.updateMaxExpirationDateSecs(newFileKey, FILE_GROUP_EXPIRATION_DATE_SECS - 1).get())
+        .isTrue();
+    SharedFile sharedFileAfterSecondUpdate = sharedFilesMetadata.read(newFileKey).get();
+    assertThat(sharedFileAfterSecondUpdate).isNotNull();
+    assertThat(sharedFileAfterSecondUpdate).isEqualTo(expectedSharedFileAfterUpdate);
+  }
+
+  @Test
+  public void testUpdateMaxExpirationDateSecs_nonExistentFile() throws Exception {
+    DataFile file = MddTestUtil.createDataFile("fileId", 0);
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+
+    ExecutionException ex =
+        Assert.assertThrows(
+            ExecutionException.class,
+            () ->
+                sfm.updateMaxExpirationDateSecs(newFileKey, FILE_GROUP_EXPIRATION_DATE_SECS).get());
+    assertThat(ex).hasCauseThat().isInstanceOf(SharedFileMissingException.class);
+  }
+
+  @Test
+  public void testSetAndroidSharedDownloadedFileEntry() throws Exception {
+    DataFile file = MddTestUtil.createSharedDataFile("fileId", 0);
+    SharedFile expectedSharedFileAfterUpdate =
+        SharedFile.newBuilder()
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .setFileName("android_shared_" + file.getAndroidSharingChecksum())
+            .setAndroidShared(true)
+            .setMaxExpirationDateSecs(FILE_GROUP_EXPIRATION_DATE_SECS)
+            .setAndroidSharingChecksum(file.getAndroidSharingChecksum())
+            .build();
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+    assertThat(sfm.reserveFileEntry(newFileKey).get()).isTrue();
+
+    SharedFile sharedFile = sharedFilesMetadata.read(newFileKey).get();
+    assertThat(sharedFile).isNotNull();
+    assertThat(sharedFile).isNotEqualTo(expectedSharedFileAfterUpdate);
+
+    assertThat(
+            sfm.setAndroidSharedDownloadedFileEntry(
+                    newFileKey, file.getAndroidSharingChecksum(), FILE_GROUP_EXPIRATION_DATE_SECS)
+                .get())
+        .isTrue();
+    sharedFile = sharedFilesMetadata.read(newFileKey).get();
+    assertThat(sharedFile).isNotNull();
+    assertThat(sharedFile).isEqualTo(expectedSharedFileAfterUpdate);
+  }
+
+  @Test
+  public void testOnDeviceUri() throws Exception {
+    DataFile file = MddTestUtil.createSharedDataFile("fileId", 0);
+    NewFileKey newFileKey =
+        SharedFilesMetadata.createKeyFromDataFile(file, AllowedReaders.ALL_GOOGLE_APPS);
+
+    assertThat(sfm.reserveFileEntry(newFileKey).get()).isTrue();
+
+    File onDeviceFile = simulateDownload(file, getLastFileName(), AllowedReaders.ALL_GOOGLE_APPS);
+    assertThat(sfm.getOnDeviceUri(newFileKey).get())
+        .isEqualTo(AndroidUri.builder(context).fromFile(onDeviceFile).build());
+
+    assertThat(
+            sfm.setAndroidSharedDownloadedFileEntry(
+                    newFileKey, file.getAndroidSharingChecksum(), FILE_GROUP_EXPIRATION_DATE_SECS)
+                .get())
+        .isTrue();
+    assertThat(sfm.getOnDeviceUri(newFileKey).get())
+        .isEqualTo(
+            BlobUri.builder(context).setBlobParameters(file.getAndroidSharingChecksum()).build());
+  }
+
+  private File simulateDownload(DataFile dataFile, String fileName, AllowedReaders allowedReaders)
+      throws IOException {
+    File onDeviceFile;
+    if (allowedReaders == AllowedReaders.ALL_GOOGLE_APPS) {
+      onDeviceFile = new File(publicDirectory, fileName);
+    } else {
+      onDeviceFile = new File(privateDirectory, fileName);
+    }
+    FileOutputStream writer = new FileOutputStream(onDeviceFile);
+    byte[] bytes = new byte[dataFile.getByteSize()];
+    writer.write(bytes);
+    writer.close();
+
+    return onDeviceFile;
+  }
+
+  private void changeFileStatusAs(NewFileKey newFileKey, FileStatus fileStatus)
+      throws InterruptedException, ExecutionException {
+    synchronized (SharedFilesMetadata.class) {
+      SharedFile sharedFile = sharedFilesMetadata.read(newFileKey).get();
+      sharedFile = sharedFile.toBuilder().setFileStatus(fileStatus).build();
+      assertThat(sharedFilesMetadata.write(newFileKey, sharedFile).get()).isTrue();
+    }
+  }
+
+  private String getLastFileName() {
+    SharedPreferences sfmMetadata =
+        SharedPreferencesUtil.getSharedPreferences(
+            context, MDD_SHARED_FILE_MANAGER_METADATA, Optional.absent());
+    long lastName = sfmMetadata.getLong(SharedFileManager.PREFS_KEY_NEXT_FILE_NAME, 1) - 1;
+    return SharedFileManager.FILE_NAME_PREFIX + lastName;
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/internal/SharedFilesMetadataTest.java b/javatests/com/google/android/libraries/mobiledatadownload/internal/SharedFilesMetadataTest.java
new file mode 100644
index 0000000..ec5e139
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/internal/SharedFilesMetadataTest.java
@@ -0,0 +1,578 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.android.libraries.mobiledatadownload.SilentFeedback;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.AndroidFileBackend;
+import com.google.android.libraries.mobiledatadownload.internal.Migrations.FileKeyVersion;
+import com.google.android.libraries.mobiledatadownload.internal.logging.EventLogger;
+import com.google.android.libraries.mobiledatadownload.internal.util.SharedFilesMetadataUtil;
+import com.google.android.libraries.mobiledatadownload.internal.util.SharedFilesMetadataUtil.FileKeyDeserializationException;
+import com.google.android.libraries.mobiledatadownload.internal.util.SharedPreferencesUtil;
+import com.google.android.libraries.mobiledatadownload.testing.TestFlags;
+import com.google.common.base.Optional;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.mobiledatadownload.TransformProto.CompressTransform;
+import com.google.mobiledatadownload.TransformProto.Transform;
+import com.google.mobiledatadownload.TransformProto.Transforms;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFile;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal.AllowedReaders;
+import com.google.mobiledatadownload.internal.MetadataProto.FileStatus;
+import com.google.mobiledatadownload.internal.MetadataProto.NewFileKey;
+import com.google.mobiledatadownload.internal.MetadataProto.SharedFile;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.ParameterizedRobolectricTestRunner;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameter;
+import org.robolectric.ParameterizedRobolectricTestRunner.Parameters;
+
+@RunWith(ParameterizedRobolectricTestRunner.class)
+public class SharedFilesMetadataTest {
+
+  private enum MetadataStoreImpl {
+    SP_IMPL,
+  }
+
+  @Parameters(name = "metadataStoreImpl = {0} instanceId = {1}")
+  public static Collection<Object[]> parameters() {
+    return Arrays.asList(
+        new Object[][] {
+          {MetadataStoreImpl.SP_IMPL, Optional.absent()},
+          {MetadataStoreImpl.SP_IMPL, Optional.of("id")},
+        });
+  }
+
+  @Parameter(value = 0)
+  public MetadataStoreImpl metadataStoreImpl;
+
+  @Parameter(value = 1)
+  public Optional<String> instanceId;
+
+  private SynchronousFileStorage storage;
+  private Context context;
+  private SharedFilesMetadata sharedFilesMetadata;
+
+  private final TestFlags flags = new TestFlags();
+  @Mock SilentFeedback mockSilentFeedback;
+  @Mock EventLogger mockLogger;
+
+  private static final Transforms COMPRESS_TRANSFORM =
+      Transforms.newBuilder()
+          .addTransform(Transform.newBuilder().setCompress(CompressTransform.getDefaultInstance()))
+          .build();
+  private static final Executor CONTROL_EXECUTOR =
+      MoreExecutors.newSequentialExecutor(Executors.newCachedThreadPool());
+
+  @Rule public final MockitoRule mocks = MockitoJUnit.rule();
+
+  @Before
+  public void setUp() throws InterruptedException, ExecutionException {
+
+    context = ApplicationProvider.getApplicationContext();
+
+    storage =
+        new SynchronousFileStorage(Arrays.asList(AndroidFileBackend.builder(context).build()));
+
+    SharedPreferencesSharedFilesMetadata sharedPreferencesMetadata =
+        new SharedPreferencesSharedFilesMetadata(context, mockSilentFeedback, instanceId, flags);
+
+    switch (metadataStoreImpl) {
+      case SP_IMPL:
+        sharedFilesMetadata = sharedPreferencesMetadata;
+        break;
+    }
+
+    Migrations.clear(context);
+    Migrations.setMigratedToNewFileKey(context, true);
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    synchronized (SharedPreferencesSharedFilesMetadata.class) {
+      sharedFilesMetadata.clear().get();
+      assertThat(
+              SharedPreferencesUtil.getSharedPreferences(
+                      context, SharedFilesMetadataUtil.MDD_SHARED_FILES, instanceId)
+                  .edit()
+                  .clear()
+                  .commit())
+          .isTrue();
+    }
+  }
+
+  @Test
+  public void init_alwaysMigrateToNewKey() throws InterruptedException, ExecutionException {
+    Migrations.setMigratedToNewFileKey(context, false);
+    flags.fileKeyVersion = Optional.of(FileKeyVersion.USE_CHECKSUM_ONLY.value);
+
+    assertThat(sharedFilesMetadata.init().get()).isFalse();
+
+    assertThat(Migrations.isMigratedToNewFileKey(context)).isTrue();
+
+    // Verify that we also set the current file version to the latest version in this case.
+    assertThat(Migrations.getCurrentVersion(context, mockSilentFeedback))
+        .isEqualTo(FileKeyVersion.USE_CHECKSUM_ONLY);
+  }
+
+  @Test
+  public void testMigrateToNewVersion_noVersionChange()
+      throws InterruptedException, ExecutionException {
+    flags.fileKeyVersion = Optional.of(FileKeyVersion.NEW_FILE_KEY.value);
+
+    // Create two files, one downloaded and the other currently being downloaded.
+    DataFile registeredFile = MddTestUtil.createDataFile("registered-file", 0);
+    DataFile downloadedFile = MddTestUtil.createDataFile("downloaded-file", /* fileIndex */ 0);
+
+    NewFileKey downloadedKey =
+        SharedFilesMetadata.createKeyFromDataFile(downloadedFile, AllowedReaders.ALL_GOOGLE_APPS);
+    NewFileKey registeredKey =
+        SharedFilesMetadata.createKeyFromDataFile(
+            registeredFile, AllowedReaders.ONLY_GOOGLE_PLAY_SERVICES);
+
+    SharedFile registeredSharedFile =
+        SharedFile.newBuilder()
+            .setFileName("registered-file")
+            .setFileStatus(FileStatus.SUBSCRIBED)
+            .build();
+    SharedFile downloadedSharedFile =
+        SharedFile.newBuilder()
+            .setFileName("downloaded-file")
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .build();
+
+    synchronized (SharedPreferencesSharedFilesMetadata.class) {
+      assertThat(writeSharedFile(registeredKey, registeredSharedFile)).isTrue();
+      assertThat(writeSharedFile(downloadedKey, downloadedSharedFile)).isTrue();
+    }
+
+    assertThat(sharedFilesMetadata.init().get()).isTrue();
+
+    // Check that we are able to read the file after the migration.
+    assertThat(sharedFilesMetadata.read(registeredKey).get()).isEqualTo(registeredSharedFile);
+    assertThat(sharedFilesMetadata.read(downloadedKey).get()).isEqualTo(downloadedSharedFile);
+
+    verifyNoErrorInPdsMigration();
+  }
+
+  @Test
+  public void testMigrateToNewVersion_toAddDownloadTransform()
+      throws InterruptedException, ExecutionException {
+    flags.fileKeyVersion = Optional.of(FileKeyVersion.ADD_DOWNLOAD_TRANSFORM.value);
+
+    // Create two files, one downloaded and the other currently being downloaded.
+    DataFile registeredFile = MddTestUtil.createDataFile("registered-file", 0);
+    DataFile downloadedFile = MddTestUtil.createDataFile("downloaded-file", /* fileIndex */ 0);
+
+    NewFileKey downloadedKey =
+        SharedFilesMetadata.createKeyFromDataFile(downloadedFile, AllowedReaders.ALL_GOOGLE_APPS);
+    NewFileKey registeredKey =
+        SharedFilesMetadata.createKeyFromDataFile(
+            registeredFile, AllowedReaders.ONLY_GOOGLE_PLAY_SERVICES);
+
+    SharedFile registeredSharedFile =
+        SharedFile.newBuilder()
+            .setFileName("registered-file")
+            .setFileStatus(FileStatus.SUBSCRIBED)
+            .build();
+    SharedFile downloadedSharedFile =
+        SharedFile.newBuilder()
+            .setFileName("downloaded-file")
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .build();
+
+    synchronized (SharedPreferencesSharedFilesMetadata.class) {
+      assertThat(writeSharedFile(registeredKey, registeredSharedFile)).isTrue();
+      assertThat(writeSharedFile(downloadedKey, downloadedSharedFile)).isTrue();
+    }
+
+    assertThat(sharedFilesMetadata.init().get()).isTrue();
+
+    // Check that we are able to read the file after the migration.
+    assertThat(sharedFilesMetadata.read(registeredKey).get()).isEqualTo(registeredSharedFile);
+    assertThat(sharedFilesMetadata.read(downloadedKey).get()).isEqualTo(downloadedSharedFile);
+
+    verifyNoErrorInPdsMigration();
+  }
+
+  @Test
+  public void testMigrateToNewVersion_useChecksumOnly()
+      throws InterruptedException, ExecutionException {
+    flags.fileKeyVersion = Optional.of(FileKeyVersion.USE_CHECKSUM_ONLY.value);
+
+    Migrations.setCurrentVersion(context, FileKeyVersion.ADD_DOWNLOAD_TRANSFORM);
+
+    // Create two files, one downloaded and the other currently being downloaded.
+    DataFile registeredFile = MddTestUtil.createDataFile("registered-file", 0);
+    DataFile downloadedFile = MddTestUtil.createDataFile("downloaded-file", /* fileIndex */ 0);
+
+    NewFileKey downloadedKey =
+        SharedFilesMetadata.createKeyFromDataFile(downloadedFile, AllowedReaders.ALL_GOOGLE_APPS);
+    NewFileKey registeredKey =
+        SharedFilesMetadata.createKeyFromDataFile(
+            registeredFile, AllowedReaders.ONLY_GOOGLE_PLAY_SERVICES);
+
+    SharedFile registeredSharedFile =
+        SharedFile.newBuilder()
+            .setFileName("registered-file")
+            .setFileStatus(FileStatus.SUBSCRIBED)
+            .build();
+    SharedFile downloadedSharedFile =
+        SharedFile.newBuilder()
+            .setFileName("downloaded-file")
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .build();
+
+    synchronized (SharedPreferencesSharedFilesMetadata.class) {
+      assertThat(writeSharedFile(registeredKey, registeredSharedFile)).isTrue();
+      assertThat(writeSharedFile(downloadedKey, downloadedSharedFile)).isTrue();
+    }
+
+    assertThat(sharedFilesMetadata.init().get()).isTrue();
+
+    // Check that we are able to read the file after the migration.
+    assertThat(sharedFilesMetadata.read(registeredKey).get()).isEqualTo(registeredSharedFile);
+    assertThat(sharedFilesMetadata.read(downloadedKey).get()).isEqualTo(downloadedSharedFile);
+
+    verifyNoErrorInPdsMigration();
+  }
+
+  @Test
+  public void testMigrateFromNewFileKeyToUseChecksumOnly_corruptedMetadata()
+      throws InterruptedException, ExecutionException {
+    flags.fileKeyVersion = Optional.of(FileKeyVersion.USE_CHECKSUM_ONLY.value);
+
+    // Create two files, one downloaded and the other currently being downloaded.
+    DataFile registeredFile = MddTestUtil.createDataFile("registered-file", 0);
+    DataFile downloadedFile = MddTestUtil.createDataFile("downloaded-file", /* fileIndex */ 0);
+
+    NewFileKey downloadedKey =
+        SharedFilesMetadata.createKeyFromDataFile(downloadedFile, AllowedReaders.ALL_GOOGLE_APPS);
+    NewFileKey registeredKey =
+        SharedFilesMetadata.createKeyFromDataFile(
+            registeredFile, AllowedReaders.ONLY_GOOGLE_PLAY_SERVICES);
+
+    SharedFile registeredSharedFile =
+        SharedFile.newBuilder()
+            .setFileName("registered-file")
+            .setFileStatus(FileStatus.SUBSCRIBED)
+            .build();
+    SharedFile downloadedSharedFile =
+        SharedFile.newBuilder()
+            .setFileName("downloaded-file")
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .build();
+
+    synchronized (SharedPreferencesSharedFilesMetadata.class) {
+      assertThat(writeSharedFile(registeredKey, registeredSharedFile)).isTrue();
+      assertThat(writeSharedFile(downloadedKey, downloadedSharedFile)).isTrue();
+    }
+
+    // Set invalid version
+    SharedPreferences migrationPrefs =
+        context.getSharedPreferences("gms_icing_mdd_migrations", Context.MODE_PRIVATE);
+    migrationPrefs.edit().putInt("mdd_file_key_version", 200).commit();
+
+    assertThat(sharedFilesMetadata.init().get()).isTrue();
+    // Check that we are able to read the file after the migration.
+    assertThat(sharedFilesMetadata.read(registeredKey).get()).isEqualTo(registeredSharedFile);
+    assertThat(sharedFilesMetadata.read(downloadedKey).get()).isEqualTo(downloadedSharedFile);
+
+    verifyNoErrorInPdsMigration();
+  }
+
+  @Test
+  public void testNoMigrate_corruptedMetadata() throws InterruptedException, ExecutionException {
+    flags.fileKeyVersion = Optional.of(FileKeyVersion.USE_CHECKSUM_ONLY.value);
+    Migrations.setCurrentVersion(context, FileKeyVersion.USE_CHECKSUM_ONLY);
+
+    // Create two files, one downloaded and the other currently being downloaded.
+    DataFile registeredFile = MddTestUtil.createDataFile("registered-file", 0);
+    DataFile downloadedFile = MddTestUtil.createDataFile("downloaded-file", /* fileIndex */ 0);
+
+    NewFileKey downloadedKey =
+        SharedFilesMetadata.createKeyFromDataFile(downloadedFile, AllowedReaders.ALL_GOOGLE_APPS);
+    NewFileKey registeredKey =
+        SharedFilesMetadata.createKeyFromDataFile(
+            registeredFile, AllowedReaders.ONLY_GOOGLE_PLAY_SERVICES);
+
+    SharedFile registeredSharedFile =
+        SharedFile.newBuilder()
+            .setFileName("registered-file")
+            .setFileStatus(FileStatus.SUBSCRIBED)
+            .build();
+    SharedFile downloadedSharedFile =
+        SharedFile.newBuilder()
+            .setFileName("downloaded-file")
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .build();
+
+    synchronized (SharedPreferencesSharedFilesMetadata.class) {
+      assertThat(writeSharedFile(registeredKey, registeredSharedFile)).isTrue();
+      assertThat(writeSharedFile(downloadedKey, downloadedSharedFile)).isTrue();
+    }
+
+    // Set invalid version
+    SharedPreferences migrationPrefs =
+        context.getSharedPreferences("gms_icing_mdd_migrations", Context.MODE_PRIVATE);
+    migrationPrefs.edit().putInt("mdd_file_key_version", 200).commit();
+
+    assertThat(sharedFilesMetadata.init().get()).isTrue();
+    // Unable to read the file because the file key version doesn't match during migration
+    assertThat(sharedFilesMetadata.read(registeredKey).get()).isNull();
+    assertThat(sharedFilesMetadata.read(downloadedKey).get()).isNull();
+
+    verifyNoErrorInPdsMigration();
+  }
+
+  @Test
+  public void testMigrateToNewVersion_downgrade_clearOff()
+      throws InterruptedException, ExecutionException {
+    Migrations.setCurrentVersion(context, FileKeyVersion.ADD_DOWNLOAD_TRANSFORM);
+    flags.fileKeyVersion = Optional.of(FileKeyVersion.NEW_FILE_KEY.value);
+
+    assertThat(sharedFilesMetadata.init().get()).isFalse();
+  }
+
+  @Test
+  public void testDeserializeNewFileKey() throws FileKeyDeserializationException {
+    Migrations.setCurrentVersion(context, FileKeyVersion.NEW_FILE_KEY);
+    String url = "https://www.gstatic.com/icing/idd/apitest/compressedFile.txt.deflate";
+    int size = 15;
+    String checksum = "ec876850e7ddc9ecde1a3844006e3663d70569e3";
+    NewFileKey fileKey =
+        NewFileKey.newBuilder()
+            .setUrlToDownload(url)
+            .setByteSize(size)
+            .setChecksum(checksum)
+            .setAllowedReaders(AllowedReaders.ALL_GOOGLE_APPS)
+            .build();
+    String serializedStr =
+        SharedFilesMetadataUtil.getSerializedFileKey(fileKey, context, mockSilentFeedback);
+    NewFileKey newFileKey =
+        SharedFilesMetadataUtil.deserializeNewFileKey(serializedStr, context, mockSilentFeedback);
+    assertThat(newFileKey.getUrlToDownload()).isEqualTo(url);
+    assertThat(newFileKey.getByteSize()).isEqualTo(size);
+    assertThat(newFileKey.getChecksum()).isEqualTo(checksum);
+    assertThat(newFileKey.getAllowedReaders()).isEqualTo(AllowedReaders.ALL_GOOGLE_APPS);
+    assertThat(newFileKey.hasDownloadTransforms()).isFalse();
+
+    // test with transforms, it should be skipped
+    String serializedStrWithDownloadTransform =
+        SharedFilesMetadataUtil.getSerializedFileKey(
+            newFileKey.toBuilder().setDownloadTransforms(COMPRESS_TRANSFORM).build(),
+            context,
+            mockSilentFeedback);
+    newFileKey =
+        SharedFilesMetadataUtil.deserializeNewFileKey(
+            serializedStrWithDownloadTransform, context, mockSilentFeedback);
+    assertThat(newFileKey.getUrlToDownload()).isEqualTo(url);
+    assertThat(newFileKey.getByteSize()).isEqualTo(size);
+    assertThat(newFileKey.getChecksum()).isEqualTo(checksum);
+    assertThat(newFileKey.getAllowedReaders()).isEqualTo(AllowedReaders.ALL_GOOGLE_APPS);
+
+    assertThat(newFileKey.hasDownloadTransforms()).isFalse();
+  }
+
+  @Test
+  public void testReadAndWriteAfterDownloadTransformMigration()
+      throws InterruptedException, ExecutionException {
+    Migrations.setCurrentVersion(context, FileKeyVersion.ADD_DOWNLOAD_TRANSFORM);
+    String url = "https://www.gstatic.com/icing/idd/apitest/compressedFile.txt.deflate";
+    int size = 15;
+    String checksum = "ec876850e7ddc9ecde1a3844006e3663d70569e3";
+    NewFileKey fileKey =
+        NewFileKey.newBuilder()
+            .setUrlToDownload(url)
+            .setByteSize(size)
+            .setChecksum(checksum)
+            .setAllowedReaders(AllowedReaders.ALL_GOOGLE_APPS)
+            .build();
+    SharedFile sharedFile =
+        SharedFile.newBuilder()
+            .setFileName("registered-file")
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .build();
+
+    // Change the same key and just add download transform
+    NewFileKey fileKeyWithTransforms =
+        fileKey.toBuilder().setDownloadTransforms(COMPRESS_TRANSFORM).build();
+    SharedFile sharedFileWithTransform =
+        SharedFile.newBuilder()
+            .setFileName("file-with-transform")
+            .setFileStatus(FileStatus.SUBSCRIBED)
+            .build();
+
+    synchronized (SharedPreferencesSharedFilesMetadata.class) {
+      assertThat(writeSharedFile(fileKey, sharedFile)).isTrue();
+      assertThat(writeSharedFile(fileKeyWithTransforms, sharedFileWithTransform)).isTrue();
+
+      assertThat(sharedFilesMetadata.read(fileKey).get()).isEqualTo(sharedFile);
+      assertThat(sharedFilesMetadata.read(fileKeyWithTransforms).get())
+          .isEqualTo(sharedFileWithTransform);
+
+      assertThat(sharedFilesMetadata.remove(fileKey).get()).isTrue();
+      assertThat(sharedFilesMetadata.remove(fileKeyWithTransforms).get()).isTrue();
+    }
+
+    verifyNoErrorInPdsMigration();
+  }
+
+  @Test
+  public void testReadAndWrite_afterChecksumOnlyMigration()
+      throws InterruptedException, ExecutionException {
+    Migrations.setCurrentVersion(context, FileKeyVersion.USE_CHECKSUM_ONLY);
+
+    String checksum = "ec876850e7ddc9ecde1a3844006e3663d70569e3";
+    NewFileKey fileKey = NewFileKey.newBuilder().setChecksum(checksum).build();
+    SharedFile sharedFile =
+        SharedFile.newBuilder()
+            .setFileName("registered-file")
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .build();
+
+    synchronized (SharedPreferencesSharedFilesMetadata.class) {
+      assertThat(writeSharedFile(fileKey, sharedFile)).isTrue();
+      assertThat(sharedFilesMetadata.read(fileKey).get()).isEqualTo(sharedFile);
+      assertThat(sharedFilesMetadata.remove(fileKey).get()).isTrue();
+    }
+
+    verifyNoErrorInPdsMigration();
+  }
+
+  @Test
+  public void testGetAllFileKeys_afterDownloadTransformMigration()
+      throws InterruptedException, ExecutionException {
+    Migrations.setCurrentVersion(context, FileKeyVersion.ADD_DOWNLOAD_TRANSFORM);
+    String url = "https://www.gstatic.com/icing/idd/apitest/compressedFile.txt.deflate";
+    int size = 15;
+    String checksum = "ec876850e7ddc9ecde1a3844006e3663d70569e3";
+    NewFileKey fileKey =
+        NewFileKey.newBuilder()
+            .setUrlToDownload(url)
+            .setByteSize(size)
+            .setChecksum(checksum)
+            .setAllowedReaders(AllowedReaders.ALL_GOOGLE_APPS)
+            .build();
+    SharedFile sharedFile =
+        SharedFile.newBuilder()
+            .setFileName("registered-file")
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .build();
+
+    // Change the same key and just add download transform
+    NewFileKey fileKeyWithTransforms =
+        fileKey.toBuilder().setDownloadTransforms(COMPRESS_TRANSFORM).build();
+    SharedFile sharedFileWithTransform =
+        SharedFile.newBuilder()
+            .setFileName("file-with-transform")
+            .setFileStatus(FileStatus.SUBSCRIBED)
+            .build();
+
+    synchronized (SharedPreferencesSharedFilesMetadata.class) {
+      assertThat(writeSharedFile(fileKey, sharedFile)).isTrue();
+      assertThat(writeSharedFile(fileKeyWithTransforms, sharedFileWithTransform)).isTrue();
+
+      List<NewFileKey> allFileKeys = sharedFilesMetadata.getAllFileKeys().get();
+      assertThat(allFileKeys).hasSize(2);
+    }
+  }
+
+  @Test
+  public void testGetAllFileKeys_afterChecksumOnlyMigration()
+      throws InterruptedException, ExecutionException {
+    Migrations.setCurrentVersion(context, FileKeyVersion.USE_CHECKSUM_ONLY);
+
+    String checksum = "ec876850e7ddc9ecde1a3844006e3663d70569e3";
+    NewFileKey fileKey =
+        NewFileKey.newBuilder()
+            .setChecksum(checksum)
+            .setAllowedReaders(AllowedReaders.ALL_GOOGLE_APPS)
+            .build();
+    SharedFile sharedFile =
+        SharedFile.newBuilder()
+            .setFileName("registered-file")
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .build();
+
+    synchronized (SharedPreferencesSharedFilesMetadata.class) {
+      assertThat(writeSharedFile(fileKey, sharedFile)).isTrue();
+
+      List<NewFileKey> allFileKeys = sharedFilesMetadata.getAllFileKeys().get();
+      assertThat(allFileKeys).hasSize(1);
+      assertThat(allFileKeys.get(0)).isEqualTo(fileKey);
+    }
+  }
+
+  @Test
+  public void testGetAllFileKeysWithCorruptedData()
+      throws InterruptedException, ExecutionException {
+    SharedPreferences prefs =
+        SharedPreferencesUtil.getSharedPreferences(
+            context, SharedFilesMetadataUtil.MDD_SHARED_FILES, instanceId);
+    String validKeyOne =
+        "https://www.gstatic.com/icing/idd/apitest/compressedFile.txt.deflate|15|checksum|1";
+    String corruptedKeyOne =
+        "https://www.gstatic.com/icing/idd/apitest/compressedFile.txt.deflate|15|abc";
+    SharedFile sharedFile =
+        SharedFile.newBuilder()
+            .setFileName("registered-file")
+            .setFileStatus(FileStatus.DOWNLOAD_COMPLETE)
+            .build();
+    assertThat(SharedPreferencesUtil.writeProto(prefs, validKeyOne, sharedFile)).isTrue();
+    assertThat(SharedPreferencesUtil.writeProto(prefs, corruptedKeyOne, sharedFile)).isTrue();
+    String corruptedKeyTwo = "|15|abc";
+    assertThat(SharedPreferencesUtil.writeProto(prefs, corruptedKeyTwo, sharedFile)).isTrue();
+    assertThat(sharedFilesMetadata.getAllFileKeys().get()).hasSize(1);
+    assertThat(
+            SharedPreferencesUtil.getSharedPreferences(
+                    context, SharedFilesMetadataUtil.MDD_SHARED_FILES, instanceId)
+                .getAll())
+        .hasSize(1);
+  }
+
+  @Test
+  public void test_createKeyFromDataFile_withZipDownloadTransform() {
+    DataFile zipFile = MddTestUtil.createZipFolderDataFile("testzip", 0);
+    NewFileKey fileKey =
+        SharedFilesMetadata.createKeyFromDataFile(zipFile, AllowedReaders.ALL_GOOGLE_APPS);
+    assertThat(fileKey.getChecksum()).isEqualTo(zipFile.getDownloadedFileChecksum());
+  }
+
+  private boolean writeSharedFile(NewFileKey newFileKey, SharedFile sharedFile)
+      throws InterruptedException, ExecutionException {
+    return sharedFilesMetadata.write(newFileKey, sharedFile).get();
+  }
+
+  private void verifyNoErrorInPdsMigration() {}
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/internal/util/AndroidManifest.xml b/javatests/com/google/android/libraries/mobiledatadownload/internal/util/AndroidManifest.xml
new file mode 100644
index 0000000..9a746ca
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/internal/util/AndroidManifest.xml
@@ -0,0 +1,30 @@
+<!--
+/*
+ * Copyright 2022 Google LLC
+ *
+ * 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.
+ */
+-->
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.google.android.libraries.mobiledatadownload.internal.util">
+  <uses-sdk
+      android:minSdkVersion="16"
+      android:targetSdkVersion="27"/>
+  <application>
+    <uses-library android:name="android.test.runner" />
+  </application>
+  <instrumentation
+      android:name="com.google.android.apps.common.testing.testrunner.Google3InstrumentationTestRunner"
+      android:targetPackage="com.google.android.libraries.mobiledatadownload.internal.util" />
+</manifest>
\ No newline at end of file
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/internal/util/BUILD b/javatests/com/google/android/libraries/mobiledatadownload/internal/util/BUILD
new file mode 100644
index 0000000..36f4805
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/internal/util/BUILD
@@ -0,0 +1,113 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_library", "android_local_test")
+
+package(
+    default_visibility = ["//:__subpackages__"],
+    licenses = ["notice"],
+)
+
+android_local_test(
+    name = "DirectoryUtilTest",
+    srcs = ["DirectoryUtilTest.java"],
+    manifest_values = {
+        "minSdkVersion": "16",
+        "targetSdkVersion": "27",
+    },
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:android",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:DirectoryUtil",
+        "@androidx_test",
+        "@com_google_guava_guava",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "EitherTest",
+    srcs = ["EitherTest.java"],
+    manifest_values = {
+        "minSdkVersion": "16",
+        "targetSdkVersion": "27",
+    },
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:Either",
+        "@com_google_guava_guava",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "FuturesUtilTest",
+    srcs = ["FuturesUtilTest.java"],
+    manifest_values = {
+        "minSdkVersion": "16",
+        "targetSdkVersion": "27",
+    },
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:FuturesUtil",
+        "@com_google_guava_guava",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "ProtoConversionUtilTest",
+    srcs = ["ProtoConversionUtilTest.java"],
+    data = [
+        "//javatests/com/google/android/libraries/mobiledatadownload/internal/util/testdata:raw_group",
+    ],
+    manifest_values = {
+        "minSdkVersion": "16",
+        "targetSdkVersion": "27",
+    },
+    deps = [
+        ":group_optional_unset_proto_data",
+        ":group_proto_data",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:android",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:bytes",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/util:ProtoConversionUtil",
+        "//javatests/com/google/android/libraries/mobiledatadownload/internal:MddTestUtil",
+        "//proto:download_config_java_proto_lite",
+        "//proto:transform_java_proto_lite",
+        "@androidx_test",
+        "@com_google_guava_guava",
+        "@com_google_protobuf//:parsers",
+        "@com_google_protobuf//:protobuf_lite",
+        "@com_google_testing//:test_util",
+        "@mockito",
+        "@truth",
+    ],
+)
+
+android_library(
+    name = "group_proto_data",
+    manifest = "AndroidManifest.xml",
+    resource_files = [
+        "//javatests/com/google/android/libraries/mobiledatadownload/internal/util/testdata:group.pb",
+        "//javatests/com/google/android/libraries/mobiledatadownload/internal/util/testdata:group_internal.pb",
+    ],
+)
+
+android_library(
+    name = "group_optional_unset_proto_data",
+    manifest = "AndroidManifest.xml",
+    resource_files = [
+        "//javatests/com/google/android/libraries/mobiledatadownload/internal/util/testdata:group_optional_unset.pb",
+        "//javatests/com/google/android/libraries/mobiledatadownload/internal/util/testdata:group_internal_optional_unset.pb",
+    ],
+)
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/internal/util/DirectoryUtilTest.java b/javatests/com/google/android/libraries/mobiledatadownload/internal/util/DirectoryUtilTest.java
new file mode 100644
index 0000000..8c66576
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/internal/util/DirectoryUtilTest.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.util;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.content.Context;
+import android.net.Uri;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.android.libraries.mobiledatadownload.file.backends.AndroidUri;
+import com.google.common.base.Optional;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+/** Unit tests for {@link DirectoryUtil}. */
+@RunWith(RobolectricTestRunner.class)
+public class DirectoryUtilTest {
+
+  private Context context;
+
+  @Before
+  public void setUp() {
+    context = ApplicationProvider.getApplicationContext();
+  }
+
+  @Test
+  public void getBaseDownloadDirectory() {
+    // No instanceid
+    Uri uri = AndroidUri.builder(context).setModule(DirectoryUtil.MDD_STORAGE_MODULE).build();
+    assertThat(DirectoryUtil.getBaseDownloadDirectory(context, Optional.absent())).isEqualTo(uri);
+
+    // valid instanceid
+    uri =
+        AndroidUri.builder(context)
+            .setModule("instanceid")
+            .setRelativePath(DirectoryUtil.MDD_STORAGE_MODULE)
+            .build();
+    assertThat(DirectoryUtil.getBaseDownloadDirectory(context, Optional.of("instanceid")))
+        .isEqualTo(uri);
+
+    // invalid instanceid. InstanceId must be [a-z].
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> DirectoryUtil.getBaseDownloadDirectory(context, Optional.of("InstanceId")));
+  }
+
+  @Test
+  public void buildFilename_buildsFilenameWithInstanceId() {
+    assertThat(DirectoryUtil.buildFilename("prefix", "suffix", Optional.absent()))
+        .isEqualTo("prefix.suffix");
+
+    assertThat(DirectoryUtil.buildFilename("prefix", "suffix", Optional.of("myinstance")))
+        .isEqualTo("prefixmyinstance.suffix");
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/internal/util/EitherTest.java b/javatests/com/google/android/libraries/mobiledatadownload/internal/util/EitherTest.java
new file mode 100644
index 0000000..2457c59
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/internal/util/EitherTest.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.util;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Ordering;
+import java.util.Comparator;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public final class EitherTest {
+
+  // ImmutableList ensures the Either List isn't modified by sortedEquals.
+  private static final ImmutableList<String> LIST = ImmutableList.of("a", "b", "c");
+  private static final ImmutableList<String> JUMBLED_LIST = ImmutableList.of("c", "a", "b");
+  private static final ImmutableList<String> OTHER_LIST = ImmutableList.of("c");
+
+  private static final Comparator<String> COMPARATOR = Ordering.natural();
+
+  @Test
+  public void sortedEquals_comparesSortedList() throws Exception {
+    assertThat(Either.sortedEquals(Either.makeLeft(LIST), Either.makeLeft(LIST), COMPARATOR))
+        .isTrue();
+    assertThat(
+            Either.sortedEquals(Either.makeLeft(LIST), Either.makeLeft(JUMBLED_LIST), COMPARATOR))
+        .isTrue();
+
+    assertThat(Either.sortedEquals(Either.makeLeft(LIST), Either.makeLeft(OTHER_LIST), COMPARATOR))
+        .isFalse();
+  }
+
+  @Test
+  public void sortedEquals_handlesNull() throws Exception {
+    assertThat(Either.sortedEquals(Either.makeLeft(LIST), null, COMPARATOR)).isFalse();
+    assertThat(Either.sortedEquals(Either.makeLeft(LIST), Either.makeLeft(null), COMPARATOR))
+        .isFalse();
+
+    assertThat(Either.sortedEquals(Either.makeLeft(null), Either.makeLeft(null), COMPARATOR))
+        .isTrue();
+    assertThat(Either.sortedEquals(null, null, COMPARATOR)).isTrue();
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/internal/util/FuturesUtilTest.java b/javatests/com/google/android/libraries/mobiledatadownload/internal/util/FuturesUtilTest.java
new file mode 100644
index 0000000..e302f09
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/internal/util/FuturesUtilTest.java
@@ -0,0 +1,162 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.util;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import com.google.android.libraries.mobiledatadownload.internal.util.FuturesUtil.SequentialFutureChain;
+import com.google.common.base.Function;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
+import java.io.IOException;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public final class FuturesUtilTest {
+
+  private static final Executor SEQUENTIAL_EXECUTOR =
+      MoreExecutors.newSequentialExecutor(Executors.newCachedThreadPool());
+
+  private FuturesUtil futuresUtil;
+
+  @Before
+  public void setUp() {
+    futuresUtil = new FuturesUtil(SEQUENTIAL_EXECUTOR);
+  }
+
+  @Test
+  public void chain_preservesOrder() throws ExecutionException, InterruptedException {
+    SequentialFutureChain<String> chain = futuresUtil.newSequentialChain("");
+    for (int i = 0; i < 10; i++) {
+      // Variables captured in lambdas must be effectively final.
+      int index = i;
+      chain.chainAsync(str -> Futures.immediateFuture(str + index));
+    }
+    String result = chain.start().get();
+    assertThat(result).isEqualTo("0123456789");
+  }
+
+  @Test
+  public void chain_mixedOperationTypes_preservesOrder()
+      throws ExecutionException, InterruptedException {
+    String result =
+        futuresUtil
+            .newSequentialChain("")
+            .chainAsync(str -> Futures.immediateFuture(str + 0))
+            .chain(str -> str + 1)
+            .chainAsync(str -> Futures.immediateFuture(str + 2))
+            .chain(str -> str + 3)
+            .chain(str -> str + 4)
+            .chainAsync(str -> Futures.immediateFuture(str + 5))
+            .start()
+            .get();
+    assertThat(result).isEqualTo("012345");
+  }
+
+  @Test
+  public void chain_preservesOrder_sideEffects() throws ExecutionException, InterruptedException {
+    StringBuilder sb = new StringBuilder("");
+    SequentialFutureChain<Void> chain = futuresUtil.newSequentialChain();
+    for (int i = 0; i < 10; i++) {
+      chain.chainAsync(appendIntAsync(sb, i));
+    }
+    chain.start().get();
+    assertThat(sb.toString()).isEqualTo("0123456789");
+  }
+
+  @Test
+  public void chain_mixedOperationTypes_preservesOrder_sideEffects()
+      throws ExecutionException, InterruptedException {
+    StringBuilder sb = new StringBuilder("");
+    futuresUtil
+        .newSequentialChain()
+        .chain(appendIntDirect(sb, 0))
+        .chainAsync(appendIntAsync(sb, 1))
+        .chain(appendIntDirect(sb, 2))
+        .chainAsync(appendIntAsync(sb, 3))
+        .chainAsync(appendIntAsync(sb, 4))
+        .chain(appendIntDirect(sb, 5))
+        .start()
+        .get();
+    assertThat(sb.toString()).isEqualTo("012345");
+  }
+
+  @Test
+  public void chain_noOp_preservesInitialState() throws ExecutionException, InterruptedException {
+    StringBuilder sb = new StringBuilder("Initial state.");
+    SequentialFutureChain<Void> chain = futuresUtil.newSequentialChain();
+    for (int i = 0; i < 0; i++) {
+      chain.chainAsync(appendIntAsync(sb, i));
+    }
+    chain.start().get();
+    assertThat(sb.toString()).isEqualTo("Initial state.");
+  }
+
+  @Test
+  public void chain_propagatesFailure() {
+    StringBuilder sb = new StringBuilder("");
+    SequentialFutureChain<Void> chain = futuresUtil.newSequentialChain();
+    chain
+        .chainAsync(appendIntAsync(sb, 0))
+        .chainAsync(appendIntAsync(sb, 1))
+        .chainAsync(voidArg -> Futures.immediateFailedFuture(new IOException("The error message.")))
+        .chainAsync(appendIntAsync(sb, 2))
+        .chainAsync(appendIntAsync(sb, 3));
+    ExecutionException ex = assertThrows(ExecutionException.class, chain.start()::get);
+    assertThat(ex).hasCauseThat().isInstanceOf(IOException.class);
+    assertThat(ex).hasMessageThat().contains("The error message.");
+  }
+
+  @Test
+  public void chain_mixedOperationTypes_propagatesFailure() {
+    StringBuilder sb = new StringBuilder("");
+    SequentialFutureChain<Void> chain = futuresUtil.newSequentialChain();
+    chain
+        .chainAsync(appendIntAsync(sb, 0))
+        .chain(appendIntDirect(sb, 1))
+        .chain(
+            voidArg -> {
+              throw new RuntimeException("The error message.");
+            })
+        .chain(appendIntDirect(sb, 2))
+        .chainAsync(appendIntAsync(sb, 3));
+    ExecutionException ex = assertThrows(ExecutionException.class, chain.start()::get);
+    assertThat(ex).hasCauseThat().isInstanceOf(RuntimeException.class);
+    assertThat(ex).hasMessageThat().contains("The error message.");
+  }
+
+  private static Function<Void, Void> appendIntDirect(StringBuilder sb, int i) {
+    return voidArg -> {
+      sb.append(i);
+      return null;
+    };
+  }
+
+  private static Function<Void, ListenableFuture<Void>> appendIntAsync(StringBuilder sb, int i) {
+    return voidArg -> {
+      sb.append(i);
+      return Futures.immediateFuture(null);
+    };
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/internal/util/ProtoConversionUtilTest.java b/javatests/com/google/android/libraries/mobiledatadownload/internal/util/ProtoConversionUtilTest.java
new file mode 100644
index 0000000..9dd366d
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/internal/util/ProtoConversionUtilTest.java
@@ -0,0 +1,275 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.internal.util;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.Context;
+import android.net.Uri;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.AndroidFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.backends.JavaFileBackend;
+import com.google.android.libraries.mobiledatadownload.file.openers.ReadByteArrayOpener;
+import com.google.android.libraries.mobiledatadownload.internal.MddTestUtil;
+import com.google.common.collect.ImmutableList;
+import com.google.common.truth.Correspondence;
+import com.google.mobiledatadownload.DownloadConfigProto.BaseFile;
+import com.google.mobiledatadownload.DownloadConfigProto.DataFile;
+import com.google.mobiledatadownload.DownloadConfigProto.DataFileGroup;
+import com.google.mobiledatadownload.DownloadConfigProto.DataFileGroup.AllowedReaders;
+import com.google.mobiledatadownload.DownloadConfigProto.DeltaFile;
+import com.google.mobiledatadownload.DownloadConfigProto.DownloadConditions;
+import com.google.mobiledatadownload.DownloadConfigProto.DownloadConditions.DeviceNetworkPolicy;
+import com.google.mobiledatadownload.DownloadConfigProto.DownloadConditions.DeviceStoragePolicy;
+import com.google.mobiledatadownload.DownloadConfigProto.ExtraHttpHeader;
+import com.google.mobiledatadownload.TransformProto.CompressTransform;
+import com.google.mobiledatadownload.TransformProto.Transform;
+import com.google.mobiledatadownload.TransformProto.Transforms;
+import com.google.mobiledatadownload.TransformProto.ZipTransform;
+import com.google.mobiledatadownload.internal.MetadataProto;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupBookkeeping;
+import com.google.mobiledatadownload.internal.MetadataProto.DataFileGroupInternal;
+import com.google.protobuf.ExtensionRegistryLite;
+import com.google.protobuf.contrib.android.ProtoParsers;
+import com.google.testing.util.TestUtil;
+import java.io.File;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.RobolectricTestRunner;
+
+/** Unit tests for {@link ProtoConversionUtil}. */
+@RunWith(RobolectricTestRunner.class)
+public final class ProtoConversionUtilTest {
+
+  @Rule public final MockitoRule mocks = MockitoJUnit.rule();
+
+  private SynchronousFileStorage fileStorage;
+  private Context context;
+
+  // The raw test data folder in google3.
+  private static final String TEST_DATA_DIR =
+      TestUtil.getRunfilesDir()
+          + "/google3/third_party/java_src/android_libs/mobiledatadownload/javatests/com/google/android/libraries/mobiledatadownload/internal/util/testdata/";
+  private static final File RAW_GROUP_WITH_EXTENSION =
+      new File(TEST_DATA_DIR, "raw_group_with_extension");
+
+  @Before
+  public void setUp() throws Exception {
+    context = ApplicationProvider.getApplicationContext();
+    fileStorage =
+        new SynchronousFileStorage(
+            ImmutableList.of(AndroidFileBackend.builder(context).build(), new JavaFileBackend()));
+  }
+
+  @Test
+  public void convert_fromDataFileGroup_toDataFileGroupInternal_noThrow() throws Exception {
+    DataFileGroup group =
+        DataFileGroup.newBuilder()
+            .setGroupName("test-group")
+            .setOwnerPackage("com.google.android.libraries.mobiledatadownload")
+            .setFileGroupVersionNumber(12)
+            .setAllowedReadersEnum(AllowedReaders.ALL_GOOGLE_APPS)
+            .setDownloadConditions(
+                DownloadConditions.newBuilder()
+                    .setDeviceNetworkPolicy(DeviceNetworkPolicy.DOWNLOAD_ON_ANY_NETWORK)
+                    .setDeviceStoragePolicy(DeviceStoragePolicy.BLOCK_DOWNLOAD_LOWER_THRESHOLD)
+                    .build())
+            .setExpirationDate(1234567890)
+            .setStaleLifetimeSecs(123456)
+            .setTrafficTag(3)
+            .setPreserveFilenamesAndIsolateFiles(true)
+            .addAllFile(
+                ImmutableList.of(
+                    DataFile.newBuilder()
+                        .setFileId("one")
+                        .setByteSize(200)
+                        .setUrlToDownload("https://www.google.com/")
+                        .setChecksum("checksum1")
+                        .build(),
+                    DataFile.newBuilder()
+                        .setFileId("two")
+                        .setByteSize(500)
+                        .setUrlToDownload("https://www.instagram.com/")
+                        .setChecksum("checksum2")
+                        .build()))
+            .addAllGroupExtraHttpHeaders(
+                ImmutableList.of(
+                    ExtraHttpHeader.newBuilder().setKey("k1").setValue("v1").build(),
+                    ExtraHttpHeader.newBuilder().setKey("k2").setValue("v2").build()))
+            .build();
+    DataFileGroupInternal unused = ProtoConversionUtil.convert(group);
+  }
+
+  @Test
+  public void convert_fromDataFileGroup_toDataFileGroupInternal_parseFromTextProto()
+      throws Exception {
+    DataFileGroup dataFileGroup = getDataFileGroupFromTextProto(R.raw.group_data_pb);
+    DataFileGroupInternal dataFileGroupInternal =
+        getDataFileGroupInternalFromTextProto(R.raw.group_internal_data_pb);
+    assertThat(ProtoConversionUtil.convert(dataFileGroup)).isEqualTo(dataFileGroupInternal);
+  }
+
+  @Test
+  public void convert_fromDataFileGroup_toDataFileGroupInternal_parseFromTextProto_optionalUnset()
+      throws Exception {
+    DataFileGroup dataFileGroup = getDataFileGroupFromTextProto(R.raw.group_optional_unset_data_pb);
+    DataFileGroupInternal dataFileGroupInternal =
+        getDataFileGroupInternalFromTextProto(R.raw.group_internal_optional_unset_data_pb);
+    assertThat(ProtoConversionUtil.convert(dataFileGroup)).isEqualTo(dataFileGroupInternal);
+  }
+
+  /**
+   * The main purpose of this test is to make sure that after migration, we are still able to
+   * interpret and parse the group metadata correctly. The raw metadata was generated by the proto
+   * {@link DataFileGroup} with extensions before the migration. Since we reuse the same tag number,
+   * the extension should now be parsed into {@link DataFileGroupBookkeeping}.
+   */
+  @Test
+  public void convert_parseRawProtoWithExtensions() throws Exception {
+    DataFileGroupInternal expected =
+        ProtoConversionUtil.convert(
+                MddTestUtil.createDataFileGroup(/*fileGroupName=*/ "test-group", 2))
+            .toBuilder()
+            .setBookkeeping(
+                DataFileGroupBookkeeping.newBuilder()
+                    .setGroupNewFilesReceivedTimestamp(1000)
+                    .build())
+            .build();
+
+    // Read the raw group with extension from file.
+    Uri uri = Uri.fromFile(RAW_GROUP_WITH_EXTENSION);
+    byte[] bytes = fileStorage.open(uri, ReadByteArrayOpener.create());
+
+    // Make sure that the proto with extension is correcly parsed.
+    DataFileGroupInternal parsedFromRawProto =
+        DataFileGroupInternal.parseFrom(bytes, ExtensionRegistryLite.getEmptyRegistry());
+    assertThat(parsedFromRawProto).isEqualTo(expected);
+  }
+
+  @Test
+  public void convert_onDownloadConditions() throws Exception {
+    DownloadConditions downloadConditions =
+        DownloadConditions.newBuilder()
+            .setDeviceNetworkPolicy(DeviceNetworkPolicy.DOWNLOAD_ON_ANY_NETWORK)
+            .setDeviceStoragePolicy(DeviceStoragePolicy.BLOCK_DOWNLOAD_LOWER_THRESHOLD)
+            .build();
+    MetadataProto.DownloadConditions convertedDownloadConditions =
+        ProtoConversionUtil.convert(downloadConditions);
+    assertThat(convertedDownloadConditions.hasDeviceStoragePolicy()).isTrue();
+    assertThat(convertedDownloadConditions.hasDeviceNetworkPolicy()).isTrue();
+    assertThat(convertedDownloadConditions.hasDownloadFirstOnWifiPeriodSecs()).isFalse();
+    assertThat(convertedDownloadConditions.getDeviceNetworkPolicy())
+        .isEqualTo(MetadataProto.DownloadConditions.DeviceNetworkPolicy.DOWNLOAD_ON_ANY_NETWORK);
+    assertThat(convertedDownloadConditions.getDeviceStoragePolicy())
+        .isEqualTo(
+            MetadataProto.DownloadConditions.DeviceStoragePolicy.BLOCK_DOWNLOAD_LOWER_THRESHOLD);
+  }
+
+  @Test
+  public void convertDataFile_convertsFields() {
+    Transforms downloadTransforms =
+        Transforms.newBuilder()
+            .addTransform(
+                Transform.newBuilder().setCompress(CompressTransform.getDefaultInstance()).build())
+            .build();
+    Transforms readTransforms =
+        Transforms.newBuilder()
+            .addTransform(Transform.newBuilder().setZip(ZipTransform.getDefaultInstance()).build())
+            .build();
+
+    DataFile dataFileExternal =
+        DataFile.newBuilder()
+            .setFileId("test-file")
+            .setUrlToDownload("https://url.to.download")
+            .setByteSize(10)
+            .setChecksumType(DataFile.ChecksumType.NONE)
+            .setChecksum("testchecksum")
+            .setDownloadTransforms(downloadTransforms)
+            .setDownloadedFileChecksum("testdownloadedchecksum")
+            .setDownloadedFileByteSize(100)
+            .setReadTransforms(readTransforms)
+            .setAndroidSharingType(DataFile.AndroidSharingType.ANDROID_BLOB_WHEN_AVAILABLE)
+            .setAndroidSharingChecksumType(DataFile.AndroidSharingChecksumType.SHA2_256)
+            .setAndroidSharingChecksum("testandroidsharingchecksum")
+            .setRelativeFilePath("relative/file/path")
+            .addDeltaFile(DeltaFile.newBuilder().setUrlToDownload("url1").build())
+            .addDeltaFile(DeltaFile.newBuilder().setUrlToDownload("url2").build())
+            .build();
+
+    MetadataProto.DataFile dataFileInternal = ProtoConversionUtil.convertDataFile(dataFileExternal);
+
+    assertThat(dataFileInternal.getFileId()).isEqualTo("test-file");
+    assertThat(dataFileInternal.getUrlToDownload()).isEqualTo("https://url.to.download");
+    assertThat(dataFileInternal.getByteSize()).isEqualTo(10);
+    assertThat(dataFileInternal.getChecksumType())
+        .isEqualTo(MetadataProto.DataFile.ChecksumType.NONE);
+    assertThat(dataFileInternal.getChecksum()).isEqualTo("testchecksum");
+    assertThat(dataFileInternal.getDownloadTransforms()).isEqualTo(downloadTransforms);
+    assertThat(dataFileInternal.getDownloadedFileChecksum()).isEqualTo("testdownloadedchecksum");
+    assertThat(dataFileInternal.getDownloadedFileByteSize()).isEqualTo(100);
+    assertThat(dataFileInternal.getReadTransforms()).isEqualTo(readTransforms);
+    assertThat(dataFileInternal.getAndroidSharingType())
+        .isEqualTo(MetadataProto.DataFile.AndroidSharingType.ANDROID_BLOB_WHEN_AVAILABLE);
+    assertThat(dataFileInternal.getAndroidSharingChecksumType())
+        .isEqualTo(MetadataProto.DataFile.AndroidSharingChecksumType.SHA2_256);
+    assertThat(dataFileInternal.getAndroidSharingChecksum())
+        .isEqualTo("testandroidsharingchecksum");
+    assertThat(dataFileInternal.getRelativeFilePath()).isEqualTo("relative/file/path");
+
+    assertThat(dataFileInternal.getDeltaFileCount()).isEqualTo(2);
+    assertThat(dataFileInternal.getDeltaFileList())
+        .comparingElementsUsing(
+            Correspondence.transforming(MetadataProto.DeltaFile::getUrlToDownload, "using url"))
+        .containsExactly("url1", "url2");
+  }
+
+  @Test
+  public void convertDeltaFile_convertsFields() {
+    DeltaFile deltaFileExternal =
+        DeltaFile.newBuilder()
+            .setUrlToDownload("https://url.to.download")
+            .setByteSize(10)
+            .setChecksum("testchecksum")
+            .setDiffDecoder(DeltaFile.DiffDecoder.VC_DIFF)
+            .setBaseFile(BaseFile.newBuilder().setChecksum("testbasechecksum").build())
+            .build();
+
+    MetadataProto.DeltaFile deltaFileInternal =
+        ProtoConversionUtil.convertDeltaFile(deltaFileExternal);
+
+    assertThat(deltaFileInternal.getUrlToDownload()).isEqualTo("https://url.to.download");
+    assertThat(deltaFileInternal.getByteSize()).isEqualTo(10);
+    assertThat(deltaFileInternal.getChecksum()).isEqualTo("testchecksum");
+    assertThat(deltaFileInternal.getDiffDecoder())
+        .isEqualTo(MetadataProto.DeltaFile.DiffDecoder.VC_DIFF);
+    assertThat(deltaFileInternal.getBaseFile().getChecksum()).isEqualTo("testbasechecksum");
+  }
+
+  private static DataFileGroup getDataFileGroupFromTextProto(int rawResId) {
+    return ProtoParsers.parseFromRawRes(
+        ApplicationProvider.getApplicationContext(), DataFileGroup.parser(), rawResId);
+  }
+
+  private static DataFileGroupInternal getDataFileGroupInternalFromTextProto(int rawResId) {
+    return ProtoParsers.parseFromRawRes(
+        ApplicationProvider.getApplicationContext(), DataFileGroupInternal.parser(), rawResId);
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/internal/util/testdata/BUILD b/javatests/com/google/android/libraries/mobiledatadownload/internal/util/testdata/BUILD
new file mode 100644
index 0000000..7b8b8eb
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/internal/util/testdata/BUILD
@@ -0,0 +1,67 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("//tools/build_rules/text_to_binary:def.bzl", "proto_data")
+
+package(
+    default_visibility = ["//:__subpackages__"],
+    licenses = ["notice"],
+)
+
+filegroup(
+    name = "raw_group",
+    testonly = 1,
+    srcs = [
+        "raw_group_with_extension",
+    ],
+)
+
+proto_data(
+    name = "group.pb",
+    src = "group.pb.txt",
+    out = "res/raw/group_data_pb",
+    proto_deps = [
+        "//proto:download_config_proto",
+    ],
+    proto_name = "mdi.download.DataFileGroup",
+)
+
+proto_data(
+    name = "group_internal.pb",
+    src = "group_internal.pb.txt",
+    out = "res/raw/group_internal_data_pb",
+    proto_deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_proto",
+    ],
+    proto_name = "mdi.download.internal.DataFileGroupInternal",
+)
+
+proto_data(
+    name = "group_optional_unset.pb",
+    src = "group_optional_unset.pb.txt",
+    out = "res/raw/group_optional_unset_data_pb",
+    proto_deps = [
+        "//proto:download_config_proto",
+    ],
+    proto_name = "mdi.download.DataFileGroup",
+)
+
+proto_data(
+    name = "group_internal_optional_unset.pb",
+    src = "group_internal_optional_unset.pb.txt",
+    out = "res/raw/group_internal_optional_unset_data_pb",
+    proto_deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_proto",
+    ],
+    proto_name = "mdi.download.internal.DataFileGroupInternal",
+)
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/internal/util/testdata/group.pb.txt b/javatests/com/google/android/libraries/mobiledatadownload/internal/util/testdata/group.pb.txt
new file mode 100644
index 0000000..76275d3
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/internal/util/testdata/group.pb.txt
@@ -0,0 +1,21 @@
+allowed_readers_enum: ALL_GOOGLE_APPS
+download_conditions {
+  device_network_policy: DOWNLOAD_FIRST_ON_WIFI_THEN_ON_ANY_NETWORK
+}
+file_group_version_number: 12
+file {
+  byte_size: 200
+  checksum: "checksum1"
+  file_id: "one"
+  url_to_download: "https://www.google.com/"
+}
+file {
+  byte_size: 500
+  checksum: "checksum2"
+  file_id: "two"
+  url_to_download: "https://www.instagram.com/"
+}
+group_name: "test-group"
+owner_package: "com.google.android.libraries.mobiledatadownload"
+stale_lifetime_secs: 123456
+traffic_tag: 3
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/internal/util/testdata/group_internal.pb.txt b/javatests/com/google/android/libraries/mobiledatadownload/internal/util/testdata/group_internal.pb.txt
new file mode 100644
index 0000000..76275d3
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/internal/util/testdata/group_internal.pb.txt
@@ -0,0 +1,21 @@
+allowed_readers_enum: ALL_GOOGLE_APPS
+download_conditions {
+  device_network_policy: DOWNLOAD_FIRST_ON_WIFI_THEN_ON_ANY_NETWORK
+}
+file_group_version_number: 12
+file {
+  byte_size: 200
+  checksum: "checksum1"
+  file_id: "one"
+  url_to_download: "https://www.google.com/"
+}
+file {
+  byte_size: 500
+  checksum: "checksum2"
+  file_id: "two"
+  url_to_download: "https://www.instagram.com/"
+}
+group_name: "test-group"
+owner_package: "com.google.android.libraries.mobiledatadownload"
+stale_lifetime_secs: 123456
+traffic_tag: 3
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/internal/util/testdata/group_internal_optional_unset.pb.txt b/javatests/com/google/android/libraries/mobiledatadownload/internal/util/testdata/group_internal_optional_unset.pb.txt
new file mode 100644
index 0000000..2ed71a9
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/internal/util/testdata/group_internal_optional_unset.pb.txt
@@ -0,0 +1,18 @@
+allowed_readers_enum: ALL_GOOGLE_APPS
+file {
+  checksum_type: NONE
+  file_id: "one"
+  url_to_download: "https://www.google.com/"
+}
+file {
+  checksum_type: NONE
+  file_id: "two"
+  url_to_download: "https://www.instagram.com/"
+}
+file {
+  checksum_type: NONE
+  file_id: "three"
+  url_to_download: "https://stackoverflow.com/"
+}
+group_name: "test-group"
+owner_package: "com.google.android.libraries.mobiledatadownload"
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/internal/util/testdata/group_optional_unset.pb.txt b/javatests/com/google/android/libraries/mobiledatadownload/internal/util/testdata/group_optional_unset.pb.txt
new file mode 100644
index 0000000..2ed71a9
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/internal/util/testdata/group_optional_unset.pb.txt
@@ -0,0 +1,18 @@
+allowed_readers_enum: ALL_GOOGLE_APPS
+file {
+  checksum_type: NONE
+  file_id: "one"
+  url_to_download: "https://www.google.com/"
+}
+file {
+  checksum_type: NONE
+  file_id: "two"
+  url_to_download: "https://www.instagram.com/"
+}
+file {
+  checksum_type: NONE
+  file_id: "three"
+  url_to_download: "https://stackoverflow.com/"
+}
+group_name: "test-group"
+owner_package: "com.google.android.libraries.mobiledatadownload"
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/internal/util/testdata/raw_group_with_extension b/javatests/com/google/android/libraries/mobiledatadownload/internal/util/testdata/raw_group_with_extension
new file mode 100644
index 0000000..354f8f4
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/internal/util/testdata/raw_group_with_extension
@@ -0,0 +1,4 @@
+
+
+test-group,https://test-group_0 
+*1230:test-group_0,https://test-group_1 *1231:test-group_1ò˜“µè
\ No newline at end of file
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/lite/AndroidManifest.xml b/javatests/com/google/android/libraries/mobiledatadownload/lite/AndroidManifest.xml
new file mode 100644
index 0000000..1083941
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/lite/AndroidManifest.xml
@@ -0,0 +1,36 @@
+<!--
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+-->
+<manifest
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.google.android.libraries.mobiledatadownload.lite">
+
+  <uses-sdk android:minSdkVersion="16"/>
+
+  <uses-permission android:name="android.permission.INTERNET"/>
+  <uses-permission
+      android:name="com.google.android.gms.permission.BIND_NETWORK_TASK_SERVICE"/>
+  <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
+
+  <application android:name="android.support.multidex.MultiDexApplication">
+  </application>
+
+  <instrumentation
+      android:name="com.google.android.apps.common.testing.testrunner.Google3InstrumentationTestRunner"
+      android:targetPackage="com.google.android.libraries.mobiledatadownload.lite" />
+
+</manifest>
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/lite/BUILD b/javatests/com/google/android/libraries/mobiledatadownload/lite/BUILD
new file mode 100644
index 0000000..d44327a
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/lite/BUILD
@@ -0,0 +1,56 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("//javatests/com/google/android/libraries/mobiledatadownload:test_defs.bzl", "mdd_local_test")
+
+package(
+    default_visibility = ["//:__subpackages__"],
+    licenses = ["notice"],
+)
+
+mdd_local_test(
+    name = "DownloadProgressMonitorTest",
+    srcs = ["DownloadProgressMonitorTest.java"],
+    test_class = "com.google.android.libraries.mobiledatadownload.lite.DownloadProgressMonitorTest",
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/spi",
+        "//java/com/google/android/libraries/mobiledatadownload/lite:DownloadListener",
+        "//java/com/google/android/libraries/mobiledatadownload/lite:DownloadProgressMonitor",
+        "//javatests/com/google/android/libraries/mobiledatadownload/testing:FakeTimeSource",
+        "@android_sdk_linux",
+        "@com_google_guava_guava",
+        "@mockito",
+        "@truth",
+    ],
+)
+
+mdd_local_test(
+    name = "DownloaderImplTest",
+    srcs = ["DownloaderImplTest.java"],
+    manifest = "AndroidManifest.xml",
+    test_class = "com.google.android.libraries.mobiledatadownload.lite.DownloaderImplTest",
+    runtime_deps = ["//third_party/java/robolectric:multidex"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload:DownloadException",
+        "//java/com/google/android/libraries/mobiledatadownload/downloader:FileDownloader",
+        "//java/com/google/android/libraries/mobiledatadownload/lite",
+        "//java/com/google/android/libraries/mobiledatadownload/lite:DownloadListener",
+        "//java/com/google/android/libraries/mobiledatadownload/lite:DownloadProgressMonitor",
+        "//javatests/com/google/android/libraries/mobiledatadownload/testing:BlockingFileDownloader",
+        "@android_sdk_linux",
+        "@androidx_test",
+        "@com_google_guava_guava",
+        "@mockito",
+        "@truth",
+    ],
+)
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/lite/DownloadProgressMonitorTest.java b/javatests/com/google/android/libraries/mobiledatadownload/lite/DownloadProgressMonitorTest.java
new file mode 100644
index 0000000..da85b02
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/lite/DownloadProgressMonitorTest.java
@@ -0,0 +1,135 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.lite;
+
+import static com.google.android.libraries.mobiledatadownload.lite.DownloadProgressMonitor.BUFFERED_TIME_MS;
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.file.spi.Monitor.OutputMonitor;
+import com.google.android.libraries.mobiledatadownload.testing.FakeTimeSource;
+import com.google.common.util.concurrent.MoreExecutors;
+import java.util.concurrent.Executor;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public class DownloadProgressMonitorTest {
+
+  @Rule public final MockitoRule mocks = MockitoJUnit.rule();
+
+  // Use directExecutor to ensure the order of test verification.
+  private static final Executor CONTROL_EXECUTOR = MoreExecutors.directExecutor();
+
+  private static final String FILE_URI_1 =
+      "android://com.google.android.gms/files/datadownload/shared/public/file_1";
+
+  // Note: We can't make those android uris static variable since the Uri.parse will fail
+  // with initialization.
+  private final Uri uri1 = Uri.parse(FILE_URI_1);
+
+  private static final String FILE_URI_2 =
+      "android://com.google.android.gms/files/datadownload/shared/public/file_2";
+  private final Uri uri2 = Uri.parse(FILE_URI_2);
+
+  private static final String FILE_URI_3 =
+      "android://com.google.android.gms/files/datadownload/shared/public/file_3";
+  private final Uri uri3 = Uri.parse(FILE_URI_3);
+
+  private DownloadProgressMonitor downloadMonitor;
+  private final FakeTimeSource clock = new FakeTimeSource();
+
+  @Mock private DownloadListener mockDownloadListener1;
+  @Mock private DownloadListener mockDownloadListener2;
+
+  @Before
+  public void setUp() throws Exception {
+    downloadMonitor = DownloadProgressMonitor.create(clock, CONTROL_EXECUTOR);
+  }
+
+  @Test
+  public void bytesWritten_cleanSlate() {
+    downloadMonitor.addDownloadListener(uri1, mockDownloadListener1);
+    downloadMonitor.addDownloadListener(uri2, mockDownloadListener2);
+
+    OutputMonitor outputMonitor1 = downloadMonitor.monitorWrite(uri1);
+    OutputMonitor outputMonitor2 = downloadMonitor.monitorWrite(uri1);
+    OutputMonitor outputMonitor3 = downloadMonitor.monitorWrite(uri3);
+
+    // outputMonitor1 is same as outputMonitor2 since they both monitor for uri1.
+    assertThat(outputMonitor1).isSameInstanceAs(outputMonitor2);
+    assertThat(outputMonitor1).isNotSameInstanceAs(outputMonitor3);
+
+    // The 1st bytesWritten was buffered.
+    outputMonitor1.bytesWritten(new byte[1], 0, 1);
+    verifyNoInteractions(mockDownloadListener1);
+
+    // Now the buffered time passed.
+    clock.advance(BUFFERED_TIME_MS + 100, MILLISECONDS);
+
+    // The 2nd bytesWritten now triggered onProgress.
+    outputMonitor1.bytesWritten(new byte[1], 1, 2);
+    // 1 (first bytesWritten) + 2 (2nd bytesWritten)
+    verify(mockDownloadListener1).onProgress(1 + 2);
+
+    // The 3rd bytesWritten was buffered
+    outputMonitor1.bytesWritten(new byte[1], 3, 4);
+    verifyNoMoreInteractions(mockDownloadListener1);
+
+    // Now the buffered time passed again.
+    clock.advance(BUFFERED_TIME_MS + 100, MILLISECONDS);
+
+    // The 4th bytesWritten now triggered onProgress.
+    outputMonitor1.bytesWritten(new byte[1], 7, 8);
+    verify(mockDownloadListener1).onProgress(1 + 2 + 4 + 8);
+
+    // No bytes were downloaded for FileGroup2.
+    verifyNoInteractions(mockDownloadListener2);
+  }
+
+  @Test
+  public void bytesWritten_addAndRemoveDownloadListener() {
+
+    // There is no download listener for uri1.
+    assertThat(downloadMonitor.monitorWrite(uri1)).isNull();
+
+    // Adding a listener now.
+    downloadMonitor.addDownloadListener(uri1, mockDownloadListener1);
+
+    // Removing the listener.
+    downloadMonitor.removeDownloadListener(uri1);
+    assertThat(downloadMonitor.monitorWrite(uri1)).isNull();
+  }
+
+  @Test
+  public void downloaderLifecycleCallback() {
+    downloadMonitor.addDownloadListener(uri1, mockDownloadListener1);
+    downloadMonitor.addDownloadListener(uri2, mockDownloadListener2);
+    downloadMonitor.pausedForConnectivity();
+    verify(mockDownloadListener1).onPausedForConnectivity();
+    verify(mockDownloadListener2).onPausedForConnectivity();
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/lite/DownloaderImplTest.java b/javatests/com/google/android/libraries/mobiledatadownload/lite/DownloaderImplTest.java
new file mode 100644
index 0000000..abd2c07
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/lite/DownloaderImplTest.java
@@ -0,0 +1,1031 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.lite;
+
+import static com.google.android.libraries.mobiledatadownload.DownloadException.DownloadResultCode.ANDROID_DOWNLOADER_UNKNOWN;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.content.Context;
+import android.net.Uri;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.android.libraries.mobiledatadownload.DownloadException;
+import com.google.android.libraries.mobiledatadownload.downloader.DownloadConstraints;
+import com.google.android.libraries.mobiledatadownload.downloader.FileDownloader;
+import com.google.android.libraries.mobiledatadownload.testing.BlockingFileDownloader;
+import com.google.common.base.Optional;
+import com.google.common.base.Supplier;
+import com.google.common.labs.concurrent.LabsFutures;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public final class DownloaderImplTest {
+  @Rule public final MockitoRule mocks = MockitoJUnit.rule();
+
+  // Use directExecutor to ensure the order of test verification.
+  private static final Executor CONTROL_EXECUTOR = MoreExecutors.directExecutor();
+  private static final Executor BACKGROUND_EXECUTOR = Executors.newCachedThreadPool();
+  private static final ScheduledExecutorService DOWNLOAD_EXECUTOR =
+      Executors.newScheduledThreadPool(2);
+  ListeningExecutorService listeningExecutorService =
+      MoreExecutors.listeningDecorator(DOWNLOAD_EXECUTOR);
+
+  // 1MB file.
+  private static final String FILE_URL =
+      "https://www.gstatic.com/icing/idd/sample_group/sample_file_3_1519240701";
+
+  @Mock private SingleFileDownloadProgressMonitor mockDownloadMonitor;
+  @Mock private DownloadListener mockDownloadListener;
+  private Downloader downloader;
+  private Context context;
+  private DownloadRequest downloadRequest;
+  private final Uri destinationFileUri =
+      Uri.parse(
+          "android://com.google.android.libraries.mobiledatadownload/files/datadownload/shared/public/file_1");
+
+  @Mock private FileDownloader fileDownloader;
+
+  @Captor ArgumentCaptor<DownloadListener> downloadListenerCaptor;
+
+  @Captor
+  ArgumentCaptor<com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest>
+      downloadRequestCaptor;
+
+  @Before
+  public void setUp() {
+    context = ApplicationProvider.getApplicationContext();
+    downloader =
+        Downloader.newBuilder()
+            .setContext(context)
+            .setControlExecutor(CONTROL_EXECUTOR)
+            .setDownloadMonitor(mockDownloadMonitor)
+            .setFileDownloaderSupplier(() -> fileDownloader)
+            .setForegroundDownloadService(this.getClass()) // don't need to use the real one.
+            .build();
+
+    downloadRequest =
+        DownloadRequest.newBuilder()
+            .setDestinationFileUri(destinationFileUri)
+            .setUrlToDownload(FILE_URL)
+            .setDownloadConstraints(DownloadConstraints.NETWORK_CONNECTED)
+            .setNotificationContentTitle("File url: " + FILE_URL)
+            .build();
+
+    when(mockDownloadListener.onComplete()).thenReturn(Futures.immediateFuture(null));
+  }
+
+  @Test
+  public void download_whenRequestAlreadyMade_dedups() throws Exception {
+    // Use BlockingFileDownloader to ensure first download is in progress.
+    BlockingFileDownloader blockingFileDownloader =
+        new BlockingFileDownloader(listeningExecutorService);
+    Supplier<FileDownloader> blockingDownloaderSupplier = () -> blockingFileDownloader;
+
+    DownloaderImpl downloaderImpl =
+        new DownloaderImpl(
+            context,
+            Optional.absent(),
+            CONTROL_EXECUTOR,
+            Optional.of(mockDownloadMonitor),
+            blockingDownloaderSupplier);
+
+    int downloadFuturesInFlightCountBefore = downloaderImpl.keyToListenableFuture.size();
+
+    ListenableFuture<Void> downloadFuture1 = downloaderImpl.download(downloadRequest);
+    ListenableFuture<Void> downloadFuture2 = downloaderImpl.download(downloadRequest);
+
+    assertThat(downloaderImpl.keyToListenableFuture).containsKey(destinationFileUri.toString());
+    assertThat(downloaderImpl.keyToListenableFuture.size() - downloadFuturesInFlightCountBefore)
+        .isEqualTo(1);
+
+    // Allow blocking download to finish
+    blockingFileDownloader.finishDownloading();
+
+    // Finish future 2 and assert that future 1 has completed as well
+    downloadFuture2.get();
+    assertThat(downloadFuture1.isDone()).isTrue();
+
+    // TODO(b/155918406): Convert to Framework test and use TestingTaskBarrier to avoid sleep.
+    // Sleep for 1 sec to wait for the Future's callback to finish.
+    Thread.sleep(/* millis = */ 1000);
+
+    // The completed download should be removed from keyToListenableFuture map.
+    assertThat(downloaderImpl.keyToListenableFuture)
+        .doesNotContainKey(destinationFileUri.toString());
+    assertThat(downloaderImpl.keyToListenableFuture).hasSize(downloadFuturesInFlightCountBefore);
+
+    // Reset state of blockingFileDownloader to prevent deadlocks
+    blockingFileDownloader.resetState();
+  }
+
+  @Test
+  public void download_whenRequestAlreadyMadeUsingForegroundService_dedups() throws Exception {
+    // Use BlockingFileDownloader to ensure first download is in progress.
+    BlockingFileDownloader blockingFileDownloader =
+        new BlockingFileDownloader(listeningExecutorService);
+    Supplier<FileDownloader> blockingDownloaderSupplier = () -> blockingFileDownloader;
+
+    DownloaderImpl downloaderImpl =
+        new DownloaderImpl(
+            context,
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            CONTROL_EXECUTOR,
+            Optional.of(mockDownloadMonitor),
+            blockingDownloaderSupplier);
+
+    int downloadFuturesInFlightCountBefore = downloaderImpl.keyToListenableFuture.size();
+
+    ListenableFuture<Void> downloadFuture1 =
+        downloaderImpl.downloadWithForegroundService(downloadRequest);
+    ListenableFuture<Void> downloadFuture2 = downloaderImpl.download(downloadRequest);
+
+    assertThat(downloaderImpl.keyToListenableFuture).containsKey(destinationFileUri.toString());
+    assertThat(downloaderImpl.keyToListenableFuture.size() - downloadFuturesInFlightCountBefore)
+        .isEqualTo(1);
+
+    // Allow blocking download to finish
+    blockingFileDownloader.finishDownloading();
+
+    // Finish future 2 and assert that future 1 has completed as well
+    downloadFuture2.get();
+    assertThat(downloadFuture1.isDone()).isTrue();
+
+    // TODO(b/155918406): Convert to Framework test and use TestingTaskBarrier to avoid sleep.
+    // Sleep for 1 sec to wait for the Future's callback to finish.
+    Thread.sleep(/* millis = */ 1000);
+
+    // The completed download should be removed from keyToListenableFuture map.
+    assertThat(downloaderImpl.keyToListenableFuture)
+        .doesNotContainKey(destinationFileUri.toString());
+    assertThat(downloaderImpl.keyToListenableFuture).hasSize(downloadFuturesInFlightCountBefore);
+
+    // Reset state of blockingFileDownloader to prevent deadlocks
+    blockingFileDownloader.resetState();
+  }
+
+  @Test
+  public void download_beginsDownload() throws Exception {
+    when(fileDownloader.startDownloading(downloadRequestCaptor.capture()))
+        .thenReturn(Futures.immediateVoidFuture());
+
+    DownloaderImpl downloaderImpl =
+        new DownloaderImpl(
+            context,
+            Optional.absent(),
+            CONTROL_EXECUTOR,
+            Optional.of(mockDownloadMonitor),
+            () -> fileDownloader);
+
+    downloadRequest =
+        DownloadRequest.newBuilder()
+            .setDestinationFileUri(destinationFileUri)
+            .setUrlToDownload(FILE_URL)
+            .setDownloadConstraints(DownloadConstraints.NETWORK_CONNECTED)
+            .setListenerOptional(Optional.of(mockDownloadListener))
+            .build();
+
+    ListenableFuture<Void> downloadFuture = downloaderImpl.download(downloadRequest);
+    downloadFuture.get();
+
+    // TODO(b/155918406): Convert to Framework test and use TestingTaskBarrier to avoid sleep.
+    // Sleep for 1 sec to wait for the Future's callback to finish.
+    Thread.sleep(/* millis = */ 1000);
+
+    // Verify that correct DownloadRequest is sent to underlying FileDownloader
+    com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest
+        actualDownloadRequest = downloadRequestCaptor.getValue();
+    assertThat(actualDownloadRequest.fileUri()).isEqualTo(destinationFileUri);
+    assertThat(actualDownloadRequest.urlToDownload()).isEqualTo(FILE_URL);
+    assertThat(actualDownloadRequest.downloadConstraints())
+        .isEqualTo(DownloadConstraints.NETWORK_CONNECTED);
+
+    // Verify that downloadMonitor adds the listener
+    verify(mockDownloadMonitor)
+        .addDownloadListener(any(Uri.class), downloadListenerCaptor.capture());
+    verify(fileDownloader)
+        .startDownloading(
+            any(com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest.class));
+
+    verify(mockDownloadMonitor).removeDownloadListener(destinationFileUri);
+    verify(mockDownloadListener).onComplete();
+
+    // Ensure that given download listener is the same one passed to download monitor
+    DownloadListener capturedDownloadListener = downloadListenerCaptor.getValue();
+    DownloadException testException =
+        DownloadException.builder().setDownloadResultCode(ANDROID_DOWNLOADER_UNKNOWN).build();
+
+    capturedDownloadListener.onProgress(10);
+    capturedDownloadListener.onFailure(testException);
+    capturedDownloadListener.onPausedForConnectivity();
+
+    verify(mockDownloadListener).onProgress(10);
+    verify(mockDownloadListener).onFailure(testException);
+    verify(mockDownloadListener).onPausedForConnectivity();
+  }
+
+  @Test
+  public void download_whenListenerProvided_handlesOnCompleteFailed() throws Exception {
+    when(fileDownloader.startDownloading(downloadRequestCaptor.capture()))
+        .thenReturn(Futures.immediateVoidFuture());
+    when(mockDownloadListener.onComplete())
+        .thenReturn(Futures.immediateFailedFuture(new Exception("test failure")));
+
+    DownloaderImpl downloaderImpl =
+        new DownloaderImpl(
+            context,
+            Optional.absent(),
+            CONTROL_EXECUTOR,
+            Optional.of(mockDownloadMonitor),
+            () -> fileDownloader);
+
+    downloadRequest =
+        DownloadRequest.newBuilder()
+            .setDestinationFileUri(destinationFileUri)
+            .setUrlToDownload(FILE_URL)
+            .setDownloadConstraints(DownloadConstraints.NETWORK_CONNECTED)
+            .setListenerOptional(Optional.of(mockDownloadListener))
+            .build();
+
+    downloader.download(downloadRequest).get();
+
+    // TODO(b/155918406): Convert to Framework test and use TestingTaskBarrier to avoid sleep.
+    // Sleep for 1 sec to wait for the Future's callback to finish.
+    Thread.sleep(/* millis = */ 1000);
+
+    // Ensure that future is still removed from internal map
+    assertThat(downloaderImpl.keyToListenableFuture)
+        .doesNotContainKey(downloadRequest.destinationFileUri().toString());
+
+    // Verify that DownloadMonitor handled DownloadListener properly
+    verify(mockDownloadMonitor).addDownloadListener(destinationFileUri, mockDownloadListener);
+    verify(mockDownloadMonitor).removeDownloadListener(destinationFileUri);
+
+    // Verify that download was started
+    verify(fileDownloader).startDownloading(any());
+
+    // Verify the DownloadListeners onComplete was invoked
+    verify(mockDownloadListener).onComplete();
+  }
+
+  @Test
+  public void download_whenListenerProvided_waitsForOnCompleteToFinish() throws Exception {
+    when(fileDownloader.startDownloading(any())).thenReturn(Futures.immediateVoidFuture());
+
+    // Use a latch to simulate a long running DownloadListener.onComplete
+    CountDownLatch blockingOnCompleteLatch = new CountDownLatch(1);
+
+    DownloaderImpl downloaderImpl =
+        new DownloaderImpl(
+            context,
+            Optional.absent(),
+            CONTROL_EXECUTOR,
+            Optional.of(mockDownloadMonitor),
+            () -> fileDownloader);
+
+    downloadRequest =
+        DownloadRequest.newBuilder()
+            .setDestinationFileUri(destinationFileUri)
+            .setUrlToDownload(FILE_URL)
+            .setDownloadConstraints(DownloadConstraints.NETWORK_CONNECTED)
+            .setListenerOptional(
+                Optional.of(
+                    new DownloadListener() {
+                      @Override
+                      public void onProgress(long currentSize) {}
+
+                      @Override
+                      public void onPausedForConnectivity() {}
+
+                      @Override
+                      public void onFailure(Throwable t) {}
+
+                      @Override
+                      public ListenableFuture<Void> onComplete() {
+                        return Futures.submitAsync(
+                            () -> {
+                              try {
+                                // Verify that future map still contains download future.
+                                assertThat(downloaderImpl.keyToListenableFuture)
+                                    .containsKey(destinationFileUri.toString());
+                                blockingOnCompleteLatch.await();
+                              } catch (InterruptedException e) {
+                                // Ignore.
+                              }
+                              return Futures.immediateVoidFuture();
+                            },
+                            BACKGROUND_EXECUTOR);
+                      }
+                    }))
+            .build();
+
+    downloaderImpl.download(downloadRequest).get();
+
+    // Verify that the download future map still contains the download future.
+    assertThat(downloaderImpl.keyToListenableFuture).containsKey(destinationFileUri.toString());
+
+    // TODO(b/155918406): Convert to Framework test and use TestingTaskBarrier to avoid sleep.
+    // Sleep for 1 sec to wait for the Future's callback to finish.
+    Thread.sleep(/* millis = */ 1000);
+
+    // Finish the onComplete method.
+    blockingOnCompleteLatch.countDown();
+
+    // TODO(b/155918406): Convert to Framework test and use TestingTaskBarrier to avoid sleep.
+    // Sleep for 1 sec to wait for the Future's callback to finish.
+    Thread.sleep(/* millis = */ 1000);
+
+    // The completed download should be removed from keyToListenableFuture map.
+    assertThat(downloaderImpl.keyToListenableFuture)
+        .doesNotContainKey(destinationFileUri.toString());
+    assertThat(downloaderImpl.keyToListenableFuture).isEmpty();
+
+    // Verify DownloadListener was added/removed
+    verify(mockDownloadMonitor).addDownloadListener(eq(destinationFileUri), any());
+    verify(mockDownloadMonitor).removeDownloadListener(destinationFileUri);
+  }
+
+  @Test
+  public void download_whenDownloadFails_reportsFailure() throws Exception {
+    DownloadException downloadException =
+        DownloadException.builder().setDownloadResultCode(ANDROID_DOWNLOADER_UNKNOWN).build();
+    when(fileDownloader.startDownloading(downloadRequestCaptor.capture()))
+        .thenReturn(Futures.immediateFailedFuture(downloadException));
+
+    DownloaderImpl downloaderImpl =
+        new DownloaderImpl(
+            context,
+            Optional.absent(),
+            CONTROL_EXECUTOR,
+            Optional.of(mockDownloadMonitor),
+            () -> fileDownloader);
+
+    downloadRequest =
+        DownloadRequest.newBuilder()
+            .setDestinationFileUri(destinationFileUri)
+            .setUrlToDownload(FILE_URL)
+            .setDownloadConstraints(DownloadConstraints.NETWORK_CONNECTED)
+            .setListenerOptional(Optional.of(mockDownloadListener))
+            .build();
+
+    ListenableFuture<Void> downloadFuture = downloaderImpl.download(downloadRequest);
+    assertThrows(ExecutionException.class, downloadFuture::get);
+    DownloadException e = LabsFutures.getFailureCauseAs(downloadFuture, DownloadException.class);
+    assertThat(e.getDownloadResultCode()).isEqualTo(ANDROID_DOWNLOADER_UNKNOWN);
+
+    // Verify that file download was started and failed
+    verify(fileDownloader).startDownloading(any());
+    com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest
+        actualDownloadRequest = downloadRequestCaptor.getValue();
+    assertThat(actualDownloadRequest.fileUri()).isEqualTo(destinationFileUri);
+    assertThat(actualDownloadRequest.urlToDownload()).isEqualTo(FILE_URL);
+    assertThat(actualDownloadRequest.downloadConstraints())
+        .isEqualTo(DownloadConstraints.NETWORK_CONNECTED);
+
+    // Verify that DownloadMonitor added/removed DownloadListener
+    verify(mockDownloadMonitor).addDownloadListener(destinationFileUri, mockDownloadListener);
+    verify(mockDownloadMonitor).removeDownloadListener(destinationFileUri);
+
+    // Verify that DownloadListener.onFailure was invoked with failure
+    verify(mockDownloadListener).onFailure(downloadException);
+    verify(mockDownloadListener, times(0)).onComplete();
+  }
+
+  @Test
+  public void download_whenReturnedFutureIsCanceled_cancelsDownload() throws Exception {
+    // Use BlockingFileDownloader to simulate long download
+    BlockingFileDownloader blockingFileDownloader =
+        new BlockingFileDownloader(listeningExecutorService);
+
+    DownloaderImpl downloaderImpl =
+        new DownloaderImpl(
+            context,
+            Optional.absent(),
+            CONTROL_EXECUTOR,
+            Optional.of(mockDownloadMonitor),
+            () -> blockingFileDownloader);
+
+    ListenableFuture<Void> downloadFuture = downloaderImpl.download(downloadRequest);
+
+    assertThat(downloaderImpl.keyToListenableFuture)
+        .containsKey(downloadRequest.destinationFileUri().toString());
+
+    downloadFuture.cancel(true);
+
+    // The download future should no longer be included in the future map
+    assertThat(downloaderImpl.keyToListenableFuture)
+        .doesNotContainKey(downloadRequest.destinationFileUri().toString());
+
+    // Reset state of blocking file downloader to prevent deadlocks
+    blockingFileDownloader.resetState();
+  }
+
+  @Test
+  public void download_whenMonitorNotProvided_whenDownloadSucceeds_waitsForListenerOnComplete()
+      throws Exception {
+    when(fileDownloader.startDownloading(any())).thenReturn(Futures.immediateVoidFuture());
+
+    // Use a latch to simulate a long running DownloadListener.onComplete
+    CountDownLatch blockingOnCompleteLatch = new CountDownLatch(1);
+
+    DownloaderImpl downloaderImpl =
+        new DownloaderImpl(
+            context, Optional.absent(), CONTROL_EXECUTOR, Optional.absent(), () -> fileDownloader);
+
+    downloadRequest =
+        DownloadRequest.newBuilder()
+            .setDestinationFileUri(destinationFileUri)
+            .setUrlToDownload(FILE_URL)
+            .setDownloadConstraints(DownloadConstraints.NETWORK_CONNECTED)
+            .setListenerOptional(
+                Optional.of(
+                    new DownloadListener() {
+                      @Override
+                      public void onProgress(long currentSize) {}
+
+                      @Override
+                      public void onPausedForConnectivity() {}
+
+                      @Override
+                      public void onFailure(Throwable t) {}
+
+                      @Override
+                      public ListenableFuture<Void> onComplete() {
+                        return Futures.submitAsync(
+                            () -> {
+                              try {
+                                // Verify that future map still contains download future.
+                                assertThat(downloaderImpl.keyToListenableFuture)
+                                    .containsKey(destinationFileUri.toString());
+                                blockingOnCompleteLatch.await();
+                              } catch (InterruptedException e) {
+                                // Ignore.
+                              }
+                              return Futures.immediateVoidFuture();
+                            },
+                            BACKGROUND_EXECUTOR);
+                      }
+                    }))
+            .build();
+
+    downloaderImpl.download(downloadRequest).get();
+
+    // Verify that the download future map still contains the download future.
+    assertThat(downloaderImpl.keyToListenableFuture).containsKey(destinationFileUri.toString());
+
+    // TODO(b/155918406): Convert to Framework test and use TestingTaskBarrier to avoid sleep.
+    // Sleep for 1 sec to wait for the Future's callback to finish.
+    Thread.sleep(/* millis = */ 1000);
+
+    // Finish the onComplete method.
+    blockingOnCompleteLatch.countDown();
+
+    // TODO(b/155918406): Convert to Framework test and use TestingTaskBarrier to avoid sleep.
+    // Sleep for 1 sec to wait for the Future's callback to finish.
+    Thread.sleep(/* millis = */ 1000);
+
+    // The completed download should be removed from keyToListenableFuture map.
+    assertThat(downloaderImpl.keyToListenableFuture)
+        .doesNotContainKey(destinationFileUri.toString());
+    assertThat(downloaderImpl.keyToListenableFuture).isEmpty();
+  }
+
+  @Test
+  public void download_whenMonitorNotProvided_whenDownloadFails_reportsFailure() throws Exception {
+    DownloadException downloadException =
+        DownloadException.builder().setDownloadResultCode(ANDROID_DOWNLOADER_UNKNOWN).build();
+    when(fileDownloader.startDownloading(downloadRequestCaptor.capture()))
+        .thenReturn(Futures.immediateFailedFuture(downloadException));
+
+    DownloaderImpl downloaderImpl =
+        new DownloaderImpl(
+            context, Optional.absent(), CONTROL_EXECUTOR, Optional.absent(), () -> fileDownloader);
+
+    downloadRequest =
+        DownloadRequest.newBuilder()
+            .setDestinationFileUri(destinationFileUri)
+            .setUrlToDownload(FILE_URL)
+            .setDownloadConstraints(DownloadConstraints.NETWORK_CONNECTED)
+            .setListenerOptional(Optional.of(mockDownloadListener))
+            .build();
+
+    ListenableFuture<Void> downloadFuture = downloaderImpl.download(downloadRequest);
+    assertThrows(ExecutionException.class, downloadFuture::get);
+    DownloadException e = LabsFutures.getFailureCauseAs(downloadFuture, DownloadException.class);
+    assertThat(e.getDownloadResultCode()).isEqualTo(ANDROID_DOWNLOADER_UNKNOWN);
+
+    // Verify that file download was started and failed
+    verify(fileDownloader).startDownloading(any());
+    com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest
+        actualDownloadRequest = downloadRequestCaptor.getValue();
+    assertThat(actualDownloadRequest.fileUri()).isEqualTo(destinationFileUri);
+    assertThat(actualDownloadRequest.urlToDownload()).isEqualTo(FILE_URL);
+    assertThat(actualDownloadRequest.downloadConstraints())
+        .isEqualTo(DownloadConstraints.NETWORK_CONNECTED);
+
+    // Verify that DownloadListener.onFailure was invoked with failure
+    verify(mockDownloadListener).onFailure(downloadException);
+    verify(mockDownloadListener, times(0)).onComplete();
+  }
+
+  @Test
+  public void downloadWithForegroundService_requiresForegroundDownloadService() throws Exception {
+    // Create downloader without providing foreground service
+    downloader =
+        Downloader.newBuilder()
+            .setContext(context)
+            .setControlExecutor(CONTROL_EXECUTOR)
+            .setDownloadMonitor(mockDownloadMonitor)
+            .setFileDownloaderSupplier(() -> fileDownloader)
+            .build();
+
+    // Without foreground service, download call should fail with IllegalStateException
+    ListenableFuture<Void> downloadFuture =
+        downloader.downloadWithForegroundService(downloadRequest);
+    ExecutionException e = assertThrows(ExecutionException.class, downloadFuture::get);
+    assertThat(e).hasCauseThat().isInstanceOf(IllegalStateException.class);
+
+    // Verify that underlying download is not started
+    verify(mockDownloadMonitor, times(0))
+        .addDownloadListener(any(Uri.class), any(DownloadListener.class));
+    verify(fileDownloader, times(0))
+        .startDownloading(
+            any(com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest.class));
+  }
+
+  @Test
+  public void downloadWithForegroundService_requiresDownloadMonitor() throws Exception {
+    // Create downloader without providing DownloadMonitor
+    downloader =
+        Downloader.newBuilder()
+            .setContext(context)
+            .setControlExecutor(CONTROL_EXECUTOR)
+            .setForegroundDownloadService(
+                this.getClass()) // don't need to use the real foreground download service.
+            .setFileDownloaderSupplier(() -> fileDownloader)
+            .build();
+
+    // Without foreground service, download call should fail with IllegalStateException
+    ListenableFuture<Void> downloadFuture =
+        downloader.downloadWithForegroundService(downloadRequest);
+    ExecutionException e = assertThrows(ExecutionException.class, downloadFuture::get);
+    assertThat(e).hasCauseThat().isInstanceOf(IllegalStateException.class);
+
+    // Verify that underlying download is not started
+    verify(mockDownloadMonitor, times(0))
+        .addDownloadListener(any(Uri.class), any(DownloadListener.class));
+    verify(fileDownloader, times(0))
+        .startDownloading(
+            any(com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest.class));
+  }
+
+  @Test
+  public void downloadWithForegroundService_whenRequestAlreadyMade_dedups() throws Exception {
+    // Use BlockingFileDownloader to control when the download will finish.
+    BlockingFileDownloader blockingFileDownloader =
+        new BlockingFileDownloader(listeningExecutorService);
+    Supplier<FileDownloader> blockingDownloaderSupplier = () -> blockingFileDownloader;
+
+    DownloaderImpl downloaderImpl =
+        new DownloaderImpl(
+            context,
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            CONTROL_EXECUTOR,
+            Optional.of(mockDownloadMonitor),
+            blockingDownloaderSupplier);
+
+    int downloadFuturesInFlightCountBefore = downloaderImpl.keyToListenableFuture.size();
+
+    ListenableFuture<Void> downloadFuture1 =
+        downloaderImpl.downloadWithForegroundService(downloadRequest);
+    ListenableFuture<Void> downloadFuture2 =
+        downloaderImpl.downloadWithForegroundService(downloadRequest);
+
+    assertThat(downloaderImpl.keyToListenableFuture).containsKey(destinationFileUri.toString());
+
+    assertThat(downloaderImpl.keyToListenableFuture.size() - downloadFuturesInFlightCountBefore)
+        .isEqualTo(1);
+
+    // Now we let the 2 futures downloadFuture1 downloadFuture2 to run by opening the latch.
+    blockingFileDownloader.finishDownloading();
+
+    // Now finish future 2, future 1 should finish too and the cache clears the future.
+    downloadFuture2.get();
+    assertThat(downloadFuture1.isDone()).isTrue();
+
+    // TODO(b/147583059): Convert to Framework test and use TestingTaskBarrier to avoid sleep.
+    // Sleep for 1 sec to wait for the Future's callback to finish.
+    Thread.sleep(/*millis=*/ 1000);
+    // The completed download is removed from the uriToListenableFuture Map.
+    assertThat(downloaderImpl.keyToListenableFuture)
+        .doesNotContainKey(destinationFileUri.toString());
+    assertThat(downloaderImpl.keyToListenableFuture).hasSize(downloadFuturesInFlightCountBefore);
+
+    // Reset state of blockingFileDownloader to prevent deadlocks
+    blockingFileDownloader.resetState();
+  }
+
+  @Test
+  public void downloadWithForegroundService_whenRequestAlreadyMadeWithoutForegroundService_dedups()
+      throws Exception {
+    // Use BlockingFileDownloader to control when the download will finish.
+    BlockingFileDownloader blockingFileDownloader =
+        new BlockingFileDownloader(listeningExecutorService);
+    Supplier<FileDownloader> blockingDownloaderSupplier = () -> blockingFileDownloader;
+
+    DownloaderImpl downloaderImpl =
+        new DownloaderImpl(
+            context,
+            Optional.of(this.getClass()), // don't need to use the real foreground download service.
+            CONTROL_EXECUTOR,
+            Optional.of(mockDownloadMonitor),
+            blockingDownloaderSupplier);
+
+    int downloadFuturesInFlightCountBefore = downloaderImpl.keyToListenableFuture.size();
+
+    ListenableFuture<Void> downloadFuture1 = downloaderImpl.download(downloadRequest);
+    ListenableFuture<Void> downloadFuture2 =
+        downloaderImpl.downloadWithForegroundService(downloadRequest);
+
+    assertThat(downloaderImpl.keyToListenableFuture).containsKey(destinationFileUri.toString());
+
+    assertThat(downloaderImpl.keyToListenableFuture.size() - downloadFuturesInFlightCountBefore)
+        .isEqualTo(1);
+
+    // Now we let the 2 futures downloadFuture1 downloadFuture2 to run by opening the latch.
+    blockingFileDownloader.finishDownloading();
+
+    // Now finish future 2, future 1 should finish too and the cache clears the future.
+    downloadFuture2.get();
+    assertThat(downloadFuture1.isDone()).isTrue();
+
+    // TODO(b/155918406): Convert to Framework test and use TestingTaskBarrier to avoid sleep.
+    // Sleep for 1 sec to wait for the Future's callback to finish.
+    Thread.sleep(/*millis=*/ 1000);
+    // The completed download is removed from the uriToListenableFuture Map.
+    assertThat(downloaderImpl.keyToListenableFuture)
+        .doesNotContainKey(destinationFileUri.toString());
+    assertThat(downloaderImpl.keyToListenableFuture).hasSize(downloadFuturesInFlightCountBefore);
+
+    // Reset state of blockingFileDownloader to prevent deadlocks
+    blockingFileDownloader.resetState();
+  }
+
+  @Test
+  public void downloadWithForegroundService() throws ExecutionException, InterruptedException {
+    ArgumentCaptor<DownloadListener> downloadListenerCaptor =
+        ArgumentCaptor.forClass(DownloadListener.class);
+    ArgumentCaptor<com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest>
+        downloadRequestCaptor =
+            ArgumentCaptor.forClass(
+                com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest.class);
+    when(fileDownloader.startDownloading(downloadRequestCaptor.capture()))
+        .thenReturn(Futures.immediateFuture(null));
+
+    downloadRequest =
+        DownloadRequest.newBuilder()
+            .setDestinationFileUri(destinationFileUri)
+            .setUrlToDownload(FILE_URL)
+            .setDownloadConstraints(DownloadConstraints.NETWORK_CONNECTED)
+            .setNotificationContentTitle("File url: " + FILE_URL)
+            .setListenerOptional(Optional.of(mockDownloadListener))
+            .build();
+
+    ListenableFuture<Void> downloadFuture =
+        downloader.downloadWithForegroundService(downloadRequest);
+    downloadFuture.get();
+
+    // TODO(b/147583059): Convert to Framework test and use TestingTaskBarrier to avoid sleep.
+    // Sleep for 1 sec to wait for the Future's callback to finish.
+    Thread.sleep(/*millis=*/ 1000);
+
+    // Verify that the correct DownloadRequest is sent to underderlying FileDownloader.
+    com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest
+        actualDownloadRequest = downloadRequestCaptor.getValue();
+    assertThat(actualDownloadRequest.fileUri()).isEqualTo(destinationFileUri);
+    assertThat(actualDownloadRequest.urlToDownload()).isEqualTo(FILE_URL);
+    assertThat(actualDownloadRequest.downloadConstraints())
+        .isEqualTo(DownloadConstraints.NETWORK_CONNECTED);
+
+    // Verify that downloadMonitor will add a DownloadListener.
+    verify(mockDownloadMonitor)
+        .addDownloadListener(any(Uri.class), downloadListenerCaptor.capture());
+    verify(fileDownloader)
+        .startDownloading(
+            any(com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest.class));
+
+    verify(mockDownloadMonitor).removeDownloadListener(destinationFileUri);
+
+    verify(mockDownloadListener).onComplete();
+
+    // Now simulate other DownloadListener's callbacks:
+    downloadListenerCaptor.getValue().onProgress(10);
+    verify(mockDownloadListener).onProgress(10);
+    downloadListenerCaptor.getValue().onPausedForConnectivity();
+    DownloadException downloadException =
+        DownloadException.builder().setDownloadResultCode(ANDROID_DOWNLOADER_UNKNOWN).build();
+    downloadListenerCaptor.getValue().onFailure(downloadException);
+
+    verify(mockDownloadListener).onPausedForConnectivity();
+    verify(mockDownloadListener).onFailure(downloadException);
+  }
+
+  @Test
+  public void downloadWithForegroundService_clientOnCompleteFailed()
+      throws ExecutionException, InterruptedException {
+    ArgumentCaptor<DownloadListener> downloadListenerCaptor =
+        ArgumentCaptor.forClass(DownloadListener.class);
+    ArgumentCaptor<com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest>
+        downloadRequestCaptor =
+            ArgumentCaptor.forClass(
+                com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest.class);
+    when(fileDownloader.startDownloading(downloadRequestCaptor.capture()))
+        .thenReturn(Futures.immediateFuture(null));
+
+    // Client's provided DownloadListener.onComplete failed.
+    when(mockDownloadListener.onComplete())
+        .thenReturn(Futures.immediateFailedFuture(new Exception()));
+
+    downloadRequest =
+        DownloadRequest.newBuilder()
+            .setDestinationFileUri(destinationFileUri)
+            .setUrlToDownload(FILE_URL)
+            .setDownloadConstraints(DownloadConstraints.NETWORK_CONNECTED)
+            .setNotificationContentTitle("File url: " + FILE_URL)
+            .setListenerOptional(Optional.of(mockDownloadListener))
+            .build();
+
+    ListenableFuture<Void> downloadFuture =
+        downloader.downloadWithForegroundService(downloadRequest);
+    downloadFuture.get();
+
+    // TODO(b/147583059): Convert to Framework test and use TestingTaskBarrier to avoid sleep.
+    // Sleep for 1 sec to wait for the Future's callback to finish.
+    Thread.sleep(/*millis=*/ 1000);
+
+    // Verify that the correct DownloadRequest is sent to underderlying FileDownloader.
+    com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest
+        actualDownloadRequest = downloadRequestCaptor.getValue();
+    assertThat(actualDownloadRequest.fileUri()).isEqualTo(destinationFileUri);
+    assertThat(actualDownloadRequest.urlToDownload()).isEqualTo(FILE_URL);
+    assertThat(actualDownloadRequest.downloadConstraints())
+        .isEqualTo(DownloadConstraints.NETWORK_CONNECTED);
+
+    // Verify that downloadMonitor will add a DownloadListener.
+    verify(mockDownloadMonitor)
+        .addDownloadListener(any(Uri.class), downloadListenerCaptor.capture());
+    verify(fileDownloader)
+        .startDownloading(
+            any(com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest.class));
+
+    verify(mockDownloadMonitor).removeDownloadListener(destinationFileUri);
+
+    verify(mockDownloadListener).onComplete();
+  }
+
+  @Test
+  public void downloadWithForegroundService_clientOnCompleteBlocked()
+      throws ExecutionException, InterruptedException {
+    ArgumentCaptor<com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest>
+        downloadRequestCaptor =
+            ArgumentCaptor.forClass(
+                com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest.class);
+    when(fileDownloader.startDownloading(downloadRequestCaptor.capture()))
+        .thenReturn(Futures.immediateFuture(null));
+
+    // Using latch to block on client's onComplete to simulate a very long running onComplete.
+    CountDownLatch blockingOnCompleteLatch = new CountDownLatch(1);
+
+    DownloaderImpl downloaderImpl =
+        new DownloaderImpl(
+            context,
+            Optional.of(this.getClass()), // don't need to use the real foreground download service
+            CONTROL_EXECUTOR,
+            Optional.of(mockDownloadMonitor),
+            () -> fileDownloader);
+
+    downloadRequest =
+        DownloadRequest.newBuilder()
+            .setDestinationFileUri(destinationFileUri)
+            .setUrlToDownload(FILE_URL)
+            .setDownloadConstraints(DownloadConstraints.NETWORK_CONNECTED)
+            .setNotificationContentTitle("File url: " + FILE_URL)
+            .setListenerOptional(
+                Optional.of(
+                    new DownloadListener() {
+                      @Override
+                      public void onProgress(long currentSize) {}
+
+                      @Override
+                      public ListenableFuture<Void> onComplete() {
+                        return Futures.submitAsync(
+                            () -> {
+                              try {
+                                // Block the onComplete task.
+                                // Verify that before client's onComplete finishes, the on-going
+                                // download future map still contain this download. This means
+                                // the Foreground Download Service has not be shut down yet.
+                                assertThat(downloaderImpl.keyToListenableFuture)
+                                    .containsKey(destinationFileUri.toString());
+                                blockingOnCompleteLatch.await();
+                              } catch (InterruptedException e) {
+                                // Ignore.
+                              }
+                              return Futures.immediateFuture(null);
+                            },
+                            BACKGROUND_EXECUTOR);
+                      }
+
+                      @Override
+                      public void onFailure(Throwable t) {}
+
+                      @Override
+                      public void onPausedForConnectivity() {}
+                    }))
+            .build();
+
+    ListenableFuture<Void> downloadFuture =
+        downloaderImpl.downloadWithForegroundService(downloadRequest);
+    downloadFuture.get();
+
+    // TODO(b/147583059): Convert to Framework test and use TestingTaskBarrier to avoid sleep.
+    // Sleep for 1 sec to wait for the Future's callback to finish.
+    Thread.sleep(/*millis=*/ 1000);
+
+    // Verify that this download future has not been removed from the keyToListenableFuture map yet.
+    assertThat(downloaderImpl.keyToListenableFuture).containsKey(destinationFileUri.toString());
+
+    // Now let's the onComplete finishes.
+    blockingOnCompleteLatch.countDown();
+
+    // TODO(b/147583059): Convert to Framework test and use TestingTaskBarrier to avoid sleep.
+    // Sleep for 1 sec to wait for the Future's callback on onComplete to finish.
+    Thread.sleep(/*millis=*/ 1000);
+
+    // The completed download is removed from the keyToListenableFuture Map.
+    assertThat(downloaderImpl.keyToListenableFuture)
+        .doesNotContainKey(destinationFileUri.toString());
+    assertThat(downloaderImpl.keyToListenableFuture).isEmpty();
+
+    verify(mockDownloadMonitor).removeDownloadListener(destinationFileUri);
+  }
+
+  @Test
+  public void downloadWithForegroundService_failure()
+      throws ExecutionException, InterruptedException {
+    ArgumentCaptor<DownloadListener> downloadListenerCaptor =
+        ArgumentCaptor.forClass(DownloadListener.class);
+    ArgumentCaptor<com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest>
+        downloadRequestCaptor =
+            ArgumentCaptor.forClass(
+                com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest.class);
+    DownloadException downloadException =
+        DownloadException.builder().setDownloadResultCode(ANDROID_DOWNLOADER_UNKNOWN).build();
+
+    when(fileDownloader.startDownloading(downloadRequestCaptor.capture()))
+        .thenReturn(Futures.immediateFailedFuture(downloadException));
+
+    downloadRequest =
+        DownloadRequest.newBuilder()
+            .setDestinationFileUri(destinationFileUri)
+            .setUrlToDownload(FILE_URL)
+            .setDownloadConstraints(DownloadConstraints.NETWORK_CONNECTED)
+            .setNotificationContentTitle("File url: " + FILE_URL)
+            .setListenerOptional(Optional.of(mockDownloadListener))
+            .build();
+
+    ListenableFuture<Void> downloadFuture =
+        downloader.downloadWithForegroundService(downloadRequest);
+    assertThrows(ExecutionException.class, downloadFuture::get);
+    DownloadException e = LabsFutures.getFailureCauseAs(downloadFuture, DownloadException.class);
+    assertThat(e.getDownloadResultCode()).isEqualTo(ANDROID_DOWNLOADER_UNKNOWN);
+
+    // TODO(b/147583059): Convert to Framework test and use TestingTaskBarrier to avoid sleep.
+    // Sleep for 1 sec to wait for the listener to finish.
+    Thread.sleep(/*millis=*/ 1000);
+
+    // Verify that the correct DownloadRequest is sent to underderlying FileDownloader.
+    com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest
+        actualDownloadRequest = downloadRequestCaptor.getValue();
+    assertThat(actualDownloadRequest.fileUri()).isEqualTo(destinationFileUri);
+    assertThat(actualDownloadRequest.urlToDownload()).isEqualTo(FILE_URL);
+    assertThat(actualDownloadRequest.downloadConstraints())
+        .isEqualTo(DownloadConstraints.NETWORK_CONNECTED);
+
+    // Verify that downloadMonitor will add a DownloadListener.
+    verify(mockDownloadMonitor)
+        .addDownloadListener(any(Uri.class), downloadListenerCaptor.capture());
+    verify(fileDownloader)
+        .startDownloading(
+            any(com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest.class));
+
+    verify(mockDownloadMonitor).removeDownloadListener(destinationFileUri);
+
+    // Since the download failed, onComplete will not be called but onFailure.
+    verify(mockDownloadListener, times(0)).onComplete();
+    verify(mockDownloadListener).onFailure(downloadException);
+  }
+
+  @Test
+  public void cancelDownloadWithForegroundService() {
+    // Use BlockingFileDownloader to control when the download will finish.
+    BlockingFileDownloader blockingFileDownloader =
+        new BlockingFileDownloader(listeningExecutorService);
+    Supplier<FileDownloader> blockingDownloaderSupplier = () -> blockingFileDownloader;
+
+    DownloaderImpl downloaderImpl =
+        new DownloaderImpl(
+            context,
+            Optional.of(this.getClass()), // don't need to use the real foreground download service
+            CONTROL_EXECUTOR,
+            Optional.of(mockDownloadMonitor),
+            blockingDownloaderSupplier);
+
+    int downloadFuturesInFlightCountBefore = downloaderImpl.keyToListenableFuture.size();
+
+    ListenableFuture<Void> downloadFuture =
+        downloaderImpl.downloadWithForegroundService(downloadRequest);
+
+    assertThat(downloaderImpl.keyToListenableFuture).containsKey(destinationFileUri.toString());
+
+    assertThat(downloaderImpl.keyToListenableFuture.size() - downloadFuturesInFlightCountBefore)
+        .isEqualTo(1);
+
+    downloaderImpl.cancelForegroundDownload(destinationFileUri.toString());
+    assertTrue(downloadFuture.isCancelled());
+
+    // The completed download is removed from the uriToListenableFuture Map.
+    assertThat(downloaderImpl.keyToListenableFuture)
+        .doesNotContainKey(destinationFileUri.toString());
+    assertThat(downloaderImpl.keyToListenableFuture).hasSize(downloadFuturesInFlightCountBefore);
+
+    // Reset state of blockingFileDownloader to prevent deadlocks
+    blockingFileDownloader.resetState();
+  }
+
+  @Test
+  public void cancelListenableFuture() {
+    // Use BlockingFileDownloader to control when the download will finish.
+    BlockingFileDownloader blockingFileDownloader =
+        new BlockingFileDownloader(listeningExecutorService);
+    Supplier<FileDownloader> blockingDownloaderSupplier = () -> blockingFileDownloader;
+
+    DownloaderImpl downloaderImpl =
+        new DownloaderImpl(
+            context,
+            Optional.of(this.getClass()), // don't need to use the real foreground download service
+            CONTROL_EXECUTOR,
+            Optional.of(mockDownloadMonitor),
+            blockingDownloaderSupplier);
+
+    ListenableFuture<Void> downloadFuture =
+        downloaderImpl.downloadWithForegroundService(downloadRequest);
+
+    assertThat(downloaderImpl.keyToListenableFuture).containsKey(destinationFileUri.toString());
+
+    downloadFuture.cancel(true);
+
+    // The completed download is removed from the uriToListenableFuture Map.
+    assertThat(downloaderImpl.keyToListenableFuture)
+        .doesNotContainKey(destinationFileUri.toString());
+
+    // Reset state of blockingFileDownloader to prevent deadlocks
+    blockingFileDownloader.resetState();
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/monitor/BUILD b/javatests/com/google/android/libraries/mobiledatadownload/monitor/BUILD
new file mode 100644
index 0000000..d84b6be
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/monitor/BUILD
@@ -0,0 +1,54 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("//javatests/com/google/android/libraries/mobiledatadownload:test_defs.bzl", "mdd_local_test")
+
+package(
+    default_visibility = ["//:__subpackages__"],
+    licenses = ["notice"],
+)
+
+mdd_local_test(
+    name = "NetworkUsageMonitorTest",
+    srcs = ["NetworkUsageMonitorTest.java"],
+    test_class = "com.google.android.libraries.mobiledatadownload.monitor.NetworkUsageMonitorTest",
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/file/common/testing",
+        "//java/com/google/android/libraries/mobiledatadownload/file/spi",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LoggingStateStore",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:NoOpLoggingState",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/proto:metadata_java_proto_lite",
+        "//java/com/google/android/libraries/mobiledatadownload/monitor:NetworkUsageMonitor",
+        "//javatests/com/google/android/libraries/mobiledatadownload/testing:FakeTimeSource",
+        "@android_sdk_linux",
+        "@robolectric",
+        "@truth",
+    ],
+)
+
+mdd_local_test(
+    name = "DownloadProgressMonitorTest",
+    srcs = ["DownloadProgressMonitorTest.java"],
+    test_class = "com.google.android.libraries.mobiledatadownload.monitor.DownloadProgressMonitorTest",
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload:DownloadListener",
+        "//java/com/google/android/libraries/mobiledatadownload/file/spi",
+        "//java/com/google/android/libraries/mobiledatadownload/lite:DownloadListener",
+        "//java/com/google/android/libraries/mobiledatadownload/monitor:DownloadProgressMonitor",
+        "//javatests/com/google/android/libraries/mobiledatadownload/testing:FakeTimeSource",
+        "@android_sdk_linux",
+        "@com_google_guava_guava",
+        "@mockito",
+        "@truth",
+    ],
+)
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/monitor/DownloadProgressMonitorTest.java b/javatests/com/google/android/libraries/mobiledatadownload/monitor/DownloadProgressMonitorTest.java
new file mode 100644
index 0000000..8791987
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/monitor/DownloadProgressMonitorTest.java
@@ -0,0 +1,432 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.monitor;
+
+import static com.google.android.libraries.mobiledatadownload.monitor.DownloadProgressMonitor.LOG_FREQUENCY;
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.DownloadListener;
+import com.google.android.libraries.mobiledatadownload.file.spi.Monitor;
+import com.google.android.libraries.mobiledatadownload.testing.FakeTimeSource;
+import com.google.common.util.concurrent.MoreExecutors;
+import java.util.concurrent.Executor;
+import java.util.concurrent.TimeUnit;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public class DownloadProgressMonitorTest {
+
+  // Use directExecutor to ensure the order of test verification.
+  private static final Executor CONTROL_EXECUTOR = MoreExecutors.directExecutor();
+
+  private static final String GROUP_NAME_1 = "group-name-1";
+
+  private static final String GROUP_NAME_2 = "group-name-2";
+
+  private static final String FILE_URI_1 =
+      "android://com.google.android.gms/files/datadownload/shared/public/file_1";
+
+  // Note: We can't make those android uris static variable since the Uri.parse will fail
+  // with initialization.
+  private final Uri uri1 = Uri.parse(FILE_URI_1);
+
+  private static final String FILE_URI_2 =
+      "android://com.google.android.gms/files/datadownload/shared/public/file_2";
+  private final Uri uri2 = Uri.parse(FILE_URI_2);
+
+  private static final String FILE_URI_3 =
+      "android://com.google.android.gms/files/datadownload/shared/public/file_3";
+  private final Uri uri3 = Uri.parse(FILE_URI_3);
+
+  private DownloadProgressMonitor downloadMonitor;
+  private final FakeTimeSource clock = new FakeTimeSource();
+
+  @Mock private DownloadListener mockDownloadListener1;
+  @Mock private DownloadListener mockDownloadListener2;
+
+  @Mock
+  private com.google.android.libraries.mobiledatadownload.lite.DownloadListener
+      mockLiteDownloadListener1;
+
+  @Mock
+  private com.google.android.libraries.mobiledatadownload.lite.DownloadListener
+      mockLiteDownloadListener2;
+
+  @Rule public final MockitoRule mocks = MockitoJUnit.rule();
+
+  @Before
+  public void setUp() throws Exception {
+    downloadMonitor = new DownloadProgressMonitor(clock, CONTROL_EXECUTOR);
+  }
+
+  @Test
+  public void bytesWritten_cleanSlate() throws Exception {
+    downloadMonitor.addDownloadListener(GROUP_NAME_1, mockDownloadListener1);
+    downloadMonitor.addDownloadListener(GROUP_NAME_2, mockDownloadListener2);
+
+    // Setup 2 FileGroups:
+    // FileGroup1: file1 and file2.
+    // FileGroup2: file3.
+    downloadMonitor.monitorUri(uri1, GROUP_NAME_1);
+    downloadMonitor.monitorUri(uri2, GROUP_NAME_1);
+    downloadMonitor.monitorUri(uri3, GROUP_NAME_2);
+
+    Monitor.OutputMonitor outputMonitor1 = downloadMonitor.monitorWrite(uri1);
+    Monitor.OutputMonitor outputMonitor2 = downloadMonitor.monitorWrite(uri2);
+    Monitor.OutputMonitor outputMonitor3 = downloadMonitor.monitorWrite(uri3);
+
+    // outputMonitor1 is same as outputMonitor2 since they both monitor for FileGroup1.
+    assertThat(outputMonitor1).isSameInstanceAs(outputMonitor2);
+    assertThat(outputMonitor1).isNotSameInstanceAs(outputMonitor3);
+
+    // The 1st bytesWritten was buffered.
+    outputMonitor1.bytesWritten(new byte[1], 0, 1);
+    verifyNoInteractions(mockDownloadListener1);
+
+    // Now the buffered time passed.
+    clock.advance(LOG_FREQUENCY + 100L, TimeUnit.MILLISECONDS);
+
+    // The 2nd bytesWritten now triggered onProgress.
+    outputMonitor1.bytesWritten(new byte[1], 1, 2);
+    // 1 (first bytesWritten) + 2 (2nd bytesWritten)
+    verify(mockDownloadListener1).onProgress(1 + 2);
+
+    // The 3rd bytesWritten was buffered
+    outputMonitor1.bytesWritten(new byte[1], 3, 4);
+    verify(mockDownloadListener1, times(0)).onProgress(1 + 2 + 4);
+
+    // Now the buffered time passed again.
+    clock.advance(LOG_FREQUENCY + 100L, TimeUnit.MILLISECONDS);
+
+    // The 4th bytesWritten now triggered onProgress.
+    outputMonitor1.bytesWritten(new byte[1], 7, 8);
+    verify(mockDownloadListener1).onProgress(1 + 2 + 4 + 8);
+
+    // No bytes were downloaded for FileGroup2.
+    verifyNoInteractions(mockDownloadListener2);
+  }
+
+  @Test
+  public void bytesWritten_forSingleFileDownload_cleanSlate() throws Exception {
+    downloadMonitor.addDownloadListener(uri1, mockLiteDownloadListener1);
+    downloadMonitor.addDownloadListener(uri2, mockLiteDownloadListener2);
+
+    Monitor.OutputMonitor outputMonitor1 = downloadMonitor.monitorWrite(uri1);
+    Monitor.OutputMonitor duplicateOutputMonitor1 = downloadMonitor.monitorWrite(uri1);
+    Monitor.OutputMonitor outputMonitor3 = downloadMonitor.monitorWrite(uri3);
+
+    // outputMonitor1 is same as duplicateOutputMonitor1 since they both monitor for uri1.
+    assertThat(outputMonitor1).isSameInstanceAs(duplicateOutputMonitor1);
+    assertThat(outputMonitor1).isNotSameInstanceAs(outputMonitor3);
+
+    // The 1st bytesWritten was buffered.
+    outputMonitor1.bytesWritten(new byte[1], 0, 1);
+    verifyNoInteractions(mockLiteDownloadListener1);
+
+    // Now the buffered time passed.
+    clock.advance(LOG_FREQUENCY + 100L, TimeUnit.MILLISECONDS);
+
+    // The 2nd bytesWritten now triggered onProgress.
+    outputMonitor1.bytesWritten(new byte[1], 1, 2);
+    // 1 (first bytesWritten) + 2 (2nd bytesWritten)
+    verify(mockLiteDownloadListener1).onProgress(1 + 2);
+
+    // The 3rd bytesWritten was buffered
+    outputMonitor1.bytesWritten(new byte[1], 3, 4);
+    verify(mockLiteDownloadListener1, times(0)).onProgress(1 + 2 + 4);
+
+    // Now the buffered time passed again.
+    clock.advance(LOG_FREQUENCY + 100L, TimeUnit.MILLISECONDS);
+
+    // The 4th bytesWritten now triggered onProgress.
+    outputMonitor1.bytesWritten(new byte[1], 7, 8);
+    verify(mockLiteDownloadListener1).onProgress(1 + 2 + 4 + 8);
+
+    // No bytes were downloaded for uri2
+    verifyNoInteractions(mockLiteDownloadListener2);
+  }
+
+  @Test
+  public void bytesWritten_forBothFileAndFileGroupDownloads_cleanSlate() throws Exception {
+    downloadMonitor.addDownloadListener(GROUP_NAME_1, mockDownloadListener1);
+    downloadMonitor.addDownloadListener(uri2, mockLiteDownloadListener2);
+
+    // Setup 1 FileGroup
+    // FileGroup1: file1
+    // file2 is a single file download
+    downloadMonitor.monitorUri(uri1, GROUP_NAME_1);
+
+    Monitor.OutputMonitor outputMonitor1 = downloadMonitor.monitorWrite(uri1);
+    Monitor.OutputMonitor outputMonitor2 = downloadMonitor.monitorWrite(uri2);
+
+    // outputMonitor1 is not the same as outputMonitor2
+    assertThat(outputMonitor1).isNotSameInstanceAs(outputMonitor2);
+
+    // The 1st bytesWritten was buffered.
+    outputMonitor1.bytesWritten(new byte[1], 0, 1);
+    verifyNoInteractions(mockDownloadListener1);
+
+    outputMonitor2.bytesWritten(new byte[1], 0, 1);
+    verifyNoInteractions(mockLiteDownloadListener2);
+
+    // Now the buffered time passed.
+    clock.advance(LOG_FREQUENCY + 100L, TimeUnit.MILLISECONDS);
+
+    // The 2nd bytesWritten now triggered onProgress.
+    outputMonitor1.bytesWritten(new byte[1], 1, 2);
+    // 1 (first bytesWritten) + 2 (2nd bytesWritten)
+    verify(mockDownloadListener1).onProgress(1 + 2);
+
+    outputMonitor2.bytesWritten(new byte[1], 1, 2);
+    verify(mockLiteDownloadListener2).onProgress(1 + 2);
+
+    // The 3rd bytesWritten was buffered
+    outputMonitor1.bytesWritten(new byte[1], 3, 4);
+    verify(mockDownloadListener1, times(0)).onProgress(1 + 2 + 4);
+
+    outputMonitor2.bytesWritten(new byte[1], 3, 4);
+    verify(mockLiteDownloadListener2, times(0)).onProgress(1 + 2 + 4);
+
+    // Now the buffered time passed again.
+    clock.advance(LOG_FREQUENCY + 100L, TimeUnit.MILLISECONDS);
+
+    // The 4th bytesWritten now triggered onProgress.
+    outputMonitor1.bytesWritten(new byte[1], 7, 8);
+    verify(mockDownloadListener1).onProgress(1 + 2 + 4 + 8);
+
+    outputMonitor2.bytesWritten(new byte[1], 7, 8);
+    verify(mockLiteDownloadListener2).onProgress(1 + 2 + 4 + 8);
+  }
+
+  @Test
+  public void bytesWritten_partialAndDownloadedFiles() throws Exception {
+
+    downloadMonitor.addDownloadListener(GROUP_NAME_1, mockDownloadListener1);
+    downloadMonitor.addDownloadListener(GROUP_NAME_2, mockDownloadListener2);
+
+    // Setup a FileGroup with 3 files. file1 is partially downloaded (1000 bytes).
+    // file2 is empty. file3 is downloaded (2000 bytes).
+    downloadMonitor.monitorUri(uri1, GROUP_NAME_1);
+    downloadMonitor.notifyCurrentFileSize(GROUP_NAME_1, 1000 /* currentSize */);
+    downloadMonitor.monitorUri(uri2, GROUP_NAME_1);
+    downloadMonitor.notifyCurrentFileSize(GROUP_NAME_1, 2000 /* currentSize */);
+
+    Monitor.OutputMonitor outputMonitor1 = downloadMonitor.monitorWrite(uri1);
+    Monitor.OutputMonitor outputMonitor2 = downloadMonitor.monitorWrite(uri2);
+
+    // both monitors are the same since 3 files are from same file group.
+    assertThat(outputMonitor1).isSameInstanceAs(outputMonitor2);
+
+    // The 1st bytesWritten was buffered.
+    outputMonitor1.bytesWritten(new byte[1], 0, 1);
+    verifyNoInteractions(mockDownloadListener1);
+
+    // Now the buffered time passed.
+    clock.advance(LOG_FREQUENCY + 100L, TimeUnit.MILLISECONDS);
+
+    // The 2nd bytesWritten now triggered onProgress.
+    outputMonitor1.bytesWritten(new byte[1], 1, 2);
+    // 2000 (downloaded file) + 1000 (partially downloaded) + 1 (first bytesWritten) + 2 (2nd
+    // bytesWritten)
+    verify(mockDownloadListener1).onProgress(2000 + 1000 + 1 + 2);
+
+    // The 3rd bytesWritten was buffered
+    outputMonitor1.bytesWritten(new byte[1], 3, 4);
+    verify(mockDownloadListener1, times(0)).onProgress(2000 + 1000 + 1 + 2 + 4);
+
+    // Now the buffered time passed again.
+    clock.advance(LOG_FREQUENCY + 100L, TimeUnit.MILLISECONDS);
+
+    // The 4th bytesWritten now triggered onProgress.
+    outputMonitor1.bytesWritten(new byte[1], 7, 8);
+    verify(mockDownloadListener1).onProgress(2000 + 1000 + 1 + 2 + 4 + 8);
+
+    // No bytes were downloaded for FileGroup2.
+    verifyNoInteractions(mockDownloadListener2);
+  }
+
+  @Test
+  public void bytesWritten_addDownloadListener() throws Exception {
+
+    // There is no download listener for GROUP_NAME_1.
+    downloadMonitor.monitorUri(uri1, GROUP_NAME_1);
+    assertThat(downloadMonitor.monitorWrite(uri1)).isNull();
+
+    // Adding a listener now.
+    downloadMonitor.addDownloadListener(GROUP_NAME_1, mockDownloadListener1);
+    downloadMonitor.monitorUri(uri1, GROUP_NAME_1);
+
+    // Removing the listener.
+    downloadMonitor.removeDownloadListener(GROUP_NAME_1);
+    assertThat(downloadMonitor.monitorWrite(uri1)).isNull();
+  }
+
+  @Test
+  public void bytesWritten_forSingleFileDownload_addAndRemoveDownloadListener() throws Exception {
+    // There is no download listener for uri1.
+    assertThat(downloadMonitor.monitorWrite(uri1)).isNull();
+
+    // Adding a listener now.
+    downloadMonitor.addDownloadListener(uri1, mockLiteDownloadListener1);
+    assertThat(downloadMonitor.monitorWrite(uri1)).isNotNull();
+
+    // Removing the listener.
+    downloadMonitor.removeDownloadListener(uri1);
+    assertThat(downloadMonitor.monitorWrite(uri1)).isNull();
+  }
+
+  @Test
+  public void bytesWritten_forBothFileAndFileGroupDownloads_addAndRemoveDownloadListener()
+      throws Exception {
+    // There is no download listener for uri1.
+    assertThat(downloadMonitor.monitorWrite(uri1)).isNull();
+
+    // There is no download listener for GROUP_NAME_2
+    downloadMonitor.monitorUri(uri2, GROUP_NAME_2);
+    assertThat(downloadMonitor.monitorWrite(uri2)).isNull();
+
+    // Adding a listener for uri1
+    downloadMonitor.addDownloadListener(uri1, mockLiteDownloadListener1);
+    assertThat(downloadMonitor.monitorWrite(uri1)).isNotNull();
+    // uri2 (File Group 2) should still have no monitor.
+    assertThat(downloadMonitor.monitorWrite(uri2)).isNull();
+
+    // Adding a listener for uri2 (File Group 2)
+    downloadMonitor.addDownloadListener(GROUP_NAME_2, mockDownloadListener2);
+    downloadMonitor.monitorUri(uri2, GROUP_NAME_2);
+    assertThat(downloadMonitor.monitorWrite(uri2)).isNotNull();
+    // uri1 still has monitor
+    assertThat(downloadMonitor.monitorWrite(uri1)).isNotNull();
+
+    // Removing the listener for uri1
+    downloadMonitor.removeDownloadListener(uri1);
+    assertThat(downloadMonitor.monitorWrite(uri1)).isNull();
+    // uri2 (File Group 2) should still have monitor.
+    assertThat(downloadMonitor.monitorWrite(uri2)).isNotNull();
+
+    // Removing listener for uri2 (File Group 2)
+    downloadMonitor.removeDownloadListener(GROUP_NAME_2);
+    assertThat(downloadMonitor.monitorWrite(uri2)).isNull();
+    // uri1 still has no monitor
+    assertThat(downloadMonitor.monitorWrite(uri1)).isNull();
+  }
+
+  @Test
+  public void bytesWritten_downloadListenerRemoved() throws Exception {
+    // There is no download listener for GROUP_NAME_1.
+    downloadMonitor.monitorUri(uri1, GROUP_NAME_1);
+    assertThat(downloadMonitor.monitorWrite(uri1)).isNull();
+
+    // Adding a listener now.
+    downloadMonitor.addDownloadListener(GROUP_NAME_1, mockDownloadListener1);
+    downloadMonitor.monitorUri(uri1, GROUP_NAME_1);
+
+    Monitor.OutputMonitor outputMonitor = downloadMonitor.monitorWrite(uri1);
+
+    // Removing the listener.
+    downloadMonitor.removeDownloadListener(GROUP_NAME_1);
+    assertThat(downloadMonitor.monitorWrite(uri1)).isNull();
+
+    outputMonitor.bytesWritten(new byte[1], 3, 4);
+
+    // Make sure that onProgress is not triggered if the DownloadListener is no longer being used.
+    verifyNoInteractions(mockDownloadListener1);
+  }
+
+  @Test
+  public void bytesWritten_forSingleFileDownload_downloadListenerRemoved() throws Exception {
+    downloadMonitor.addDownloadListener(uri1, mockLiteDownloadListener1);
+
+    // Monitor should exist
+    Monitor.OutputMonitor outputMonitor = downloadMonitor.monitorWrite(uri1);
+    assertThat(outputMonitor).isNotNull();
+
+    // Removing the listener
+    downloadMonitor.removeDownloadListener(uri1);
+
+    // Monitor should not exist anymore
+    assertThat(downloadMonitor.monitorWrite(uri1)).isNull();
+
+    outputMonitor.bytesWritten(new byte[1], 3, 4);
+
+    // Make sure onProgress is not triggered if DownloadListener is not longer used.
+    verify(mockLiteDownloadListener1, never()).onProgress(anyLong());
+  }
+
+  @Test
+  public void bytesWritten_forBothFileAndFileGroupDownloads_downloadListenerRemoved()
+      throws Exception {
+    // There is no download listener for GROUP_NAME_1 and no listener for uri2
+    downloadMonitor.monitorUri(uri1, GROUP_NAME_1);
+    assertThat(downloadMonitor.monitorWrite(uri1)).isNull();
+    assertThat(downloadMonitor.monitorWrite(uri2)).isNull();
+
+    // Adding a listener for GROUP_NAME_1 and single file uri2
+    downloadMonitor.addDownloadListener(uri2, mockLiteDownloadListener2);
+    downloadMonitor.addDownloadListener(GROUP_NAME_1, mockDownloadListener1);
+    downloadMonitor.monitorUri(uri1, GROUP_NAME_1);
+
+    Monitor.OutputMonitor outputMonitor1 = downloadMonitor.monitorWrite(uri1);
+    Monitor.OutputMonitor outputMonitor2 = downloadMonitor.monitorWrite(uri2);
+
+    // Removing the listener.
+    downloadMonitor.removeDownloadListener(GROUP_NAME_1);
+    downloadMonitor.removeDownloadListener(uri2);
+
+    assertThat(downloadMonitor.monitorWrite(uri1)).isNull();
+    assertThat(downloadMonitor.monitorWrite(uri2)).isNull();
+
+    outputMonitor1.bytesWritten(new byte[1], 3, 4);
+    outputMonitor2.bytesWritten(new byte[1], 3, 4);
+
+    // Make sure that onProgress is not triggered if the DownloadListener is no longer being used.
+    verifyNoInteractions(mockDownloadListener1);
+    verifyNoInteractions(mockLiteDownloadListener2);
+  }
+
+  @Test
+  public void downloaderLifecycleCallback() throws Exception {
+    downloadMonitor.addDownloadListener(GROUP_NAME_1, mockDownloadListener1);
+    downloadMonitor.addDownloadListener(GROUP_NAME_2, mockDownloadListener2);
+    downloadMonitor.pausedForConnectivity();
+    verify(mockDownloadListener1).pausedForConnectivity();
+    verify(mockDownloadListener2).pausedForConnectivity();
+  }
+
+  @Test
+  public void downloaderLifecycleCallback_forFileAndFileGroupDownloads() throws Exception {
+    downloadMonitor.addDownloadListener(uri1, mockLiteDownloadListener1);
+    downloadMonitor.addDownloadListener(GROUP_NAME_2, mockDownloadListener2);
+
+    downloadMonitor.pausedForConnectivity();
+
+    verify(mockLiteDownloadListener1).onPausedForConnectivity();
+    verify(mockDownloadListener2).pausedForConnectivity();
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/monitor/NetworkUsageMonitorTest.java b/javatests/com/google/android/libraries/mobiledatadownload/monitor/NetworkUsageMonitorTest.java
new file mode 100644
index 0000000..86aadb4
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/monitor/NetworkUsageMonitorTest.java
@@ -0,0 +1,386 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.monitor;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import android.content.Context;
+import android.net.ConnectivityManager;
+import android.net.NetworkInfo.DetailedState;
+import android.net.Uri;
+import android.os.Build;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.android.libraries.mobiledatadownload.file.common.testing.TemporaryUri;
+import com.google.android.libraries.mobiledatadownload.file.spi.Monitor;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LoggingStateStore;
+import com.google.android.libraries.mobiledatadownload.internal.logging.NoOpLoggingState;
+import com.google.android.libraries.mobiledatadownload.testing.FakeTimeSource;
+import com.google.mobiledatadownload.internal.MetadataProto.GroupKey;
+import java.util.concurrent.Executor;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.Shadows;
+import org.robolectric.shadows.ShadowNetworkInfo;
+
+@RunWith(RobolectricTestRunner.class)
+public class NetworkUsageMonitorTest {
+
+  private static final Executor executor = directExecutor();
+  private static final String GROUP_NAME_1 = "group-name-1";
+  private static final String OWNER_PACKAGE_1 = "owner-package-1";
+  private static final String VARIANT_ID_1 = "variant-id-1";
+  private static final int VERSION_NUMBER_1 = 1;
+  private static final int BUILD_ID_1 = 123;
+
+  private static final String GROUP_NAME_2 = "group-name-2";
+  private static final String OWNER_PACKAGE_2 = "owner-package-2";
+  private static final String VARIANT_ID_2 = "variant-id-2";
+
+  private static final int VERSION_NUMBER_2 = 2;
+  private static final int BUILD_ID_2 = 456;
+
+  private static final String FILE_URI_1 =
+      "android://com.google.android.gms/files/datadownload/shared/public/file_1";
+
+  // Note: We can't make those android uris static variable since the Uri.parse will fail
+  // with initialization.
+  private final Uri uri1 = Uri.parse(FILE_URI_1);
+
+  private static final String FILE_URI_2 =
+      "android://com.google.android.gms/files/datadownload/shared/public/file_2";
+  private final Uri uri2 = Uri.parse(FILE_URI_2);
+
+  private static final String FILE_URI_3 =
+      "android://com.google.android.gms/files/datadownload/shared/public/file_3";
+  private final Uri uri3 = Uri.parse(FILE_URI_3);
+
+  private NetworkUsageMonitor networkUsageMonitor;
+  private LoggingStateStore loggingStateStore;
+  private Context context;
+  private final FakeTimeSource clock = new FakeTimeSource();
+
+  ConnectivityManager connectivityManager;
+
+  @Rule public final TemporaryUri tmpUri = new TemporaryUri();
+
+  @Before
+  public void setUp() throws Exception {
+    context = ApplicationProvider.getApplicationContext();
+
+    loggingStateStore = new NoOpLoggingState();
+
+    // TODO(b/177015303): use builder when available
+    networkUsageMonitor = new NetworkUsageMonitor(context, clock);
+
+    this.connectivityManager =
+        (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
+  }
+
+  private void setNetworkConnectivityType(int networkConnectivityType) {
+    Shadows.shadowOf(connectivityManager)
+        .setActiveNetworkInfo(
+            ShadowNetworkInfo.newInstance(
+                DetailedState.CONNECTED,
+                networkConnectivityType,
+                0 /* subtype */,
+                true /* isAvailable */,
+                true /* isConnected */));
+  }
+
+  @Test
+  public void testBytesWritten() throws Exception {
+    // Setup 2 FileGroups:
+    // FileGroup1: file1 and file2.
+    // FileGroup2: file3.
+
+    GroupKey groupKey1 =
+        GroupKey.newBuilder()
+            .setOwnerPackage(OWNER_PACKAGE_1)
+            .setGroupName(GROUP_NAME_1)
+            .setVariantId(VARIANT_ID_1)
+            .build();
+    networkUsageMonitor.monitorUri(
+        uri1, groupKey1, BUILD_ID_1, VERSION_NUMBER_1, loggingStateStore);
+    networkUsageMonitor.monitorUri(
+        uri2, groupKey1, BUILD_ID_1, VERSION_NUMBER_1, loggingStateStore);
+
+    GroupKey groupKey2 =
+        GroupKey.newBuilder()
+            .setOwnerPackage(OWNER_PACKAGE_2)
+            .setGroupName(GROUP_NAME_2)
+            .setVariantId(VARIANT_ID_2)
+            .build();
+
+    networkUsageMonitor.monitorUri(
+        uri3, groupKey2, BUILD_ID_2, VERSION_NUMBER_2, loggingStateStore);
+
+    Monitor.OutputMonitor outputMonitor1 = networkUsageMonitor.monitorWrite(uri1);
+    Monitor.OutputMonitor outputMonitor2 = networkUsageMonitor.monitorWrite(uri2);
+    Monitor.OutputMonitor outputMonitor3 = networkUsageMonitor.monitorWrite(uri3);
+
+    // outputMonitor1 is same as outputMonitor2 since they both monitor for FileGroup1.
+    assertThat(outputMonitor1).isSameInstanceAs(outputMonitor2);
+    assertThat(outputMonitor1).isNotSameInstanceAs(outputMonitor3);
+
+    // First we have WIFI connection.
+    // Downloaded 1 bytes on WIFI for uri1
+    setNetworkConnectivityType(ConnectivityManager.TYPE_WIFI);
+    outputMonitor1.bytesWritten(new byte[1], 0, 1);
+
+    // Downloaded 2 bytes on WIFI for uri1
+    outputMonitor1.bytesWritten(new byte[2], 0, 2);
+
+    // Downloaded 4 bytes on WIFI for uri2
+    outputMonitor2.bytesWritten(new byte[4], 0, 4);
+
+    // Downloaded 8 bytes on WIFI for uri3
+    outputMonitor3.bytesWritten(new byte[8], 0, 8);
+
+    // Then we have CELLULAR connection.
+    // Downloaded 16 bytes on CELLULAR for uri1
+    setNetworkConnectivityType(ConnectivityManager.TYPE_MOBILE);
+    outputMonitor1.bytesWritten(new byte[16], 0, 16);
+
+    // Downloaded 32 bytes on CELLULAR for uri2
+    outputMonitor2.bytesWritten(new byte[32], 0, 32);
+
+    // Downloaded 64 bytes on CELLULAR for uri3
+    outputMonitor3.bytesWritten(new byte[64], 0, 64);
+
+    // close() will trigger saving counters to LoggingStateStore.
+    outputMonitor1.close();
+    outputMonitor2.close();
+    outputMonitor3.close();
+
+    // await executors idle here if we switch from directExecutor...
+  }
+
+  @Test
+  public void testBytesWritten_multipleVersions() throws Exception {
+    // Setup 2 versions of a FileGroup:
+    // FileGroup v1: file1 and file2.
+    // FileGroup v2: file2 and file3.
+    GroupKey groupKey1 =
+        GroupKey.newBuilder()
+            .setOwnerPackage(OWNER_PACKAGE_1)
+            .setGroupName(GROUP_NAME_1)
+            .setVariantId(VARIANT_ID_1)
+            .build();
+    networkUsageMonitor.monitorUri(
+        uri1, groupKey1, BUILD_ID_1, VERSION_NUMBER_1, loggingStateStore);
+    networkUsageMonitor.monitorUri(
+        uri2, groupKey1, BUILD_ID_1, VERSION_NUMBER_1, loggingStateStore);
+
+    GroupKey groupKey2 =
+        GroupKey.newBuilder()
+            .setOwnerPackage(OWNER_PACKAGE_2)
+            .setGroupName(GROUP_NAME_2)
+            .setVariantId(VARIANT_ID_2)
+            .build();
+
+    // This would update uri2 to belong to FileGroup v2.
+    networkUsageMonitor.monitorUri(
+        uri2, groupKey2, BUILD_ID_2, VERSION_NUMBER_2, loggingStateStore);
+    networkUsageMonitor.monitorUri(
+        uri3, groupKey2, BUILD_ID_2, VERSION_NUMBER_2, loggingStateStore);
+
+    Monitor.OutputMonitor outputMonitor1 = networkUsageMonitor.monitorWrite(uri1);
+    Monitor.OutputMonitor outputMonitor2 = networkUsageMonitor.monitorWrite(uri2);
+    Monitor.OutputMonitor outputMonitor3 = networkUsageMonitor.monitorWrite(uri3);
+
+    // outputMonitor2 is same as outputMonitor3 since they both monitor for the same version of the
+    // same FileGroup.
+    assertThat(outputMonitor1).isNotSameInstanceAs(outputMonitor2);
+    assertThat(outputMonitor2).isSameInstanceAs(outputMonitor3);
+
+    // First we have WIFI connection.
+    // Downloaded 1 bytes on WIFI for uri1
+    setNetworkConnectivityType(ConnectivityManager.TYPE_WIFI);
+    outputMonitor1.bytesWritten(new byte[1], 0, 1);
+
+    // Downloaded 2 bytes on WIFI for uri1
+    outputMonitor1.bytesWritten(new byte[2], 0, 2);
+
+    // Downloaded 4 bytes on WIFI for uri2
+    outputMonitor2.bytesWritten(new byte[4], 0, 4);
+
+    // Downloaded 8 bytes on WIFI for uri3
+    outputMonitor3.bytesWritten(new byte[8], 0, 8);
+
+    // Then we have CELLULAR connection.
+    // Downloaded 16 bytes on CELLULAR for uri1
+    setNetworkConnectivityType(ConnectivityManager.TYPE_MOBILE);
+    outputMonitor1.bytesWritten(new byte[16], 0, 16);
+
+    // Downloaded 32 bytes on CELLULAR for uri2
+    outputMonitor2.bytesWritten(new byte[32], 0, 32);
+
+    // Downloaded 64 bytes on CELLULAR for uri3
+    outputMonitor3.bytesWritten(new byte[64], 0, 64);
+
+    // close() will trigger saving counters to SharedPreference.
+    outputMonitor1.close();
+    outputMonitor2.close();
+    outputMonitor3.close();
+  }
+
+  @Test
+  public void testBytesWritten_flush_interval() throws Exception {
+    // Setup 1 FileGroups:
+    // FileGroup1: file1
+
+    GroupKey groupKey1 =
+        GroupKey.newBuilder()
+            .setOwnerPackage(OWNER_PACKAGE_1)
+            .setGroupName(GROUP_NAME_1)
+            .setVariantId(VARIANT_ID_1)
+            .build();
+    networkUsageMonitor.monitorUri(
+        uri1, groupKey1, BUILD_ID_1, VERSION_NUMBER_1, loggingStateStore);
+
+    Monitor.OutputMonitor outputMonitor1 = networkUsageMonitor.monitorWrite(uri1);
+
+    // Advance time so counters are flushed
+    clock.advance(NetworkUsageMonitor.LOG_FREQUENCY_SECONDS + 1, SECONDS);
+
+    // Downloaded 1 bytes on WIFI for uri1
+    setNetworkConnectivityType(ConnectivityManager.TYPE_WIFI);
+    outputMonitor1.bytesWritten(new byte[1], 0, 1);
+
+    // Advance the clock by < LOG_FREQUENCY_SECONDS
+    clock.advance(1, MILLISECONDS);
+    outputMonitor1.bytesWritten(new byte[2], 0, 2);
+
+    clock.advance(1, MILLISECONDS);
+    outputMonitor1.bytesWritten(new byte[16], 0, 4);
+
+    // Only the 1st and 2nd chunks were saved.
+    assertThat(loggingStateStore.getAndResetAllDataUsage().get()).isEmpty();
+
+    // Advance the clock by > LOG_FREQUENCY_SECONDS
+    clock.advance(NetworkUsageMonitor.LOG_FREQUENCY_SECONDS + 1, SECONDS);
+    outputMonitor1.bytesWritten(new byte[16], 0, 8);
+  }
+
+  @Test
+  public void testBytesWritten_mix_write_append() throws Exception {
+    // Setup 2 FileGroups:
+    // FileGroup1: file1 and file2.
+    // FileGroup2: file3.
+
+    GroupKey groupKey1 =
+        GroupKey.newBuilder()
+            .setOwnerPackage(OWNER_PACKAGE_1)
+            .setGroupName(GROUP_NAME_1)
+            .setVariantId(VARIANT_ID_1)
+            .build();
+    networkUsageMonitor.monitorUri(
+        uri1, groupKey1, BUILD_ID_1, VERSION_NUMBER_1, loggingStateStore);
+    networkUsageMonitor.monitorUri(
+        uri2, groupKey1, BUILD_ID_1, VERSION_NUMBER_1, loggingStateStore);
+
+    GroupKey groupKey2 =
+        GroupKey.newBuilder()
+            .setOwnerPackage(OWNER_PACKAGE_2)
+            .setGroupName(GROUP_NAME_2)
+            .setVariantId(VARIANT_ID_2)
+            .build();
+
+    networkUsageMonitor.monitorUri(
+        uri3, groupKey2, BUILD_ID_2, VERSION_NUMBER_2, loggingStateStore);
+
+    Monitor.OutputMonitor outputMonitor1 = networkUsageMonitor.monitorWrite(uri1);
+    Monitor.OutputMonitor outputMonitor2 = networkUsageMonitor.monitorAppend(uri2);
+    Monitor.OutputMonitor outputMonitor3 = networkUsageMonitor.monitorAppend(uri3);
+
+    // outputMonitor1 is same as outputMonitor2 since they both monitor for FileGroup1.
+    assertThat(outputMonitor1).isSameInstanceAs(outputMonitor2);
+    assertThat(outputMonitor1).isNotSameInstanceAs(outputMonitor3);
+
+    // First we have WIFI connection.
+    // Downloaded 1 bytes on WIFI for uri1
+    setNetworkConnectivityType(ConnectivityManager.TYPE_WIFI);
+    outputMonitor1.bytesWritten(new byte[1], 0, 1);
+
+    // Downloaded 2 bytes on WIFI for uri1
+    outputMonitor1.bytesWritten(new byte[2], 0, 2);
+
+    // Downloaded 4 bytes on WIFI for uri2
+    outputMonitor2.bytesWritten(new byte[4], 0, 4);
+
+    // Downloaded 8 bytes on WIFI for uri3
+    outputMonitor3.bytesWritten(new byte[8], 0, 8);
+
+    // Then we have CELLULAR connection.
+    // Downloaded 16 bytes on CELLULAR for uri1
+    setNetworkConnectivityType(ConnectivityManager.TYPE_MOBILE);
+    outputMonitor1.bytesWritten(new byte[16], 0, 16);
+
+    // Downloaded 32 bytes on CELLULAR for uri2
+    outputMonitor2.bytesWritten(new byte[32], 0, 32);
+
+    // Downloaded 64 bytes on CELLULAR for uri3
+    outputMonitor3.bytesWritten(new byte[64], 0, 64);
+
+    // close() will trigger saving counters to SharedPreference.
+    outputMonitor1.close();
+    outputMonitor2.close();
+    outputMonitor3.close();
+
+    // await executors idle here if we switch from directExecutor...
+  }
+
+  @Test
+  public void getNetworkConnectivityType() {
+    setNetworkConnectivityType(ConnectivityManager.TYPE_WIFI);
+    assertThat(NetworkUsageMonitor.isCellular(context)).isFalse();
+
+    setNetworkConnectivityType(ConnectivityManager.TYPE_ETHERNET);
+    assertThat(NetworkUsageMonitor.isCellular(context)).isFalse();
+
+    setNetworkConnectivityType(ConnectivityManager.TYPE_VPN);
+    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+      assertThat(NetworkUsageMonitor.isCellular(context)).isFalse();
+
+    } else {
+      assertThat(NetworkUsageMonitor.isCellular(context)).isTrue();
+    }
+
+    setNetworkConnectivityType(ConnectivityManager.TYPE_MOBILE);
+    assertThat(NetworkUsageMonitor.isCellular(context)).isTrue();
+
+    // Fail to get NetworkInfo(return null) will return TYPE_WIFI.
+    Shadows.shadowOf(connectivityManager).setActiveNetworkInfo(null);
+    assertThat(NetworkUsageMonitor.isCellular(context)).isFalse();
+  }
+
+  @Test
+  public void testNotRegisterUri() {
+    // Creating the outputMonitor before registering the uri through monitorUri will return
+    // null.
+    Monitor.OutputMonitor outputMonitor = networkUsageMonitor.monitorWrite(uri1);
+    assertThat(outputMonitor).isNull();
+
+    outputMonitor = networkUsageMonitor.monitorAppend(uri1);
+    assertThat(outputMonitor).isNull();
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/populator/AndroidManifest.xml b/javatests/com/google/android/libraries/mobiledatadownload/populator/AndroidManifest.xml
new file mode 100644
index 0000000..5102491
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/populator/AndroidManifest.xml
@@ -0,0 +1,36 @@
+<!--
+/*
+ * Copyright 2022 Google LLC
+ *
+ * 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.
+ */
+-->
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+  package="com.google.android.libraries.mobiledatadownload.populator" >
+
+  <uses-sdk android:minSdkVersion="16" />
+  <uses-permission android:name="android.permission.INTERNET"/>
+  <uses-permission
+    android:name="com.google.android.gms.permission.BIND_NETWORK_TASK_SERVICE"/>
+  <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
+
+  <application android:name="android.support.multidex.MultiDexApplication">
+    <!--This meta-data tag is required to use Google Play Services.-->
+    <meta-data android:name="com.google.android.gms.version" android:value="@integer/google_play_services_version"/>
+  </application>
+
+  <instrumentation
+    android:name="com.google.android.apps.common.testing.testrunner.Google3InstrumentationTestRunner"
+    android:targetPackage="com.google.android.libraries.mobiledatadownload.populator" />
+</manifest>
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/populator/BUILD b/javatests/com/google/android/libraries/mobiledatadownload/populator/BUILD
new file mode 100644
index 0000000..34ae724
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/populator/BUILD
@@ -0,0 +1,99 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_local_test")
+
+package(
+    default_visibility = ["//:__subpackages__"],
+    licenses = ["notice"],
+)
+
+android_local_test(
+    name = "MigrationProxyPopulatorTest",
+    srcs = ["MigrationProxyPopulatorTest.java"],
+    manifest_values = {
+        "minSdkVersion": "16",
+        "targetSdkVersion": "27",
+    },
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload",
+        "//java/com/google/android/libraries/mobiledatadownload/populator:MigrationProxyPopulator",
+        "@com_google_guava_guava",
+        "@mockito",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "LocationProviderImplTest",
+    srcs = ["LocationProviderImplTest.java"],
+    manifest_values = {
+        "minSdkVersion": "16",
+        "targetSdkVersion": "27",
+    },
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/populator:LocationProvider",
+        "@androidx_test",
+        "@mockito",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "ManifestConfigFlagPopulatorTest",
+    srcs = ["ManifestConfigFlagPopulatorTest.java"],
+    manifest_values = {
+        "minSdkVersion": "16",
+        "targetSdkVersion": "27",
+    },
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload",
+        "//java/com/google/android/libraries/mobiledatadownload/populator:ManifestConfigFlagPopulator",
+        "//java/com/google/android/libraries/mobiledatadownload/populator:ManifestConfigHelper",
+        "//java/com/google/android/libraries/mobiledatadownload/populator:ManifestConfigOverrider",
+        "//proto:download_config_java_proto_lite",
+        "@com_google_guava_guava",
+        "@mockito",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "LocaleOverriderTest",
+    srcs = ["LocaleOverriderTest.java"],
+    manifest_values = {
+        "minSdkVersion": "16",
+        "targetSdkVersion": "27",
+    },
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/populator:LocaleOverrider",
+        "//proto:download_config_java_proto_lite",
+        "@com_google_guava_guava",
+        "@truth",
+    ],
+)
+
+android_local_test(
+    name = "MigrationProxyLocaleOverriderTest",
+    srcs = ["MigrationProxyLocaleOverriderTest.java"],
+    manifest_values = {
+        "minSdkVersion": "16",
+        "targetSdkVersion": "27",
+    },
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/populator:LocaleOverrider",
+        "//java/com/google/android/libraries/mobiledatadownload/populator:MigrationProxyLocaleOverrider",
+        "//proto:download_config_java_proto_lite",
+        "@truth",
+    ],
+)
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/populator/LocaleOverriderTest.java b/javatests/com/google/android/libraries/mobiledatadownload/populator/LocaleOverriderTest.java
new file mode 100644
index 0000000..4126f06
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/populator/LocaleOverriderTest.java
@@ -0,0 +1,181 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.populator;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.base.Supplier;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.mobiledatadownload.DownloadConfigProto.DataFile;
+import com.google.mobiledatadownload.DownloadConfigProto.DataFileGroup;
+import com.google.mobiledatadownload.DownloadConfigProto.ManifestConfig;
+import java.util.Locale;
+import java.util.concurrent.ExecutionException;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+/** Tests for {@link LocaleOverrider}. */
+@RunWith(RobolectricTestRunner.class)
+public class LocaleOverriderTest {
+
+  @Test
+  public void override_equalStrategy_hasMatch() throws InterruptedException, ExecutionException {
+    Supplier<Locale> localeSupplier = () -> Locale.forLanguageTag("en-US");
+    ManifestConfig config =
+        ManifestConfig.newBuilder()
+            .addEntry(createEntryWithLocaleAndIdentifier("en", "en-resource"))
+            .addEntry(createEntryWithLocaleAndIdentifier("en-US", "en-US-resource"))
+            .build();
+    LocaleOverrider overrider =
+        LocaleOverrider.builder()
+            .setLocaleSupplier(localeSupplier)
+            .setMatchStrategy(LocaleOverrider.EQUAL_STRATEGY)
+            .build();
+    DataFileGroup dataFileGroup = overrider.override(config).get().get(0);
+    assertThat(dataFileGroup.getLocale(0)).isEqualTo("en-US");
+    assertThat(dataFileGroup.toString()).contains("en-US-resource");
+  }
+
+  @Test
+  public void override_equalStrategy_hasMatch_localeLF()
+      throws InterruptedException, ExecutionException {
+    Supplier<ListenableFuture<Locale>> localeSupplier =
+        () -> Futures.immediateFuture(Locale.forLanguageTag("en-US"));
+    ManifestConfig config =
+        ManifestConfig.newBuilder()
+            .addEntry(createEntryWithLocaleAndIdentifier("en", "en-resource"))
+            .addEntry(createEntryWithLocaleAndIdentifier("en-US", "en-US-resource"))
+            .build();
+    LocaleOverrider overrider =
+        LocaleOverrider.builder()
+            .setLocaleFutureSupplier(localeSupplier, MoreExecutors.directExecutor())
+            .setMatchStrategy(LocaleOverrider.EQUAL_STRATEGY)
+            .build();
+    DataFileGroup dataFileGroup = overrider.override(config).get().get(0);
+    assertThat(dataFileGroup.getLocale(0)).isEqualTo("en-US");
+    assertThat(dataFileGroup.toString()).contains("en-US-resource");
+  }
+
+  @Test
+  public void override_equalStrategy_noMatch() throws InterruptedException, ExecutionException {
+    Supplier<Locale> localeSupplier = () -> Locale.forLanguageTag("jp-JP");
+    ManifestConfig config =
+        ManifestConfig.newBuilder()
+            .addEntry(createEntryWithLocaleAndIdentifier("en", "en-resource"))
+            .addEntry(createEntryWithLocaleAndIdentifier("en-US", "en-US-resource"))
+            .build();
+    LocaleOverrider overrider =
+        LocaleOverrider.builder()
+            .setLocaleSupplier(localeSupplier)
+            .setMatchStrategy(LocaleOverrider.EQUAL_STRATEGY)
+            .build();
+
+    assertThat(overrider.override(config).get()).isEmpty();
+  }
+
+  @Test
+  public void override_langFallbackStrategy_exactMatch()
+      throws InterruptedException, ExecutionException {
+    Supplier<Locale> localeSupplier = () -> Locale.forLanguageTag("en-US");
+    ManifestConfig config =
+        ManifestConfig.newBuilder()
+            .addEntry(createEntryWithLocaleAndIdentifier("en", "en-resource"))
+            .addEntry(createEntryWithLocaleAndIdentifier("en-US", "en-US-resource"))
+            .build();
+    LocaleOverrider overrider =
+        LocaleOverrider.builder()
+            .setLocaleSupplier(localeSupplier)
+            .setMatchStrategy(LocaleOverrider.LANG_FALLBACK_STRATEGY)
+            .build();
+
+    assertThat(overrider.override(config).get().get(0).toString()).contains("en-US-resource");
+  }
+
+  @Test
+  public void override_langFallbackStrategy_fallbackMatch()
+      throws InterruptedException, ExecutionException {
+    Supplier<Locale> localeSupplier = () -> Locale.forLanguageTag("en-US");
+    ManifestConfig config =
+        ManifestConfig.newBuilder()
+            .addEntry(createEntryWithLocaleAndIdentifier("en", "en-resource", true))
+            .addEntry(createEntryWithLocaleAndIdentifier("en-GB", "en-GB-resource", true))
+            .build();
+    LocaleOverrider overrider =
+        LocaleOverrider.builder()
+            .setLocaleSupplier(localeSupplier)
+            .setMatchStrategy(LocaleOverrider.LANG_FALLBACK_STRATEGY)
+            .build();
+
+    DataFileGroup dataFileGroup = overrider.override(config).get().get(0);
+    assertThat(dataFileGroup.getLocale(0)).isEqualTo("en");
+    assertThat(dataFileGroup.getLocaleCount()).isEqualTo(1);
+    assertThat(dataFileGroup.toString()).contains("en-resource");
+  }
+
+  @Test
+  public void override_langFallbackStrategy_noMatch()
+      throws InterruptedException, ExecutionException {
+    Supplier<Locale> localeSupplier = () -> Locale.forLanguageTag("en-US");
+    ManifestConfig config =
+        ManifestConfig.newBuilder()
+            .addEntry(createEntryWithLocaleAndIdentifier("jp", "jp-resource"))
+            .addEntry(createEntryWithLocaleAndIdentifier("en-GB", "en-GB-resource"))
+            .build();
+    LocaleOverrider overrider =
+        LocaleOverrider.builder()
+            .setLocaleSupplier(localeSupplier)
+            .setMatchStrategy(LocaleOverrider.EQUAL_STRATEGY)
+            .build();
+
+    assertThat(overrider.override(config).get()).isEmpty();
+  }
+
+  /**
+   * Creates a {@link ManifestConfig.Entry} with {@code locale} and some field set to {@code
+   * identifier}
+   */
+  private static ManifestConfig.Entry createEntryWithLocaleAndIdentifier(
+      String locale, String identifier) {
+    return ManifestConfig.Entry.newBuilder()
+        .setModifier(ManifestConfig.Entry.Modifier.newBuilder().addLocale(locale).build())
+        .setDataFileGroup(
+            DataFileGroup.newBuilder().addFile(DataFile.newBuilder().setFileId(identifier)))
+        .build();
+  }
+
+  /**
+   * Creates a {@link ManifestConfig.Entry} with {@code locale} and some field set to {@code
+   * identifier}
+   *
+   * @param isLocaleSetInDF if true, locale is set to DataFileGroup
+   */
+  private static ManifestConfig.Entry createEntryWithLocaleAndIdentifier(
+      String locale, String identifier, boolean isLocaleSetInDF) {
+    if (isLocaleSetInDF) {
+      return ManifestConfig.Entry.newBuilder()
+          .setModifier(ManifestConfig.Entry.Modifier.newBuilder().addLocale(locale).build())
+          .setDataFileGroup(
+              DataFileGroup.newBuilder()
+                  .addFile(DataFile.newBuilder().setFileId(identifier))
+                  .addLocale(locale))
+          .build();
+    }
+    return createEntryWithLocaleAndIdentifier(locale, identifier);
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/populator/LocationProviderImplTest.java b/javatests/com/google/android/libraries/mobiledatadownload/populator/LocationProviderImplTest.java
new file mode 100644
index 0000000..44cba5b
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/populator/LocationProviderImplTest.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.populator;
+
+import static android.Manifest.permission.ACCESS_FINE_LOCATION;
+import static androidx.test.core.app.ApplicationProvider.getApplicationContext;
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.when;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.Manifest.permission;
+import android.app.Application;
+import android.location.Location;
+import android.location.LocationManager;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.RobolectricTestRunner;
+
+/** Tests for {@link LocationProviderImpl}. */
+@RunWith(RobolectricTestRunner.class)
+public class LocationProviderImplTest {
+
+  @Rule public final MockitoRule mocks = MockitoJUnit.rule();
+
+  @Mock private LocationManager locationManager;
+  private LocationProvider locationProvider;
+  private Location locationWithAccuracy;
+  private Location locationWithoutAccuracy;
+
+  @Before
+  public void setUp() {
+    locationProvider = new LocationProviderImpl(getApplicationContext(), locationManager);
+    locationWithAccuracy = new Location("provider");
+    locationWithAccuracy.setLatitude(40.7);
+    locationWithAccuracy.setLongitude(-74.0);
+    locationWithoutAccuracy = new Location(locationWithAccuracy);
+    locationWithAccuracy.setAccuracy(10);
+  }
+
+  @Test
+  public void getLocation_whenPermissionsDenied_returnsAbsentLocation() {
+    shadowOf((Application) getApplicationContext())
+        .denyPermissions(permission.ACCESS_FINE_LOCATION, permission.ACCESS_COARSE_LOCATION);
+    assertThat(locationProvider.get()).isAbsent();
+  }
+
+  @Test
+  public void getLocation_whenEitherAccessLocationPermissionGranted_returnsLocation() {
+    when(locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER))
+        .thenReturn(locationWithAccuracy);
+    shadowOf((Application) getApplicationContext())
+        .denyPermissions(permission.ACCESS_COARSE_LOCATION);
+    shadowOf((Application) getApplicationContext())
+        .grantPermissions(permission.ACCESS_FINE_LOCATION);
+
+    assertThat(locationProvider.get()).hasValue(locationWithAccuracy);
+
+    shadowOf((Application) getApplicationContext())
+        .denyPermissions(permission.ACCESS_FINE_LOCATION);
+    shadowOf((Application) getApplicationContext())
+        .grantPermissions(permission.ACCESS_COARSE_LOCATION);
+
+    assertThat(locationProvider.get()).hasValue(locationWithAccuracy);
+
+    shadowOf((Application) getApplicationContext())
+        .grantPermissions(permission.ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION);
+    assertThat(locationProvider.get()).hasValue(locationWithAccuracy);
+  }
+
+  @Test
+  public void getLocation_whenNetworkLocationHasNoAccuracy_returnsGpsLocation() {
+    shadowOf((Application) getApplicationContext())
+        .grantPermissions(permission.ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION);
+
+    when(locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER))
+        .thenReturn(locationWithoutAccuracy);
+    when(locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER))
+        .thenReturn(locationWithAccuracy);
+    assertThat(locationProvider.get()).hasValue(locationWithAccuracy);
+  }
+
+  @Test
+  public void getLocation_whenBothLocationsHaveNoAccuracy_returnsAbsentLocation() {
+    shadowOf((Application) getApplicationContext())
+        .grantPermissions(permission.ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION);
+
+    when(locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER))
+        .thenReturn(locationWithoutAccuracy);
+    when(locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER))
+        .thenReturn(locationWithoutAccuracy);
+    assertThat(locationProvider.get()).isAbsent();
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/populator/ManifestConfigFlagPopulatorTest.java b/javatests/com/google/android/libraries/mobiledatadownload/populator/ManifestConfigFlagPopulatorTest.java
new file mode 100644
index 0000000..1d4a9be
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/populator/ManifestConfigFlagPopulatorTest.java
@@ -0,0 +1,283 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.populator;
+
+import static com.google.android.libraries.mobiledatadownload.populator.ManifestConfigHelper.URL_TEMPLATE_CHECKSUM_PLACEHOLDER;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.google.android.libraries.mobiledatadownload.AddFileGroupRequest;
+import com.google.android.libraries.mobiledatadownload.MobileDataDownload;
+import com.google.common.base.Optional;
+import com.google.common.collect.Iterables;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.mobiledatadownload.DownloadConfigProto.BaseFile;
+import com.google.mobiledatadownload.DownloadConfigProto.DataFile;
+import com.google.mobiledatadownload.DownloadConfigProto.DataFileGroup;
+import com.google.mobiledatadownload.DownloadConfigProto.DeltaFile;
+import com.google.mobiledatadownload.DownloadConfigProto.DeltaFile.DiffDecoder;
+import com.google.mobiledatadownload.DownloadConfigProto.ManifestConfig;
+import com.google.mobiledatadownload.DownloadConfigProto.ManifestConfig.UrlTemplate;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.RobolectricTestRunner;
+
+/** Tests for {@link ManifestConfigFlagPopulator}. */
+@RunWith(RobolectricTestRunner.class)
+public class ManifestConfigFlagPopulatorTest {
+  @Rule public final MockitoRule mocks = MockitoJUnit.rule();
+
+  @Mock MobileDataDownload mockMobileDataDownload;
+  @Captor private ArgumentCaptor<AddFileGroupRequest> addFileGroupRequestCaptor;
+
+  private static final DeltaFile DELTA_FILE =
+      DeltaFile.newBuilder()
+          .setDiffDecoder(DiffDecoder.VC_DIFF)
+          .setUrlToDownload("https://standard.file.url")
+          .setChecksum("standardDeltaFileChecksum")
+          .setBaseFile(BaseFile.newBuilder().setChecksum("standardBaseFileChecksum").build())
+          .build();
+
+  private static final DataFile DATA_FILE =
+      DataFile.newBuilder()
+          .setFileId("standard-file")
+          .setUrlToDownload("https://standard.file.url")
+          .setChecksum("standardFileChecksum")
+          .addDeltaFile(DELTA_FILE)
+          .build();
+
+  private static final DataFileGroup DATA_FILE_GROUP =
+      DataFileGroup.newBuilder().setGroupName("groupName").setOwnerPackage("ownerPackage").build();
+
+  @Before
+  public void setUp() {
+    when(mockMobileDataDownload.addFileGroup(any(AddFileGroupRequest.class)))
+        .thenReturn(Futures.immediateFuture(true));
+  }
+
+  @Test
+  public void refreshFromManifestConfig_absentOverrider()
+      throws ExecutionException, InterruptedException {
+    ManifestConfig manifestConfig = createManifestConfig();
+
+    ManifestConfigHelper.refreshFromManifestConfig(
+            mockMobileDataDownload, manifestConfig, /*overriderOptional=*/ Optional.absent())
+        .get();
+    verify(mockMobileDataDownload, times(2)).addFileGroup(addFileGroupRequestCaptor.capture());
+
+    assertThat(addFileGroupRequestCaptor.getAllValues().get(0).dataFileGroup().getGroupName())
+        .isEqualTo("groupNameLocaleEnUS");
+    assertThat(addFileGroupRequestCaptor.getAllValues().get(1).dataFileGroup().getGroupName())
+        .isEqualTo("groupNameLocaleEnUK");
+  }
+
+  @Test
+  public void refreshFromManifestConfig_withUrlTemplate_absentOverrider()
+      throws ExecutionException, InterruptedException {
+    ManifestConfig manifestConfigWithUrlTemplate = createManifestConfigWithUrlTemplate();
+    // File url_to_download populated using url templates.
+    DataFileGroup dataFileGroup1 =
+        DATA_FILE_GROUP.toBuilder()
+            .addFile(
+                DATA_FILE.toBuilder()
+                    .setUrlToDownload("https://standard.file.url/standardFileChecksum")
+                    .setDeltaFile(
+                        0,
+                        DELTA_FILE.toBuilder()
+                            .setUrlToDownload(
+                                "https://standard.file.url/standardDeltaFileChecksum")))
+            .build();
+    DataFileGroup dataFileGroup2 = DATA_FILE_GROUP.toBuilder().addFile(DATA_FILE).build();
+
+    ManifestConfigHelper.refreshFromManifestConfig(
+            mockMobileDataDownload,
+            manifestConfigWithUrlTemplate,
+            /*overriderOptional=*/ Optional.absent())
+        .get();
+    verify(mockMobileDataDownload, times(2)).addFileGroup(addFileGroupRequestCaptor.capture());
+
+    List<AddFileGroupRequest> addFileGroupRequests = addFileGroupRequestCaptor.getAllValues();
+    assertThat(Iterables.transform(addFileGroupRequests, AddFileGroupRequest::dataFileGroup))
+        .containsExactly(dataFileGroup1, dataFileGroup2)
+        .inOrder();
+  }
+
+  @Test
+  public void refreshFromManifestConfig_noUrlTemplate_urlToDownloadEmpty_absentOverrider()
+      throws ExecutionException, InterruptedException {
+    // Files with no url_to_download will fail since url template is not available.
+    ManifestConfig manifestConfigWithoutUrlTemplate =
+        createManifestConfigWithUrlTemplate().toBuilder().clearUrlTemplate().build();
+
+    IllegalArgumentException illegalArgumentException =
+        assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                ManifestConfigHelper.refreshFromManifestConfig(
+                        mockMobileDataDownload,
+                        manifestConfigWithoutUrlTemplate,
+                        /*overriderOptional=*/ Optional.absent())
+                    .get());
+
+    assertThat(illegalArgumentException)
+        .hasMessageThat()
+        .isEqualTo("DataFile standard-file url_to_download is missing.");
+  }
+
+  @Test
+  public void refreshFromManifestConfig_identityOverrider()
+      throws ExecutionException, InterruptedException {
+    ManifestConfig manifestConfig = createManifestConfig();
+
+    // Use a ManifestConfigOverrider that does not filter/change anything.
+    ManifestConfigOverrider overrider =
+        new ManifestConfigOverrider() {
+          @Override
+          public ListenableFuture<List<DataFileGroup>> override(ManifestConfig config) {
+            List<DataFileGroup> overriderResults = new ArrayList<>();
+            for (ManifestConfig.Entry entry : config.getEntryList()) {
+              overriderResults.add(entry.getDataFileGroup());
+            }
+
+            return Futures.immediateFuture(overriderResults);
+          }
+        };
+
+    ManifestConfigHelper.refreshFromManifestConfig(
+            mockMobileDataDownload, manifestConfig, Optional.of(overrider))
+        .get();
+    verify(mockMobileDataDownload, times(2)).addFileGroup(addFileGroupRequestCaptor.capture());
+
+    assertThat(addFileGroupRequestCaptor.getAllValues().get(0).dataFileGroup().getGroupName())
+        .isEqualTo("groupNameLocaleEnUS");
+    assertThat(addFileGroupRequestCaptor.getAllValues().get(1).dataFileGroup().getGroupName())
+        .isEqualTo("groupNameLocaleEnUK");
+  }
+
+  @Test
+  public void refreshFromManifestConfig_en_USLocaleOverrider()
+      throws ExecutionException, InterruptedException {
+    ManifestConfig manifestConfig = createManifestConfig();
+
+    // Use a ManifestConfigOverrider that only keeps entry with locale en_US.
+    ManifestConfigOverrider overrider =
+        new ManifestConfigOverrider() {
+          @Override
+          public ListenableFuture<List<DataFileGroup>> override(ManifestConfig config) {
+            List<DataFileGroup> overriderResults = new ArrayList<>();
+            for (ManifestConfig.Entry entry : config.getEntryList()) {
+              if ("en_US".equals(entry.getModifier().getLocaleList().get(0))) {
+                overriderResults.add(entry.getDataFileGroup());
+              }
+            }
+
+            return Futures.immediateFuture(overriderResults);
+          }
+        };
+
+    ManifestConfigHelper.refreshFromManifestConfig(
+            mockMobileDataDownload, manifestConfig, Optional.of(overrider))
+        .get();
+    verify(mockMobileDataDownload, times(1)).addFileGroup(addFileGroupRequestCaptor.capture());
+
+    assertThat(addFileGroupRequestCaptor.getAllValues().get(0).dataFileGroup().getGroupName())
+        .isEqualTo("groupNameLocaleEnUS");
+  }
+
+  @Test
+  public void refreshFileGroups_usesSupplier() throws ExecutionException, InterruptedException {
+    ManifestConfig manifestConfig = createManifestConfig();
+
+    ManifestConfigFlagPopulator populator =
+        ManifestConfigFlagPopulator.builder()
+            .setManifestConfigSupplier(() -> manifestConfig)
+            .build();
+
+    populator.refreshFileGroups(mockMobileDataDownload).get();
+    verify(mockMobileDataDownload, times(2)).addFileGroup(addFileGroupRequestCaptor.capture());
+
+    assertThat(addFileGroupRequestCaptor.getAllValues().get(0).dataFileGroup().getGroupName())
+        .isEqualTo("groupNameLocaleEnUS");
+    assertThat(addFileGroupRequestCaptor.getAllValues().get(1).dataFileGroup().getGroupName())
+        .isEqualTo("groupNameLocaleEnUK");
+  }
+
+  @Test
+  public void manifestConfigFlagPopulatorBuilder_rejectsNotBothPackageAndFlag() {
+    assertThrows(
+        IllegalArgumentException.class, () -> ManifestConfigFlagPopulator.builder().build());
+  }
+
+  private static ManifestConfig createManifestConfig() {
+    // Create a ManifestConfig with 2 entries for locale en_US and en_UK.
+    ManifestConfig.Entry entryUs =
+        ManifestConfig.Entry.newBuilder()
+            .setModifier(ManifestConfig.Entry.Modifier.newBuilder().addLocale("en_US").build())
+            .setDataFileGroup(
+                DataFileGroup.newBuilder()
+                    .setGroupName("groupNameLocaleEnUS")
+                    .setOwnerPackage("ownerPackage"))
+            .build();
+
+    ManifestConfig.Entry entryUk =
+        ManifestConfig.Entry.newBuilder()
+            .setModifier(ManifestConfig.Entry.Modifier.newBuilder().addLocale("en_UK").build())
+            .setDataFileGroup(
+                DataFileGroup.newBuilder()
+                    .setGroupName("groupNameLocaleEnUK")
+                    .setOwnerPackage("ownerPackage"))
+            .build();
+
+    return ManifestConfig.newBuilder().addEntry(entryUs).addEntry(entryUk).build();
+  }
+
+  private static ManifestConfig createManifestConfigWithUrlTemplate() {
+    DataFileGroup dataFileGroupWithoutUrlDownload =
+        DATA_FILE_GROUP.toBuilder()
+            .addFile(
+                DATA_FILE.toBuilder()
+                    .clearUrlToDownload()
+                    .setDeltaFile(0, DELTA_FILE.toBuilder().clearUrlToDownload()))
+            .build();
+    DataFileGroup dataFileGroup2 = DATA_FILE_GROUP.toBuilder().addFile(DATA_FILE).build();
+
+    return ManifestConfig.newBuilder()
+        .setUrlTemplate(
+            UrlTemplate.newBuilder()
+                .setFileUrlTemplate(
+                    "https://standard.file.url/" + URL_TEMPLATE_CHECKSUM_PLACEHOLDER)
+                .build())
+        .addEntry(
+            ManifestConfig.Entry.newBuilder().setDataFileGroup(dataFileGroupWithoutUrlDownload))
+        .addEntry(ManifestConfig.Entry.newBuilder().setDataFileGroup(dataFileGroup2))
+        .build();
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/populator/MigrationProxyLocaleOverriderTest.java b/javatests/com/google/android/libraries/mobiledatadownload/populator/MigrationProxyLocaleOverriderTest.java
new file mode 100644
index 0000000..2a96b65
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/populator/MigrationProxyLocaleOverriderTest.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.populator;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.mobiledatadownload.DownloadConfigProto.ManifestConfig;
+import java.util.Locale;
+import java.util.concurrent.ExecutionException;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+/** Tests for {@link MigrationProxyLocaleOverrider}. */
+@RunWith(RobolectricTestRunner.class)
+public class MigrationProxyLocaleOverriderTest {
+
+  private LocaleOverrider localeOverrider;
+  private ManifestConfig manifestConfig;
+
+  @Before
+  public void setUp() {
+    manifestConfig =
+        ManifestConfig.newBuilder()
+            .addEntry(
+                ManifestConfig.Entry.newBuilder()
+                    .setModifier(
+                        ManifestConfig.Entry.Modifier.newBuilder().addLocale("en-US").build())
+                    .build())
+            .build();
+    localeOverrider =
+        LocaleOverrider.builder().setLocaleSupplier(() -> Locale.forLanguageTag("en-US")).build();
+  }
+
+  @Test
+  public void override_whenFlagReturnsFalse_returnsNothing()
+      throws InterruptedException, ExecutionException {
+    MigrationProxyLocaleOverrider migrationProxyLocaleOverrider =
+        new MigrationProxyLocaleOverrider(localeOverrider, () -> false);
+    assertThat(migrationProxyLocaleOverrider.override(manifestConfig).get()).isEmpty();
+  }
+
+  @Test
+  public void override_whenFlagReturnsTrue_usesLocaleOverrider()
+      throws InterruptedException, ExecutionException {
+    MigrationProxyLocaleOverrider migrationProxyLocaleOverrider =
+        new MigrationProxyLocaleOverrider(localeOverrider, () -> true);
+    assertThat(migrationProxyLocaleOverrider.override(manifestConfig).get()).hasSize(1);
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/populator/MigrationProxyPopulatorTest.java b/javatests/com/google/android/libraries/mobiledatadownload/populator/MigrationProxyPopulatorTest.java
new file mode 100644
index 0000000..05327e9
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/populator/MigrationProxyPopulatorTest.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.populator;
+
+import static com.google.common.labs.truth.FutureSubject.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import com.google.android.libraries.mobiledatadownload.FileGroupPopulator;
+import com.google.android.libraries.mobiledatadownload.MobileDataDownload;
+import com.google.common.util.concurrent.Futures;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.RobolectricTestRunner;
+
+/** Tests for {@link MigrationProxyPopulator}. */
+@RunWith(RobolectricTestRunner.class)
+public class MigrationProxyPopulatorTest {
+
+  @Rule public final MockitoRule mocks = MockitoJUnit.rule();
+
+  private static final Void VOID = null;
+
+  @Mock private FileGroupPopulator controlPopulator;
+  @Mock private FileGroupPopulator experimentPopulator;
+  @Mock private MobileDataDownload mobileDataDownload;
+
+  @Before
+  public void setUp() {
+    when(controlPopulator.refreshFileGroups(any())).thenReturn(Futures.immediateFuture(VOID));
+    when(experimentPopulator.refreshFileGroups(any())).thenReturn(Futures.immediateFuture(VOID));
+  }
+
+  @Test
+  public void refreshFileGroups_whenFlagReturnsFalse_usesControlPopulator() {
+    MigrationProxyPopulator migrationProxyPopulator =
+        new MigrationProxyPopulator(controlPopulator, experimentPopulator, () -> false);
+    assertThat(migrationProxyPopulator.refreshFileGroups(mobileDataDownload))
+        .whenDone()
+        .isSuccessful();
+    verify(controlPopulator).refreshFileGroups(any());
+    verifyNoInteractions(experimentPopulator);
+  }
+
+  @Test
+  public void refreshFileGroups_whenFlagReturnsTrue_usesExperimentPopulator() {
+    MigrationProxyPopulator migrationProxyPopulator =
+        new MigrationProxyPopulator(controlPopulator, experimentPopulator, () -> true);
+    assertThat(migrationProxyPopulator.refreshFileGroups(mobileDataDownload))
+        .whenDone()
+        .isSuccessful();
+    verifyNoInteractions(controlPopulator);
+    verify(experimentPopulator).refreshFileGroups(any());
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/test_defs.bzl b/javatests/com/google/android/libraries/mobiledatadownload/test_defs.bzl
new file mode 100644
index 0000000..964756d
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/test_defs.bzl
@@ -0,0 +1,158 @@
+# Copyright 2022 Google LLC
+#
+# 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 methods to generate test suites."""
+
+load("//devtools/deps/check:deps_check.bzl", "check_dependencies")
+load(
+    "@build_bazel_rules_android//android:rules.bzl",
+    "android_application_test",
+    "android_local_test",
+    "infer_test_class_from_srcs",
+)
+load("//devtools/build_cleaner/skylark:build_defs.bzl", "register_extension_info")
+
+_MANIFEST_VALUES = {
+    "minSdkVersion": "16",
+    "targetSdkVersion": "29",
+}
+
+def mdd_local_test(name, deps, manifest_values = _MANIFEST_VALUES, **kwargs):
+    """Generate 2 flavors for android_local_test, with default behavior and with all flags on.
+
+    The all on testing only works as expected if the test includes flagrule/FlagRule as a rule in
+    the test.
+
+    Args:
+      name: the test name
+      deps: a list of deps
+      manifest_values: manifest values to use.
+      **kwargs: Any other param that is supported by <internal>.
+    """
+
+    android_local_test(
+        name = name,
+        deps = deps,
+        manifest_values = manifest_values,
+        **kwargs
+    )
+
+# See <internal>
+register_extension_info(
+    extension = mdd_local_test,
+    label_regex_for_dep = "(all_on_)?{extension_name}",
+)
+
+# Run mdd_android_test on an assortment of emulators.
+# The last item in the list will be used as the default.
+_EMULATOR_IMAGES = [
+    # Automotive
+    "//tools/android/emulated_devices/automotive:auto_29_x86",
+
+    # Android Phone
+    "//tools/android/emulated_devices/generic_phone:google_21_x86_gms_stable",
+    "//tools/android/emulated_devices/generic_phone:google_22_x86_gms_stable",
+    "//tools/android/emulated_devices/generic_phone:google_23_x86_gms_stable",
+    "//tools/android/emulated_devices/generic_phone:google_24_x86_gms_stable",
+    "//tools/android/emulated_devices/generic_phone:google_25_x86_gms_stable",
+    "//tools/android/emulated_devices/generic_phone:google_26_x86_gms_stable",
+    "//tools/android/emulated_devices/generic_phone:google_27_x86_gms_stable",
+    "//tools/android/emulated_devices/generic_phone:google_28_x86_gms_stable",
+]
+
+# Logcat filter flags.
+_COMMON_LOGCAT_ARGS = [
+    "--test_logcat_filter='MDD:V,MobileDataDownload:V'",
+]
+
+# This ensures that the `android_application_test` will merge the resources in its dependencies into the
+# test binary, just like an `android_binary` rule does.
+# This is a workaround for b/111061456.
+_EMPTY_LOCAL_RESOURCE_FILES = []
+
+# Wrapper around android_application_test to generate targets for multiple emulator images.
+def mdd_android_test(name, target_devices = _EMULATOR_IMAGES, **kwargs):
+    """Generate an android_application_test for MDD.
+
+    Args:
+      name: the test name
+      target_devices: one or more emulators to run the test on.
+        The default test name will run on the last emulator provided.
+        If additional emulators are listed, additional test targets will be
+        created by appending the emulator name to the original test name
+      **kwargs: Any keyword arguments to be passed.
+
+    Returns:
+      each android_application_test per emulator image.
+    """
+
+    deps = kwargs.pop("deps", [])
+    multidex = kwargs.pop("multidex", "native")
+    srcs = kwargs.pop("srcs", [])
+    test_class = kwargs.pop("test_class", infer_test_class_from_srcs(name, srcs))
+
+    # create a BUILD target for the last element of target_devices, with the basic test name
+    target_device = target_devices[-1]
+    android_application_test(
+        name = name,
+        srcs = srcs,
+        deps = deps,
+        multidex = multidex,
+        local_resource_files = _EMPTY_LOCAL_RESOURCE_FILES,
+        args = _COMMON_LOGCAT_ARGS,
+        target_devices = [target_device],
+        test_class = test_class,
+        **kwargs
+    )
+
+    # if there are multiple target_devices, create named BUILD targets for all the other ones except
+    # the last one.
+    if len(target_devices) > 1:
+        for target_device in target_devices[:-1]:
+            target_device_name = target_device.replace("//tools/android/emulated_devices/", "").replace(":", "_")
+            android_application_test(
+                name = name + "_" + target_device_name,
+                srcs = srcs,
+                deps = deps,
+                multidex = multidex,
+                local_resource_files = _EMPTY_LOCAL_RESOURCE_FILES,
+                args = _COMMON_LOGCAT_ARGS,
+                target_devices = [target_device],
+                test_class = test_class,
+                **kwargs
+            )
+
+# Wrapper around check_dependencies.
+def dependencies_test(name, allowlist = [], **kwargs):
+    """Generate a dependencies_test for MDD.
+
+    Args:
+      name: The test name.
+      allowlist: The excluded targets under the package.
+      **kwargs: Any keyword arguments to be passed.
+    """
+    all_builds = []
+    for r in native.existing_rules().values():
+        allowlisted = False
+        for build in allowlist:
+            # Ignore the leading colon in build.
+            if build[1:] in r["name"]:
+                allowlisted = True
+                break
+        if not allowlisted:
+            all_builds.append(r["name"])
+    check_dependencies(
+        name = name,
+        of = [":" + build for build in all_builds],
+        **kwargs
+    )
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/testdata/BUILD b/javatests/com/google/android/libraries/mobiledatadownload/testdata/BUILD
new file mode 100644
index 0000000..dec506c
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/testdata/BUILD
@@ -0,0 +1,37 @@
+# Copyright 2022 Google LLC
+#
+# 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(
+    default_visibility = ["//:__subpackages__"],
+    licenses = ["notice"],
+)
+
+filegroup(
+    name = "integration_test_data_files",
+    testonly = 1,
+    srcs = [
+        "odws1_empty",
+        "step1.txt",
+        "step2.txt",
+        "zip_test_folder.zip",
+    ],
+)
+
+filegroup(
+    name = "downloader_test_data_files",
+    testonly = 1,
+    srcs = [
+        "full_file.txt",
+        "partial_file.txt",
+    ],
+)
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/testdata/full_file.txt b/javatests/com/google/android/libraries/mobiledatadownload/testdata/full_file.txt
new file mode 100644
index 0000000..c6e435e
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/testdata/full_file.txt
@@ -0,0 +1,2 @@
+For testing, this file only contains half the content.
+The other half is also included for completed content.
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/testdata/odws1_empty b/javatests/com/google/android/libraries/mobiledatadownload/testdata/odws1_empty
new file mode 100644
index 0000000..1c990c4
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/testdata/odws1_empty
Binary files differ
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/testdata/partial_file.txt b/javatests/com/google/android/libraries/mobiledatadownload/testdata/partial_file.txt
new file mode 100644
index 0000000..959c2d8
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/testdata/partial_file.txt
@@ -0,0 +1 @@
+For testing, this file only contains half the content.
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/testdata/step1.txt b/javatests/com/google/android/libraries/mobiledatadownload/testdata/step1.txt
new file mode 100644
index 0000000..6702453
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/testdata/step1.txt
@@ -0,0 +1 @@
+https://www.gstatic.com/icing/idd/sample_group/step2.txt
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/testdata/step2.txt b/javatests/com/google/android/libraries/mobiledatadownload/testdata/step2.txt
new file mode 100644
index 0000000..5503305
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/testdata/step2.txt
@@ -0,0 +1 @@
+Step 2 file.
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/testdata/zip_test_folder.zip b/javatests/com/google/android/libraries/mobiledatadownload/testdata/zip_test_folder.zip
new file mode 100644
index 0000000..8ff7aba
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/testdata/zip_test_folder.zip
Binary files differ
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/testing/AndroidManifest.xml b/javatests/com/google/android/libraries/mobiledatadownload/testing/AndroidManifest.xml
new file mode 100644
index 0000000..a06e4b6
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/testing/AndroidManifest.xml
@@ -0,0 +1,41 @@
+<!--
+/*
+ * Copyright 2022 Google LLC
+ *
+ * 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.
+ */
+-->
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+  package="com.google.android.libraries.mobiledatadownload.testing" >
+
+  <uses-sdk android:minSdkVersion="16" />
+
+  <uses-permission android:name="android.permission.INTERNET"/>
+  <uses-permission
+    android:name="com.google.android.gms.permission.BIND_NETWORK_TASK_SERVICE"/>
+  <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
+
+  <!-- Permission needed to download files locally in LocalFileDownloader. -->
+  <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
+
+  <!-- Needed to allow insecure HTTP protocol used by TestHttpServer. -->
+  <application android:usesCleartextTraffic="true">
+    <!-- Explicit declaration needed to make org.apache.http present in DexPathList. -->
+    <uses-library android:name="org.apache.http.legacy" android:required="false" />
+  </application>
+
+  <instrumentation
+    android:name="com.google.android.apps.common.testing.testrunner.Google3InstrumentationTestRunner"
+    android:targetPackage="com.google.android.libraries.mobiledatadownload.testing" />
+</manifest>
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/testing/BUILD b/javatests/com/google/android/libraries/mobiledatadownload/testing/BUILD
new file mode 100644
index 0000000..94c4af0
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/testing/BUILD
@@ -0,0 +1,129 @@
+# Copyright 2022 Google LLC
+#
+# 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.
+load("@build_bazel_rules_android//android:rules.bzl", "android_library")
+
+package(
+    default_visibility = ["//:__subpackages__"],
+    licenses = ["notice"],
+)
+
+android_library(
+    name = "BlockingFileDownloader",
+    testonly = 1,
+    srcs = [
+        "BlockingFileDownloader.java",
+    ],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/downloader:FileDownloader",
+        "@com_google_guava_guava",
+        "@flogger",
+    ],
+)
+
+android_library(
+    name = "FakeLogger",
+    testonly = 1,
+    srcs = ["FakeLogger.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload:Logger",
+        "@com_google_guava_guava",
+        "@com_google_protobuf//:protobuf_lite",
+    ],
+)
+
+android_library(
+    name = "FakeTimeSource",
+    testonly = 1,
+    srcs = ["FakeTimeSource.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload:TimeSource",
+    ],
+)
+
+android_library(
+    name = "LocalFileDownloader",
+    testonly = 1,
+    srcs = ["LocalFileDownloader.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload:DownloadException",
+        "//java/com/google/android/libraries/mobiledatadownload/downloader:FileDownloader",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/openers:stream",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LogUtil",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "MddNotificationCapture",
+    testonly = 1,
+    srcs = ["MddNotificationCapture.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload/foreground:NotificationUtil",
+        "@com_google_android_testing//:util",
+        "@com_google_code_findbugs_jsr305",
+        "@com_google_guava_guava",
+        "@truth",
+    ],
+)
+
+android_library(
+    name = "RobolectricFileDownloader",
+    testonly = 1,
+    srcs = ["RobolectricFileDownloader.java"],
+    deps = [
+        ":LocalFileDownloader",
+        "//java/com/google/android/libraries/mobiledatadownload/downloader:FileDownloader",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file",
+        "@com_google_guava_guava",
+        "@com_google_runfiles",
+    ],
+)
+
+android_library(
+    name = "TestFlags",
+    testonly = 1,
+    srcs = ["TestFlags.java"],
+    deps = [
+        "//java/com/google/android/libraries/mobiledatadownload:Flags",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "TestFileDownloader",
+    testonly = 1,
+    srcs = ["TestFileDownloader.java"],
+    deps = [
+        ":LocalFileDownloader",
+        "//java/com/google/android/libraries/mobiledatadownload/downloader:FileDownloader",
+        "//java/com/google/android/libraries/mobiledatadownload/file",
+        "//java/com/google/android/libraries/mobiledatadownload/file/backends:file",
+        "//java/com/google/android/libraries/mobiledatadownload/internal/logging:LogUtil",
+        "@com_google_guava_guava",
+    ],
+)
+
+android_library(
+    name = "TestHttpServer",
+    testonly = 1,
+    srcs = ["TestHttpServer.java"],
+    deps = [
+        "@android_sdk_linux",
+        "@com_google_guava_guava",
+    ],
+)
+
+exports_files(["AndroidManifest.xml"])
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/testing/BlockingFileDownloader.java b/javatests/com/google/android/libraries/mobiledatadownload/testing/BlockingFileDownloader.java
new file mode 100644
index 0000000..e4f5eec
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/testing/BlockingFileDownloader.java
@@ -0,0 +1,216 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.testing;
+
+import com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest;
+import com.google.android.libraries.mobiledatadownload.downloader.FileDownloader;
+import com.google.common.base.Optional;
+import com.google.common.flogger.GoogleLogger;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import java.util.concurrent.CountDownLatch;
+
+/**
+ * File Downloader that allows control over how long the download takes.
+ *
+ * <p>An optional delegate FileDownloader can be provided to perform downloading while maintaining
+ * control over the download state.
+ *
+ * <h3>The states of a download</h3>
+ *
+ * <p>The following "states" are defined and using the BlockingFileDownloader will allow the
+ * download to be paused so assertions can be made:
+ *
+ * <ol>
+ *   <li>Idle - The download has not started yet.
+ *   <li>In Progress - The download has started, but has not completed yet.
+ *   <li>Finishing* - The download is finishing (Only applicable when delegate is provided).
+ *   <li>Finished - The download has finished.
+ * </ol>
+ *
+ * <h5>The "Finishing" state</h5>
+ *
+ * <p>The Finishing state is a special state only applicable when a delegate FileDownloader is
+ * provided. The Finishing state can be considered most like the In Progress state, the main
+ * distinction being that no action is being performed during the In Progress state, but the
+ * delegate is running during the Finishing state.
+ *
+ * <p><em>Why not just run the delegate during In Progress?</em>
+ *
+ * <p>Because the delgate could be performing actual work (i.e. network calls, I/O), there could be
+ * some variability introduced that causes test assertions to become flaky. The In Progress is
+ * reserved to effectively be a Frozen state that ensure no work is being done. This ensures that
+ * test assertions remain consistent.
+ *
+ * <h5>Controlling the states</h5>
+ *
+ * <p>After creating an instance of BlockingFileDownloader, the following example shows how the
+ * BlockingFileDownloader can control the state as well as when assertions can be made.
+ *
+ * <pre>{@code
+ * // Before the call to MDD's downloadFileGroup, the state is considered "Idle" and assertions
+ * // can be made at this time.
+ *
+ * mdd.downloadFileGroup(...);
+ *
+ * // After calling downloadFileGroup, the state is in a transition period from "Idle" to
+ * // "In Progress." assertions should not be made during this time.
+ *
+ * blockingFileDownloader.waitForDownloadStarted();
+ *
+ * // The above method blocks until the "In Progress" state has been reached. Assertions can be made
+ * // after this call for the "In Progress" state. If no assertions need to be made during this
+ * // state, the above call can be skipped.
+ *
+ * blockingFileDownloader.finishDownloading();
+ *
+ * // The above method moves the state from "In Progress" to "Finishing" (if a delegate is provided)
+ * // or "Finished" (if no delegate is provided). This is another transition period, so assertions
+ * // should not be made during this time.
+ *
+ * blockingFileDownloader.waitForDownloadCompleted();
+ *
+ * // The above method ensures the state has changed from "In Progress"/"Finishing" to "Finished."
+ * // After this point, assertions can be made safely again.
+ * //
+ * // Optionally, the future returned from mdd.downloadFileGroup can be awaited instead, since that
+ * // also ensures the download has completed.
+ * }</pre>
+ */
+public final class BlockingFileDownloader implements FileDownloader {
+  private static final GoogleLogger logger = GoogleLogger.forEnclosingClass();
+
+  private final ListeningExecutorService downloadExecutor;
+  private final Optional<FileDownloader> delegateFileDownloaderOptional;
+
+  private CountDownLatch downloadInProgressLatch = new CountDownLatch(1);
+  private CountDownLatch downloadFinishingLatch = new CountDownLatch(1);
+  private CountDownLatch delegateInProgressLatch = new CountDownLatch(1);
+  private CountDownLatch downloadFinishedLatch = new CountDownLatch(1);
+
+  public BlockingFileDownloader(ListeningExecutorService downloadExecutor) {
+    this.downloadExecutor = downloadExecutor;
+    this.delegateFileDownloaderOptional = Optional.absent();
+  }
+
+  public BlockingFileDownloader(
+      ListeningExecutorService downloadExecutor, FileDownloader delegateFileDownloader) {
+    this.downloadExecutor = downloadExecutor;
+    this.delegateFileDownloaderOptional = Optional.of(delegateFileDownloader);
+  }
+
+  @Override
+  public ListenableFuture<Void> startDownloading(DownloadRequest downloadRequest) {
+    ListenableFuture<Void> downloadFuture =
+        Futures.submitAsync(
+            () -> {
+              logger.atInfo().log("Download Started, state changed to: In Progress");
+              downloadInProgressLatch.countDown();
+
+              logger.atInfo().log("Waiting for download to continue");
+              downloadFinishingLatch.await();
+
+              ListenableFuture<Void> result;
+              if (delegateFileDownloaderOptional.isPresent()) {
+                logger.atInfo().log("Download State Changed to: Finishing (delegate in progress)");
+                // Delegate was provided, so perform its download
+                result = delegateFileDownloaderOptional.get().startDownloading(downloadRequest);
+                delegateInProgressLatch.countDown();
+              } else {
+                result = Futures.immediateVoidFuture();
+              }
+
+              return result;
+            },
+            downloadExecutor);
+
+    // Add a callback to ensure the state transitions from "In Progress"/"Finishing" to "Finished."
+    Futures.addCallback(
+        downloadFuture,
+        new FutureCallback<Void>() {
+          @Override
+          public void onSuccess(Void unused) {
+            BlockingFileDownloader.logger.atInfo().log("Download State Changed to: Finished");
+            downloadFinishedLatch.countDown();
+          }
+
+          @Override
+          public void onFailure(Throwable t) {
+            BlockingFileDownloader.logger
+                .atSevere()
+                .withCause(t)
+                .log("Download State Changed to: Finished (with error)");
+            downloadFinishedLatch.countDown();
+          }
+        },
+        downloadExecutor);
+
+    return downloadFuture;
+  }
+
+  /** Blocks the caller thread until the download has moved to the "In Progress" state. */
+  public void waitForDownloadStarted() throws InterruptedException {
+    downloadInProgressLatch.await();
+  }
+
+  /** Blocks the caller thread until the download has moved to the "Finishing" state. */
+  public void waitForDownloadCompleted() throws InterruptedException {
+    downloadFinishedLatch.await();
+  }
+
+  /**
+   * Blocks the caller thread until the delegate downloader has been invoked. This method will never
+   * complete if no delegate was provided.
+   */
+  public void waitForDelegateStarted() throws InterruptedException {
+    delegateInProgressLatch.await();
+  }
+
+  /**
+   * Finishes the current download lifecycle.
+   *
+   * <p>The in progress latch is triggered if it hasn't yet been triggered to prevent a deadlock
+   * from occurring.
+   */
+  public void finishDownloading() {
+    if (downloadInProgressLatch.getCount() > 0) {
+      downloadInProgressLatch.countDown();
+    }
+    downloadFinishingLatch.countDown();
+  }
+
+  /**
+   * Resets the current state of the download lifecycle.
+   *
+   * <p>An existing download cycle is finished before resetting the state to prevent a deadlock from
+   * occurring.
+   */
+  public void resetState() {
+    // Force a finish download if it was previously in progress to prevent deadlock.
+    if (downloadFinishedLatch.getCount() > 0) {
+      finishDownloading();
+    }
+    downloadFinishedLatch.countDown();
+
+    logger.atInfo().log("Reset State back to: Idle");
+    downloadInProgressLatch = new CountDownLatch(1);
+    downloadFinishingLatch = new CountDownLatch(1);
+    downloadFinishedLatch = new CountDownLatch(1);
+    delegateInProgressLatch = new CountDownLatch(1);
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/testing/FakeLogger.java b/javatests/com/google/android/libraries/mobiledatadownload/testing/FakeLogger.java
new file mode 100644
index 0000000..1790a93
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/testing/FakeLogger.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.testing;
+
+import com.google.android.libraries.mobiledatadownload.Logger;
+import com.google.common.collect.ImmutableList;
+import com.google.protobuf.MessageLite;
+
+/** Fake Logger implementation that saves event codes sent to it. */
+public final class FakeLogger implements Logger {
+
+  private final ImmutableList.Builder<Integer> logEvents;
+
+  public FakeLogger() {
+    logEvents = ImmutableList.<Integer>builder();
+  }
+
+  @Override
+  public void log(MessageLite msg, int eventCode) {
+    logEvents.add(eventCode);
+  }
+
+  /** Returns an ImmutableList containing all the event codes that have been sent to this logger. */
+  public ImmutableList<Integer> getLogEvents() {
+    return logEvents.build();
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/testing/FakeTimeSource.java b/javatests/com/google/android/libraries/mobiledatadownload/testing/FakeTimeSource.java
new file mode 100644
index 0000000..c8e7fa8
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/testing/FakeTimeSource.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.testing;
+
+import com.google.android.libraries.mobiledatadownload.TimeSource;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+
+/** Fake implementation of {@link TimeSource} for testing. */
+public final class FakeTimeSource implements TimeSource {
+
+  private final AtomicLong currentMillis = new AtomicLong();
+
+  @Override
+  public long currentTimeMillis() {
+    return currentMillis.get();
+  }
+
+  /** Advances the current time and returns {@code this}. */
+  public FakeTimeSource advance(long interval, TimeUnit units) {
+    long millis = units.toMillis(interval);
+    if (millis < 0) {
+      throw new IllegalArgumentException("Can't advance negative duration: " + millis);
+    }
+    currentMillis.getAndAdd(millis);
+    return this;
+  }
+
+  /** Sets the current time and returns {@code this}. */
+  public FakeTimeSource set(long millis) {
+    if (millis < 0) {
+      throw new IllegalArgumentException("Can't set before unix epoch:" + millis);
+    }
+    currentMillis.set(millis);
+    return this;
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/testing/LocalFileDownloader.java b/javatests/com/google/android/libraries/mobiledatadownload/testing/LocalFileDownloader.java
new file mode 100644
index 0000000..fb2a90c
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/testing/LocalFileDownloader.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.testing;
+
+import static com.google.common.util.concurrent.Futures.immediateFailedFuture;
+import static com.google.common.util.concurrent.Futures.immediateVoidFuture;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.DownloadException;
+import com.google.android.libraries.mobiledatadownload.DownloadException.DownloadResultCode;
+import com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest;
+import com.google.android.libraries.mobiledatadownload.downloader.FileDownloader;
+import com.google.android.libraries.mobiledatadownload.file.Opener;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.openers.ReadStreamOpener;
+import com.google.android.libraries.mobiledatadownload.file.openers.WriteStreamOpener;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil;
+import com.google.common.io.ByteStreams;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.concurrent.Executor;
+
+/**
+ * A {@link FileDownloader} that "downloads" by copying the file from the local folder.
+ *
+ * <p>Note that LocalFileDownloader ignores DownloadConditions.
+ */
+public final class LocalFileDownloader implements FileDownloader {
+
+  private static final String TAG = "LocalFileDownloader";
+
+  private final Executor backgroudExecutor;
+  private final SynchronousFileStorage fileStorage;
+
+  public LocalFileDownloader(
+      SynchronousFileStorage fileStorage, ListeningExecutorService executor) {
+    this.fileStorage = fileStorage;
+    this.backgroudExecutor = executor;
+  }
+
+  @Override
+  public ListenableFuture<Void> startDownloading(DownloadRequest downloadRequest) {
+    return Futures.submitAsync(() -> startDownloadingInternal(downloadRequest), backgroudExecutor);
+  }
+
+  private ListenableFuture<Void> startDownloadingInternal(DownloadRequest downloadRequest) {
+    Uri fileUri = downloadRequest.fileUri();
+    String urlToDownload = downloadRequest.urlToDownload();
+    LogUtil.d("%s: startDownloading; fileUri: %s; urlToDownload: %s", TAG, fileUri, urlToDownload);
+
+    Uri uriToDownload = Uri.parse(urlToDownload);
+    if (uriToDownload == null) {
+      LogUtil.e("%s: Invalid urlToDownload %s", TAG, urlToDownload);
+      return immediateFailedFuture(new IllegalArgumentException("Invalid urlToDownload"));
+    }
+
+    try {
+      Opener<InputStream> readStreamOpener = ReadStreamOpener.create();
+      Opener<OutputStream> writeStreamOpener = WriteStreamOpener.create();
+      long writtenBytes;
+      try (InputStream in = fileStorage.open(uriToDownload, readStreamOpener);
+          OutputStream out = fileStorage.open(fileUri, writeStreamOpener)) {
+        writtenBytes = ByteStreams.copy(in, out);
+      }
+      LogUtil.d("%s: File URI %s download complete, writtenBytes: %d", TAG, fileUri, writtenBytes);
+    } catch (IOException e) {
+      LogUtil.e(e, "%s: startDownloading got exception", TAG);
+      return immediateFailedFuture(
+          DownloadException.builder()
+              .setDownloadResultCode(DownloadResultCode.ANDROID_DOWNLOADER_HTTP_ERROR)
+              .build());
+    }
+
+    return immediateVoidFuture();
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/testing/MddNotificationCapture.java b/javatests/com/google/android/libraries/mobiledatadownload/testing/MddNotificationCapture.java
new file mode 100644
index 0000000..96454e7
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/testing/MddNotificationCapture.java
@@ -0,0 +1,308 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.testing;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.content.Context;
+import android.os.Build.VERSION;
+import android.os.Build.VERSION_CODES;
+import android.service.notification.StatusBarNotification;
+import com.google.android.apps.common.testing.util.AndroidTestUtil;
+import com.google.android.libraries.mobiledatadownload.foreground.NotificationUtil;
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableList;
+import com.google.common.truth.Correspondence;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import javax.annotation.Nullable;
+
+/** Testing Utility to capture notifications and make assertions. */
+public interface MddNotificationCapture {
+  /**
+   * ADB shell command to clear notifications.
+   *
+   * <p>This command uses the notification service (i.e. NotificationManager) and calls the first
+   * method in that services idl definition. For our service, this refers to the method <a
+   * href="<internal>">cancelAllNotifications</a> in the INotificationManager.aidl.
+   */
+  static final String CLEAR_NOTIFICATIONS_CMD = "service call notification 1";
+
+  /**
+   * ADB shell command to dump notification content to logcat.
+   *
+   * <p>The {@code dumpsys} command is used to dump system diagnostics. We are interested in
+   * notifications, so they are specified here. The {@code --noredact} flag is added to provided
+   * unredacted info (the actual values of variables) instead of just their type definitions. This
+   * flag has varying levels of support across the API levels, but should provide equivalent or
+   * greater information in the log dump that can be captured.
+   */
+  static final String DUMP_NOTIFICATION_CMD = "dumpsys notification --noredact";
+
+  static final ImmutableList<Integer> MDD_ICON_IDS =
+      ImmutableList.of(
+          android.R.drawable.stat_sys_download,
+          android.R.drawable.stat_sys_download_done,
+          android.R.drawable.stat_sys_warning);
+
+  public static void clearNotifications() {
+    try {
+      AndroidTestUtil.executeShellCommand(CLEAR_NOTIFICATIONS_CMD);
+    } catch (IOException e) {
+      throw new IllegalStateException("Unable to execute shell command", e);
+    }
+  }
+
+  public static MddNotificationCapture snapshot(Context context) {
+    // Capturing active notifications is only available for API level 23 and above. Check to see if
+    // the current version supports it, otherwise fall back to using adb to capture notification
+    // content.
+    if (VERSION.SDK_INT >= VERSION_CODES.M) {
+      NotificationManager manager =
+          (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
+      StatusBarNotification[] activeNotifications = manager.getActiveNotifications();
+      List<Notification> notifications = new ArrayList<>();
+      for (StatusBarNotification notification : activeNotifications) {
+        // TODO(b/148401016): Add some test to ensure only MDD notifications are included in this
+        // list.
+        notifications.add(notification.getNotification());
+      }
+      return new MPlusNotificationCapture(context, notifications);
+    }
+    try {
+      // NotificationManager.getActivitNotifications() is unavailable, fallback to using adb
+      String result = AndroidTestUtil.executeShellCommand(DUMP_NOTIFICATION_CMD);
+      return new PreMNotificationCapture(context, result);
+    } catch (IOException e) {
+      throw new IllegalStateException("Unable to execute shell command", e);
+    }
+  }
+
+  void assertStartNotificationCaptured(String title, String text);
+
+  void assertSuccessNotificationCaptured(String title);
+
+  void assertFailedNotificationCaptured(String title);
+
+  void assertPausedNotificationCaptured(String title);
+
+  void assertNoNotificationsCaptured();
+
+  /**
+   * Implementation of Notification Capture for API levels below 23 (Android M).
+   *
+   * <p>This implementation relies on content captured from adb about notifictions. The primary
+   * method of finding results is using Regexes to search for relevant pieces of information about
+   * captured notifications.
+   */
+  public static class PreMNotificationCapture implements MddNotificationCapture {
+    private final Context context;
+    private final String notificationOutput;
+
+    private static final Pattern CONTENT_TITLE_PATTERN =
+        Pattern.compile("android\\.title=String\\s\\((.+)\\)");
+    private static final Pattern CONTENT_TEXT_PATTERN =
+        Pattern.compile("android\\.text=String\\s\\((.+)\\)");
+    private static final Pattern ICON_PATTERN = Pattern.compile("icon=0x([a-fA-F0-9]+)");
+
+    private PreMNotificationCapture(Context context, String notificationOutput) {
+      this.context = context;
+      this.notificationOutput = notificationOutput;
+    }
+
+    @Override
+    public void assertStartNotificationCaptured(String title, String text) {
+      assertNotificationCapturedMatches(title, text, android.R.drawable.stat_sys_download);
+    }
+
+    @Override
+    public void assertSuccessNotificationCaptured(String title) {
+      assertNotificationCapturedMatches(
+          title,
+          NotificationUtil.getDownloadSuccessMessage(context),
+          android.R.drawable.stat_sys_download_done);
+    }
+
+    @Override
+    public void assertFailedNotificationCaptured(String title) {
+      assertNotificationCapturedMatches(
+          title,
+          NotificationUtil.getDownloadFailedMessage(context),
+          android.R.drawable.stat_sys_warning);
+    }
+
+    @Override
+    public void assertPausedNotificationCaptured(String title) {
+      assertNotificationCapturedMatches(
+          title,
+          NotificationUtil.getDownloadPausedMessage(context),
+          android.R.drawable.stat_sys_download);
+    }
+
+    @Override
+    public void assertNoNotificationsCaptured() {
+      List<String> titleMatches = getMatching(CONTENT_TITLE_PATTERN);
+      List<String> textMatches = getMatching(CONTENT_TEXT_PATTERN);
+      List<String> iconMatches = getMatching(ICON_PATTERN);
+
+      assertThat(titleMatches).isEmpty();
+      assertThat(textMatches).isEmpty();
+
+      // Capturing through adb includes inactive notifications too, so just make sure none of the
+      // MDD notifications were capturesd.
+      assertThat(iconMatches)
+          .comparingElementsUsing(
+              Correspondence.<String, Integer>transforming(
+                  match -> {
+                    // Our regex should capture only valid hexadecimal values
+                    int iconResId = Integer.parseInt(match, 16);
+                    return iconResId;
+                  },
+                  "convert to resource id"))
+          .containsNoneIn(MDD_ICON_IDS);
+    }
+
+    // TODO(b/148401016): Remove "unused" when title/text can be matched.
+    private void assertNotificationCapturedMatches(
+        String unusedTitle, String unusedText, int icon) {
+      /* List<String> titleMatches = getMatching(CONTENT_TITLE_PATTERN);
+       * List<String> textMatches = getMatching(CONTENT_TEXT_PATTERN); */
+      List<String> iconMatches = getMatching(ICON_PATTERN);
+
+      // TODO(b/148401016): Figure out how to access unredacted title and text content to match.
+      /* assertThat(titleMatches)
+       *     .comparingElementsUsing(
+       *         Correspondence.<String, Boolean>transforming(
+       *             match -> match.contains(title), "is a title match"))
+       *     .contains(true);
+       * assertThat(textMatches)
+       *     .comparingElementsUsing(
+       *         Correspondence.<String, Boolean>transforming(
+       *             match -> match.contains(text), "is a text match"))
+       *     .contains(true); */
+      assertThat(iconMatches)
+          .comparingElementsUsing(
+              Correspondence.<String, Boolean>transforming(
+                  match -> {
+                    // Our regex should capture only valid hexadecimal values
+                    int iconResId = Integer.parseInt(match, 16);
+                    return iconResId == icon;
+                  },
+                  "is an icon match"))
+          .contains(true);
+    }
+
+    private List<String> getMatching(Pattern pattern) {
+      List<String> matches = new ArrayList<>();
+      Matcher matcher = pattern.matcher(notificationOutput);
+      while (matcher.find()) {
+        matches.add(matcher.group(1).trim());
+      }
+      return matches;
+    }
+  }
+
+  /**
+   * Implementation of Notification Capture for API level 23 (Android M) and above.
+   *
+   * <p>This implementation relies on capturing Notifications using {@link
+   * NotificationManager#getActiveNotifications}. Available parts of the {@link Notification} are
+   * used to check for matching notifications.
+   */
+  public static class MPlusNotificationCapture implements MddNotificationCapture {
+    private final Context context;
+    private final List<Notification> notifications;
+
+    private MPlusNotificationCapture(Context context, List<Notification> notifications) {
+      Preconditions.checkState(
+          VERSION.SDK_INT >= VERSION_CODES.M, "This implementation only works for M+ devices");
+      this.context = context;
+      this.notifications = notifications;
+    }
+
+    @Override
+    public void assertStartNotificationCaptured(String title, String text) {
+      assertThat(notifications)
+          .comparingElementsUsing(
+              createMatcherForNotification(
+                  title, text, android.R.drawable.stat_sys_download, "is a start notification"))
+          .contains(true);
+    }
+
+    @Override
+    public void assertSuccessNotificationCaptured(String title) {
+      assertThat(notifications)
+          .comparingElementsUsing(
+              createMatcherForNotification(
+                  title,
+                  NotificationUtil.getDownloadSuccessMessage(context),
+                  android.R.drawable.stat_sys_download_done,
+                  "is a success notification"))
+          .contains(true);
+    }
+
+    @Override
+    public void assertFailedNotificationCaptured(String title) {
+      assertThat(notifications)
+          .comparingElementsUsing(
+              createMatcherForNotification(
+                  title,
+                  NotificationUtil.getDownloadFailedMessage(context),
+                  android.R.drawable.stat_sys_warning,
+                  "is a failed notification"))
+          .contains(true);
+    }
+
+    @Override
+    public void assertPausedNotificationCaptured(String title) {
+      assertThat(notifications)
+          .comparingElementsUsing(
+              createMatcherForNotification(
+                  title,
+                  NotificationUtil.getDownloadPausedMessage(context),
+                  android.R.drawable.stat_sys_download,
+                  "is a paused notification"))
+          .contains(true);
+    }
+
+    @Override
+    public void assertNoNotificationsCaptured() {
+      assertThat(notifications).isEmpty();
+    }
+
+    private static Correspondence<Notification, Boolean> createMatcherForNotification(
+        String title, String text, int icon, String tag) {
+      return Correspondence.transforming(
+          (@Nullable Notification actual) -> {
+            if (actual == null) {
+              return false;
+            }
+
+            boolean matches =
+                String.valueOf(actual.extras.getCharSequence("android.title")).equals(title)
+                    && String.valueOf(actual.extras.getCharSequence("android.text")).equals(text)
+                    && (actual.getSmallIcon().getResId() == icon);
+            return matches;
+          },
+          tag);
+    }
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/testing/RobolectricFileDownloader.java b/javatests/com/google/android/libraries/mobiledatadownload/testing/RobolectricFileDownloader.java
new file mode 100644
index 0000000..504c151
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/testing/RobolectricFileDownloader.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.testing;
+
+import static com.google.common.util.concurrent.Futures.immediateVoidFuture;
+
+import android.net.Uri;
+import com.google.android.libraries.mobiledatadownload.downloader.DownloadConstraints;
+import com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest;
+import com.google.android.libraries.mobiledatadownload.downloader.FileDownloader;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.FileUri;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.devtools.build.runtime.RunfilesPaths;
+import java.nio.file.Path;
+
+/**
+ * A {@link FileDownloader} suitable for use in Robolectric tests that "downloads" by copying the
+ * file from the testdata folder.
+ *
+ * <p>The filename is the Last Path Segment of the urlToDownload. For example, the URL
+ * https://www.gstatic.com/icing/idd/sample_group/step1.txt will be mapped to the file
+ * testSrcDirectory/testDataRelativePath/step1.txt. See <internal> for additional information on
+ * providing data files for tests.
+ *
+ * <p>Note that TestFileDownloader ignores the DownloadConditions.
+ */
+public final class RobolectricFileDownloader implements FileDownloader {
+
+  private final String testDataRelativePath;
+  private final FileDownloader delegateDownloader;
+
+  public RobolectricFileDownloader(
+      String testDataRelativePath,
+      SynchronousFileStorage fileStorage,
+      ListeningExecutorService executor) {
+    this.testDataRelativePath = testDataRelativePath;
+    this.delegateDownloader = new LocalFileDownloader(fileStorage, executor);
+  }
+
+  @Override
+  public ListenableFuture<Void> startDownloading(DownloadRequest downloadRequest) {
+    Uri fileUri = downloadRequest.fileUri();
+    String urlToDownload = downloadRequest.urlToDownload();
+    DownloadConstraints downloadConstraints = downloadRequest.downloadConstraints();
+
+    // We need to translate the real urlToDownload to the one representing the local file in
+    // testdata folder.
+    Uri uriToDownload = Uri.parse(urlToDownload.trim());
+    if (uriToDownload == null) {
+      return immediateVoidFuture();
+    }
+
+    Path testDataPath = RunfilesPaths.resolve(testDataRelativePath);
+    Path uriToDownloadPath = testDataPath.resolve(uriToDownload.getLastPathSegment());
+
+    String testDataUrl = FileUri.builder().setPath(uriToDownloadPath.toString()).build().toString();
+
+    return delegateDownloader.startDownloading(
+        DownloadRequest.newBuilder()
+            .setFileUri(fileUri)
+            .setUrlToDownload(testDataUrl)
+            .setDownloadConstraints(downloadConstraints)
+            .build());
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/testing/TestFileDownloader.java b/javatests/com/google/android/libraries/mobiledatadownload/testing/TestFileDownloader.java
new file mode 100644
index 0000000..fb42c1a
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/testing/TestFileDownloader.java
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.testing;
+
+import static com.google.common.util.concurrent.Futures.immediateVoidFuture;
+
+import android.net.Uri;
+import android.os.Environment;
+import com.google.android.libraries.mobiledatadownload.downloader.DownloadConstraints;
+import com.google.android.libraries.mobiledatadownload.downloader.DownloadRequest;
+import com.google.android.libraries.mobiledatadownload.downloader.FileDownloader;
+import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
+import com.google.android.libraries.mobiledatadownload.file.backends.FileUri;
+import com.google.android.libraries.mobiledatadownload.internal.logging.LogUtil;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+
+/**
+ * A {@link FileDownloader} that "downloads" by copying the file from the testdata folder.
+ *
+ * <p>The filename is the Last Path Segment of the urlToDownload. For example, the URL
+ * https://www.gstatic.com/icing/idd/sample_group/step1.txt will be mapped to the file
+ * testDataAbsolutePath/step1.txt.
+ *
+ * <p>Note that TestFileDownloader ignores the DownloadConditions.
+ */
+public final class TestFileDownloader implements FileDownloader {
+
+  private static final String TAG = "TestDataFileDownloader";
+
+  private static final String GOOGLE3_ABSOLUTE_PATH =
+      Environment.getExternalStorageDirectory() + "/googletest/test_runfiles/google3/";
+
+  private final String testDataAbsolutePath;
+  private final FileDownloader delegateDownloader;
+
+  public TestFileDownloader(
+      String testDataRelativePath,
+      SynchronousFileStorage fileStorage,
+      ListeningExecutorService executor) {
+    this.testDataAbsolutePath = GOOGLE3_ABSOLUTE_PATH + testDataRelativePath;
+    this.delegateDownloader = new LocalFileDownloader(fileStorage, executor);
+  }
+
+  @Override
+  public ListenableFuture<Void> startDownloading(DownloadRequest downloadRequest) {
+    Uri fileUri = downloadRequest.fileUri();
+    String urlToDownload = downloadRequest.urlToDownload();
+    DownloadConstraints downloadConstraints = downloadRequest.downloadConstraints();
+
+    // We need to translate the real urlToDownload to the one representing the local file in
+    // testdata folder.
+    Uri uriToDownload = Uri.parse(urlToDownload.trim());
+    if (uriToDownload == null) {
+      LogUtil.e("%s: Invalid urlToDownload %s", TAG, urlToDownload);
+      return immediateVoidFuture();
+    }
+    if (uriToDownload.getPath().endsWith("odws1_empty.jar")) {
+      // TODO(b/222519077): this is necessary to adapt the real file URL to local testdata
+      uriToDownload =
+          Uri.parse(uriToDownload.getPath().substring(0, uriToDownload.getPath().length() - 4));
+    }
+
+    String testDataUrl =
+        FileUri.builder()
+            .setPath(testDataAbsolutePath + uriToDownload.getLastPathSegment())
+            .build()
+            .toString();
+
+    return delegateDownloader.startDownloading(
+        DownloadRequest.newBuilder()
+            .setFileUri(fileUri)
+            .setUrlToDownload(testDataUrl)
+            .setDownloadConstraints(downloadConstraints)
+            .build());
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/testing/TestFlags.java b/javatests/com/google/android/libraries/mobiledatadownload/testing/TestFlags.java
new file mode 100644
index 0000000..85c9648
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/testing/TestFlags.java
@@ -0,0 +1,411 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.testing;
+
+import com.google.android.libraries.mobiledatadownload.Flags;
+import com.google.common.base.Optional;
+
+/** Default {@link Flags} with simple overrides for ease of testing. */
+public final class TestFlags implements Flags {
+
+  public Optional<Boolean> clearStateOnMddDisabled = Optional.absent();
+  public Optional<Boolean> mddDeleteGroupsRemovedAccounts = Optional.absent();
+  public Optional<Boolean> broadcastNewlyDownloadedGroups = Optional.absent();
+  public Optional<Boolean> logFileGroupsWithFilesMissing = Optional.absent();
+  public Optional<Boolean> deleteFileGroupsWithFilesMissing = Optional.absent();
+  public Optional<Boolean> dumpMddInfo = Optional.absent();
+  public Optional<Boolean> enableDebugUi = Optional.absent();
+  public Optional<Boolean> enableClientErrorLogging = Optional.absent();
+  public Optional<Integer> fileKeyVersion = Optional.absent();
+  public Optional<Boolean> testOnlyFileKeyVersion = Optional.absent();
+  public Optional<Boolean> enableCompressedFile = Optional.absent();
+  public Optional<Boolean> enableZipFolder = Optional.absent();
+  public Optional<Boolean> enableDeltaDownload = Optional.absent();
+  public Optional<Boolean> enableMddGcmService = Optional.absent();
+  public Optional<Boolean> enableSilentFeedback = Optional.absent();
+  public Optional<Boolean> migrateToNewFileKey = Optional.absent();
+  public Optional<Boolean> migrateFileExpirationPolicy = Optional.absent();
+  public Optional<Boolean> downloadFirstOnWifiThenOnAnyNetwork = Optional.absent();
+  public Optional<Boolean> logStorageStats = Optional.absent();
+  public Optional<Boolean> logNetworkStats = Optional.absent();
+  public Optional<Boolean> removeGroupkeysWithDownloadedFieldNotSet = Optional.absent();
+  public Optional<Boolean> cacheLastLocation = Optional.absent();
+  public Optional<Integer> locationCustomParamS2Level = Optional.absent();
+  public Optional<Integer> locationTaskTimeoutSec = Optional.absent();
+  public Optional<Boolean> addConfigsFromPhenotype = Optional.absent();
+  public Optional<Boolean> enableMobileDataDownload = Optional.absent();
+  public Optional<Integer> mddResetTrigger = Optional.absent();
+  public Optional<Boolean> mddEnableDownloadPendingGroups = Optional.absent();
+  public Optional<Boolean> mddEnableVerifyPendingGroups = Optional.absent();
+  public Optional<Boolean> mddEnableGarbageCollection = Optional.absent();
+  public Optional<Boolean> mddDeleteUninstalledApps = Optional.absent();
+  public Optional<Boolean> enableMobstoreFileService = Optional.absent();
+  public Optional<Boolean> enableDelayedDownload = Optional.absent();
+  public Optional<Boolean> gcmRescheduleOnlyOncePerProcessStart = Optional.absent();
+  public Optional<Boolean> gmsMddSwitchToCronet = Optional.absent();
+  public Optional<Boolean> enableDaysSinceLastMaintenanceTracking = Optional.absent();
+  public Optional<Boolean> enableSideloading = Optional.absent();
+  public Optional<Boolean> enableDownloadStageExperimentIdPropagation = Optional.absent();
+  public Optional<Boolean> enableIsolatedStructureVerification = Optional.absent();
+  public Optional<Boolean> enableRngBasedDeviceStableSampling = Optional.absent();
+  public Optional<Long> maintenanceGcmTaskPeriod = Optional.absent();
+  public Optional<Long> chargingGcmTaskPeriod = Optional.absent();
+  public Optional<Long> cellularChargingGcmTaskPeriod = Optional.absent();
+  public Optional<Long> wifiChargingGcmTaskPeriod = Optional.absent();
+  public Optional<Integer> mddDefaultSampleInterval = Optional.absent();
+  public Optional<Integer> mddDownloadEventsSampleInterval = Optional.absent();
+  public Optional<Integer> groupStatsLoggingSampleInterval = Optional.absent();
+  public Optional<Integer> apiLoggingSampleInterval = Optional.absent();
+  public Optional<Integer> cleanupLogLoggingSampleInterval = Optional.absent();
+  public Optional<Integer> silentFeedbackSampleInterval = Optional.absent();
+  public Optional<Integer> storageStatsLoggingSampleInterval = Optional.absent();
+  public Optional<Integer> networkStatsLoggingSampleInterval = Optional.absent();
+  public Optional<Integer> mobstoreFileServiceStatsSampleInterval = Optional.absent();
+  public Optional<Integer> mddAndroidSharingSampleInterval = Optional.absent();
+  public Optional<Boolean> downloaderEnforceHttps = Optional.absent();
+  public Optional<Boolean> enforceLowStorageBehavior = Optional.absent();
+  public Optional<Integer> absFreeSpaceAfterDownload = Optional.absent();
+  public Optional<Integer> absFreeSpaceAfterDownloadLowStorageAllowed = Optional.absent();
+  public Optional<Integer> absFreeSpaceAfterDownloadExtremelyLowStorageAllowed = Optional.absent();
+  public Optional<Float> fractionFreeSpaceAfterDownload = Optional.absent();
+  public Optional<Integer> timeToWaitForDownloader = Optional.absent();
+  public Optional<Integer> downloaderMaxThreads = Optional.absent();
+  public Optional<Integer> downloaderMaxRetryOnChecksumMismatchCount = Optional.absent();
+
+  private final Flags delegate = new Flags() {};
+
+  @Override
+  public boolean clearStateOnMddDisabled() {
+    return clearStateOnMddDisabled.or(delegate.clearStateOnMddDisabled());
+  }
+
+  @Override
+  public boolean mddDeleteGroupsRemovedAccounts() {
+    return mddDeleteGroupsRemovedAccounts.or(delegate.mddDeleteGroupsRemovedAccounts());
+  }
+
+  @Override
+  public boolean broadcastNewlyDownloadedGroups() {
+    return broadcastNewlyDownloadedGroups.or(delegate.broadcastNewlyDownloadedGroups());
+  }
+
+  @Override
+  public boolean logFileGroupsWithFilesMissing() {
+    return logFileGroupsWithFilesMissing.or(delegate.logFileGroupsWithFilesMissing());
+  }
+
+  @Override
+  public boolean deleteFileGroupsWithFilesMissing() {
+    return deleteFileGroupsWithFilesMissing.or(delegate.deleteFileGroupsWithFilesMissing());
+  }
+
+  @Override
+  public boolean dumpMddInfo() {
+    return dumpMddInfo.or(delegate.dumpMddInfo());
+  }
+
+  @Override
+  public boolean enableDebugUi() {
+    return enableDebugUi.or(delegate.enableDebugUi());
+  }
+
+  @Override
+  public boolean enableClientErrorLogging() {
+    return enableClientErrorLogging.or(delegate.enableClientErrorLogging());
+  }
+
+  @Override
+  public int fileKeyVersion() {
+    return fileKeyVersion.or(delegate.fileKeyVersion());
+  }
+
+  @Override
+  public boolean testOnlyFileKeyVersion() {
+    return testOnlyFileKeyVersion.or(delegate.testOnlyFileKeyVersion());
+  }
+
+  @Override
+  public boolean enableCompressedFile() {
+    return enableCompressedFile.or(delegate.enableCompressedFile());
+  }
+
+  @Override
+  public boolean enableZipFolder() {
+    return enableZipFolder.or(delegate.enableZipFolder());
+  }
+
+  @Override
+  public boolean enableDeltaDownload() {
+    return enableDeltaDownload.or(delegate.enableDeltaDownload());
+  }
+
+  @Override
+  public boolean enableMddGcmService() {
+    return enableMddGcmService.or(delegate.enableMddGcmService());
+  }
+
+  @Override
+  public boolean enableSilentFeedback() {
+    return enableSilentFeedback.or(delegate.enableSilentFeedback());
+  }
+
+  @Override
+  public boolean migrateToNewFileKey() {
+    return migrateToNewFileKey.or(delegate.migrateToNewFileKey());
+  }
+
+  @Override
+  public boolean migrateFileExpirationPolicy() {
+    return migrateFileExpirationPolicy.or(delegate.migrateFileExpirationPolicy());
+  }
+
+  @Override
+  public boolean downloadFirstOnWifiThenOnAnyNetwork() {
+    return downloadFirstOnWifiThenOnAnyNetwork.or(delegate.downloadFirstOnWifiThenOnAnyNetwork());
+  }
+
+  @Override
+  public boolean logStorageStats() {
+    return logStorageStats.or(delegate.logStorageStats());
+  }
+
+  @Override
+  public boolean logNetworkStats() {
+    return logNetworkStats.or(delegate.logNetworkStats());
+  }
+
+  @Override
+  public boolean removeGroupkeysWithDownloadedFieldNotSet() {
+    return removeGroupkeysWithDownloadedFieldNotSet.or(
+        delegate.removeGroupkeysWithDownloadedFieldNotSet());
+  }
+
+  @Override
+  public boolean cacheLastLocation() {
+    return cacheLastLocation.or(delegate.cacheLastLocation());
+  }
+
+  @Override
+  public int locationCustomParamS2Level() {
+    return locationCustomParamS2Level.or(delegate.locationCustomParamS2Level());
+  }
+
+  @Override
+  public int locationTaskTimeoutSec() {
+    return locationTaskTimeoutSec.or(delegate.locationTaskTimeoutSec());
+  }
+
+  @Override
+  public boolean addConfigsFromPhenotype() {
+    return addConfigsFromPhenotype.or(delegate.addConfigsFromPhenotype());
+  }
+
+  @Override
+  public boolean enableMobileDataDownload() {
+    return enableMobileDataDownload.or(delegate.enableMobileDataDownload());
+  }
+
+  @Override
+  public int mddResetTrigger() {
+    return mddResetTrigger.or(delegate.mddResetTrigger());
+  }
+
+  @Override
+  public boolean mddEnableDownloadPendingGroups() {
+    return mddEnableDownloadPendingGroups.or(delegate.mddEnableDownloadPendingGroups());
+  }
+
+  @Override
+  public boolean mddEnableVerifyPendingGroups() {
+    return mddEnableVerifyPendingGroups.or(delegate.mddEnableVerifyPendingGroups());
+  }
+
+  @Override
+  public boolean mddEnableGarbageCollection() {
+    return mddEnableGarbageCollection.or(delegate.mddEnableGarbageCollection());
+  }
+
+  @Override
+  public boolean mddDeleteUninstalledApps() {
+    return mddDeleteUninstalledApps.or(delegate.mddDeleteUninstalledApps());
+  }
+
+  @Override
+  public boolean enableMobstoreFileService() {
+    return enableMobstoreFileService.or(delegate.enableMobstoreFileService());
+  }
+
+  @Override
+  public boolean enableDelayedDownload() {
+    return enableDelayedDownload.or(delegate.enableDelayedDownload());
+  }
+
+  @Override
+  public boolean gcmRescheduleOnlyOncePerProcessStart() {
+    return gcmRescheduleOnlyOncePerProcessStart.or(delegate.gcmRescheduleOnlyOncePerProcessStart());
+  }
+
+  @Override
+  public boolean gmsMddSwitchToCronet() {
+    return gmsMddSwitchToCronet.or(delegate.gmsMddSwitchToCronet());
+  }
+
+  @Override
+  public boolean enableDaysSinceLastMaintenanceTracking() {
+    return enableDaysSinceLastMaintenanceTracking.or(
+        delegate.enableDaysSinceLastMaintenanceTracking());
+  }
+
+  @Override
+  public boolean enableSideloading() {
+    return enableSideloading.or(delegate.enableSideloading());
+  }
+
+  @Override
+  public boolean enableDownloadStageExperimentIdPropagation() {
+    return enableDownloadStageExperimentIdPropagation.or(
+        delegate.enableDownloadStageExperimentIdPropagation());
+  }
+
+  @Override
+  public boolean enableIsolatedStructureVerification() {
+    return enableIsolatedStructureVerification.or(delegate.enableIsolatedStructureVerification());
+  }
+
+  @Override
+  public boolean enableRngBasedDeviceStableSampling() {
+    return enableRngBasedDeviceStableSampling.or(delegate.enableRngBasedDeviceStableSampling());
+  }
+
+  @Override
+  public long maintenanceGcmTaskPeriod() {
+    return maintenanceGcmTaskPeriod.or(delegate.maintenanceGcmTaskPeriod());
+  }
+
+  @Override
+  public long chargingGcmTaskPeriod() {
+    return chargingGcmTaskPeriod.or(delegate.chargingGcmTaskPeriod());
+  }
+
+  @Override
+  public long cellularChargingGcmTaskPeriod() {
+    return cellularChargingGcmTaskPeriod.or(delegate.cellularChargingGcmTaskPeriod());
+  }
+
+  @Override
+  public long wifiChargingGcmTaskPeriod() {
+    return wifiChargingGcmTaskPeriod.or(delegate.wifiChargingGcmTaskPeriod());
+  }
+
+  @Override
+  public int mddDefaultSampleInterval() {
+    return mddDefaultSampleInterval.or(delegate.mddDefaultSampleInterval());
+  }
+
+  @Override
+  public int mddDownloadEventsSampleInterval() {
+    return mddDownloadEventsSampleInterval.or(delegate.mddDownloadEventsSampleInterval());
+  }
+
+  @Override
+  public int groupStatsLoggingSampleInterval() {
+    return groupStatsLoggingSampleInterval.or(delegate.groupStatsLoggingSampleInterval());
+  }
+
+  @Override
+  public int apiLoggingSampleInterval() {
+    return apiLoggingSampleInterval.or(delegate.apiLoggingSampleInterval());
+  }
+
+  @Override
+  public int cleanupLogLoggingSampleInterval() {
+    return cleanupLogLoggingSampleInterval.or(delegate.cleanupLogLoggingSampleInterval());
+  }
+
+  @Override
+  public int silentFeedbackSampleInterval() {
+    return silentFeedbackSampleInterval.or(delegate.silentFeedbackSampleInterval());
+  }
+
+  @Override
+  public int storageStatsLoggingSampleInterval() {
+    return storageStatsLoggingSampleInterval.or(delegate.storageStatsLoggingSampleInterval());
+  }
+
+  @Override
+  public int networkStatsLoggingSampleInterval() {
+    return networkStatsLoggingSampleInterval.or(delegate.networkStatsLoggingSampleInterval());
+  }
+
+  @Override
+  public int mobstoreFileServiceStatsSampleInterval() {
+    return mobstoreFileServiceStatsSampleInterval.or(
+        delegate.mobstoreFileServiceStatsSampleInterval());
+  }
+
+  @Override
+  public int mddAndroidSharingSampleInterval() {
+    return mddAndroidSharingSampleInterval.or(delegate.mddAndroidSharingSampleInterval());
+  }
+
+  @Override
+  public boolean downloaderEnforceHttps() {
+    return downloaderEnforceHttps.or(delegate.downloaderEnforceHttps());
+  }
+
+  @Override
+  public boolean enforceLowStorageBehavior() {
+    return enforceLowStorageBehavior.or(delegate.enforceLowStorageBehavior());
+  }
+
+  @Override
+  public int absFreeSpaceAfterDownload() {
+    return absFreeSpaceAfterDownload.or(delegate.absFreeSpaceAfterDownload());
+  }
+
+  @Override
+  public int absFreeSpaceAfterDownloadLowStorageAllowed() {
+    return absFreeSpaceAfterDownloadLowStorageAllowed.or(
+        delegate.absFreeSpaceAfterDownloadLowStorageAllowed());
+  }
+
+  @Override
+  public int absFreeSpaceAfterDownloadExtremelyLowStorageAllowed() {
+    return absFreeSpaceAfterDownloadExtremelyLowStorageAllowed.or(
+        delegate.absFreeSpaceAfterDownloadExtremelyLowStorageAllowed());
+  }
+
+  @Override
+  public float fractionFreeSpaceAfterDownload() {
+    return fractionFreeSpaceAfterDownload.or(delegate.fractionFreeSpaceAfterDownload());
+  }
+
+  @Override
+  public int timeToWaitForDownloader() {
+    return timeToWaitForDownloader.or(delegate.timeToWaitForDownloader());
+  }
+
+  @Override
+  public int downloaderMaxThreads() {
+    return downloaderMaxThreads.or(delegate.downloaderMaxThreads());
+  }
+
+  @Override
+  public int downloaderMaxRetryOnChecksumMismatchCount() {
+    return downloaderMaxRetryOnChecksumMismatchCount.or(
+        delegate.downloaderMaxRetryOnChecksumMismatchCount());
+  }
+}
diff --git a/javatests/com/google/android/libraries/mobiledatadownload/testing/TestHttpServer.java b/javatests/com/google/android/libraries/mobiledatadownload/testing/TestHttpServer.java
new file mode 100644
index 0000000..466d6d2
--- /dev/null
+++ b/javatests/com/google/android/libraries/mobiledatadownload/testing/TestHttpServer.java
@@ -0,0 +1,201 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.libraries.mobiledatadownload.testing;
+
+import android.net.Uri;
+import android.util.Log;
+import com.google.common.base.Optional;
+import java.io.File;
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.apache.http.Header;
+import org.apache.http.HttpException;
+import org.apache.http.HttpRequest;
+import org.apache.http.HttpResponse;
+import org.apache.http.HttpStatus;
+import org.apache.http.entity.FileEntity;
+import org.apache.http.impl.DefaultConnectionReuseStrategy;
+import org.apache.http.impl.DefaultHttpResponseFactory;
+import org.apache.http.impl.DefaultHttpServerConnection;
+import org.apache.http.params.BasicHttpParams;
+import org.apache.http.params.CoreConnectionPNames;
+import org.apache.http.params.HttpParams;
+import org.apache.http.protocol.BasicHttpContext;
+import org.apache.http.protocol.BasicHttpProcessor;
+import org.apache.http.protocol.HttpContext;
+import org.apache.http.protocol.HttpRequestHandler;
+import org.apache.http.protocol.HttpRequestHandlerRegistry;
+import org.apache.http.protocol.HttpService;
+
+/** TestHttpServer is a simple http server that listens to http requests on a single thread. */
+public final class TestHttpServer {
+
+  private static final String TAG = "TestHttpServer";
+  private static final String TEST_HOST = "localhost";
+
+  private static final String HEAD_REQUEST_METHOD = "HEAD";
+  private static final String ETAG_HEADER = "ETag";
+  private static final String IF_NONE_MATCH_HEADER = "If-None-Match";
+  private static final String BINARY_CONTENT_TYPE = "application/binary";
+  private static final String PROTO_CONTENT_TYPE = "application/x-protobuf";
+  private static final String TEXT_CONTENT_TYPE = "text/plain";
+
+  private final HttpParams httpParams = new BasicHttpParams();
+  private final HttpService httpService;
+  private final HttpRequestHandlerRegistry registry;
+  private final AtomicBoolean finished = new AtomicBoolean();
+
+  private Thread serverThread;
+  private ServerSocket serverSocket;
+  // 0 means user didn't specify a port number and will use automatically assigned port.
+  private final int userDesignatedPort;
+
+  public TestHttpServer() {
+    this(0);
+  }
+
+  public TestHttpServer(int portNumber) {
+    userDesignatedPort = portNumber;
+    httpParams.setBooleanParameter(CoreConnectionPNames.TCP_NODELAY, true);
+    registry = new HttpRequestHandlerRegistry();
+
+    httpService =
+        new HttpService(
+            new BasicHttpProcessor(),
+            new DefaultConnectionReuseStrategy(),
+            new DefaultHttpResponseFactory());
+    httpService.setHandlerResolver(registry);
+    httpService.setParams(httpParams);
+  }
+  /** Registers a handler for an endpoint pattern. */
+  public void registerHandler(String pattern, HttpRequestHandler handler) {
+    registry.register(pattern, handler);
+  }
+
+  /** Registers a handler that binds onto a text file for an endpoint pattern. */
+  public void registerTextFile(String pattern, String filepath) {
+    registerFile(pattern, filepath, TEXT_CONTENT_TYPE, /* eTagOptional = */ Optional.absent());
+  }
+
+  /** Registers a handler that binds onto a file for an endpoint pattern. */
+  public void registerBinaryFile(String pattern, String filepath) {
+    registerFile(pattern, filepath, BINARY_CONTENT_TYPE, /*eTagOptional=*/ Optional.absent());
+  }
+
+  /**
+   * Registers a handler that binds onto a proto file for an endpoint pattern with the specified
+   * ETag.
+   */
+  public void registerProtoFileWithETag(String pattern, String filepath, String eTag) {
+    registerFile(pattern, filepath, PROTO_CONTENT_TYPE, Optional.of(eTag));
+  }
+
+  private void registerFile(
+      String pattern, String filepath, String contentType, Optional<String> eTagOptional) {
+    registerHandler(
+        pattern,
+        (httpRequest, httpResponse, httpContext) -> {
+          if (eTagOptional.isPresent()) {
+            String eTag = eTagOptional.get();
+            httpResponse.addHeader(ETAG_HEADER, eTag);
+            setHttpStatusCode(httpRequest, httpResponse, eTag);
+            if (httpResponse.getStatusLine().getStatusCode() == HttpStatus.SC_NOT_MODIFIED
+                || HEAD_REQUEST_METHOD.equals(httpRequest.getRequestLine().getMethod())) {
+              return;
+            }
+          } else { // The ETag is not present.
+            httpResponse.setStatusCode(HttpStatus.SC_OK);
+          }
+          File file = new File(filepath);
+          httpResponse.setEntity(new FileEntity(file, contentType));
+        });
+  }
+
+  /** Starts the test http server and returns the prefix of the test url. */
+  public Uri.Builder startServer() throws IOException {
+    serverSocket =
+        new ServerSocket(
+            /*port=*/ userDesignatedPort, /*backlog=*/ 0, InetAddress.getByName(TEST_HOST));
+    serverThread =
+        new Thread(
+            () -> {
+              try {
+                while (!finished.get()) {
+                  Socket socket = serverSocket.accept();
+                  handleRequest(socket);
+                }
+              } catch (IOException e) {
+                Log.e(TAG, "Exception: " + e);
+              }
+            });
+    serverThread.start();
+    return getTestUrlPrefix();
+  }
+
+  public void stopServer() {
+    try {
+      finished.set(true);
+      serverSocket.close();
+      serverThread.join();
+    } catch (IOException | InterruptedException e) {
+      Log.e(TAG, "Exception when stopping server: " + e);
+    }
+  }
+
+  private void handleRequest(Socket socket) {
+    DefaultHttpServerConnection connection = new DefaultHttpServerConnection();
+    try {
+      connection.bind(socket, httpParams);
+      HttpContext httpContext = new BasicHttpContext();
+      httpService.handleRequest(connection, httpContext);
+    } catch (IOException | HttpException e) {
+      Log.e(TAG, "Unexpected exception while processing request " + e);
+    } finally {
+      try {
+        connection.shutdown();
+      } catch (IOException e) {
+        // Ignore.
+      }
+    }
+  }
+
+  private Uri.Builder getTestUrlPrefix() {
+    String authority = TEST_HOST + ":" + serverSocket.getLocalPort();
+    return new Uri.Builder().scheme("http").encodedAuthority(authority);
+  }
+
+  private static void setHttpStatusCode(
+      HttpRequest httpRequest, HttpResponse httpResponse, String eTag) {
+    Header[] headers = httpRequest.getAllHeaders();
+    // We use `If-None-Match` header and ETag to detect whether the file has been changed since the
+    // last sync. If the ETag from client matches the one at server, the file is not changed and
+    // HttpStatus.SC_NOT_MODIFIED is returned; otherwise, the file is changed and HttpStatus.SC_OK
+    // is returned.
+    for (Header header : headers) {
+      // Find the `If-None-Match` header.
+      if (!IF_NONE_MATCH_HEADER.equals(header.getName())) {
+        continue;
+      }
+      httpResponse.setStatusCode(
+          eTag.equals(header.getValue()) ? HttpStatus.SC_NOT_MODIFIED : HttpStatus.SC_OK);
+      return;
+    }
+    httpResponse.setStatusCode(HttpStatus.SC_OK);
+  }
+}
diff --git a/mobile-data-download.iml b/mobile-data-download.iml
new file mode 100644
index 0000000..afa4d1e
--- /dev/null
+++ b/mobile-data-download.iml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<module type="JAVA_MODULE" version="4">
+  <component name="NewModuleRootManager" inherit-compiler-output="true">
+    <exclude-output />
+    <content url="file://$MODULE_DIR$">
+      <sourceFolder url="file://$MODULE_DIR$/android-annotation-stubs" isTestSource="false" packagePrefix="__PACKAGE__" />
+      <sourceFolder url="file://$MODULE_DIR$/java" isTestSource="false" />
+      <sourceFolder url="file://$MODULE_DIR$/javatests" isTestSource="false" />
+    </content>
+    <orderEntry type="inheritedJdk" />
+    <orderEntry type="sourceFolder" forTests="false" />
+  </component>
+</module>
\ No newline at end of file
diff --git a/proto/Android.bp b/proto/Android.bp
new file mode 100644
index 0000000..5de4478
--- /dev/null
+++ b/proto/Android.bp
@@ -0,0 +1,36 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+java_library {
+    name: "mobile-data-download-java-proto-lite",
+    proto: {
+        type: "lite",
+        include_dirs: ["external/protobuf/src"],
+        canonical_path_from_root: false,
+        //local_include_dirs: ["proto/*"],
+    },
+    static_libs: [
+        "libprotobuf-java-lite",
+    ],
+    srcs: [
+        "**/*.proto",
+        ":libprotobuf-internal-protos"],
+    sdk_version: "current",
+    min_sdk_version: "30",
+    jarjar_rules: "jarjar-rules.txt",
+    apex_available: [
+        "//apex_available:platform",
+        "com.android.adservices",
+    ],
+}
\ No newline at end of file
diff --git a/proto/BUILD b/proto/BUILD
new file mode 100644
index 0000000..52b5e18
--- /dev/null
+++ b/proto/BUILD
@@ -0,0 +1,45 @@
+package(
+    default_visibility = ["//visibility:public"],
+    licenses = ["notice"],
+)
+
+proto_library(
+    name = "client_config_proto",
+    srcs = ["client_config.proto"],
+    cc_api_version = 2,
+    deps = [
+        "@com_google_protobuf//:any_proto",
+    ],
+)
+
+java_lite_proto_library(
+    name = "client_config_java_proto_lite",
+    deps = [":client_config_proto"],
+)
+
+proto_library(
+    name = "download_config_proto",
+    srcs = ["download_config.proto"],
+    cc_api_version = 2,
+    deps = [
+        ":transform_proto",
+        "@com_google_protobuf//:any_proto",
+    ],
+    alwayslink = 1,
+)
+
+java_lite_proto_library(
+    name = "download_config_java_proto_lite",
+    deps = [":download_config_proto"],
+)
+
+proto_library(
+    name = "transform_proto",
+    srcs = ["transform.proto"],
+    cc_api_version = 2,
+)
+
+java_lite_proto_library(
+    name = "transform_java_proto_lite",
+    deps = [":transform_proto"],
+)
diff --git a/proto/client_config.proto b/proto/client_config.proto
new file mode 100644
index 0000000..7ae1e98
--- /dev/null
+++ b/proto/client_config.proto
@@ -0,0 +1,136 @@
+// Copyright 2022 Google LLC
+//
+// 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.
+syntax = "proto2";
+
+package com.google.android.libraries.mdi.download;
+
+import "google/protobuf/any.proto";
+
+//option jspb_use_correct_proto2_semantics = false;  // <internal> TODO
+option java_package = "com.google.mobiledatadownload";
+option java_outer_classname = "ClientConfigProto";
+option objc_class_prefix = "ICN";
+
+// Next id: 15
+message ClientFileGroup {
+  // Unique name to identify the group, that the client wants to read.
+  optional string group_name = 1;
+
+  optional string owner_package = 3;
+
+  // The account associated to the file group.
+  optional string account = 6;
+
+  optional int32 version_number = 4;
+
+  enum Status {
+    UNSPECIFIED = 0;
+
+    // This group is downloaded and ready to use.
+    DOWNLOADED = 1;
+
+    // This group is pending download, and should be downloaded by calling
+    // the download API before it can be used.
+    //
+    // file.file_uri will not be set if the status is set to pending.
+    PENDING = 2;
+
+    // This group has finished downloading, but custom validation has
+    // not yet been performed. This state is only expected to be seen
+    // in the CustomFileGroupValidator.
+    PENDING_CUSTOM_VALIDATION = 3;
+  }
+
+  // Status of the client file group.
+  optional Status status = 5;
+
+  // List of files in the group.
+  repeated ClientFile file = 2;
+
+  // Unique identifier of a DataFileGroup config (i.e. a "snapshot") created
+  // when using MDD Ingress API.
+  //
+  // NOTE: This field name and description are not finalized yet! Reach out to
+  // <internal>@ to discuss using this field.
+  optional int64 build_id = 8;
+
+  // A fingerprint allowing clients to identify a DataFileGroup
+  // config based on a given set of properties (i.e. a "partition" of
+  // any file group properties). This can be used by clients as an exact match
+  // for a class of DataFileGroups during targeting or as a compatibility check.
+  //
+  // NOTE: This field name and description are not finalized yet! Reach out to
+  // <internal>@ to discuss using this field.
+  optional string variant_id = 12;
+
+  // The locales compatible with the file group. This can be different from the
+  // device locale.
+  //
+  // Values in this list may be exact locales (e.g. "en-US") or language-only
+  // ("en-*").
+  // Example 1: locale = ["en-US"]; // compatible with "en-US" only
+  // Example 2: locale = ["en-US", "en-CA"]; // compatible with "en-US" or
+  //                                         // "en-CA"
+  // Example 3: locale = ["en-*"]; // compatible with all "en" locales
+  repeated string locale = 10;
+
+  reserved 11;
+
+  // Custom metadata attached to the group.
+  //
+  // This allows clients to include specific metadata about the group for their
+  // own processing purposes. The metadata must be included when the group is
+  // added to MDD, then it will be available here when retrieving the group.
+  //
+  // This property should only be used if absolutely necessary. Please consult
+  // with <internal>@ if you have questions about this property or a potential
+  // use-case.
+  optional .google.protobuf.Any custom_metadata = 13;
+
+  reserved 14;
+
+  reserved 7, 9;
+}
+
+// Next id: 6
+message ClientFile {
+  // Unique name to identify the file within the group.
+  optional string file_id = 1;
+
+  // File Uri that can be opened using FileStorage library (<internal>).
+  optional string file_uri = 2;
+
+  // The full size of the file as specified in byte_size field of the config
+  // given to MDD. For files unzipped from zip file with zip download
+  // transforms, it will be the actual file size on disk.
+  optional int32 full_size_in_bytes = 3;
+
+  // The download size of the file as specified in downloaded_file_byte_size
+  // field (<internal>) of the
+  // config given to MDD. It could be used to track and calculate the download
+  // progress.
+  optional int32 download_size_in_bytes = 4;
+
+  // Custom metadata attached to the file
+  //
+  // This allows clients to include specific metadata about the file for their
+  // own processing purposes. The metadata must be included when the file's
+  // group is added to MDD, then it will be available here when retrieving the
+  // containing group.
+  //
+  // This property should only be used if absolutely necessary. Please consult
+  // with <internal>@ if you have questions about this property or a potential
+  // use-case.
+  optional .google.protobuf.Any custom_metadata = 5;
+}
diff --git a/proto/download_config.proto b/proto/download_config.proto
new file mode 100644
index 0000000..c4c7e34
--- /dev/null
+++ b/proto/download_config.proto
@@ -0,0 +1,555 @@
+// Copyright 2022 Google LLC
+//
+// 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.
+syntax = "proto2";
+
+package mdi.download;
+
+import "google/protobuf/any.proto";
+import "transform.proto";
+
+option java_package = "com.google.mobiledatadownload";
+option java_outer_classname = "DownloadConfigProto";
+option objc_class_prefix = "Icing";
+//option go_api_flag = "OPEN_TO_OPAQUE_HYBRID";  // See <internal>. TODO
+
+// The top-level proto for Mobile Data Download (<internal>).
+message DownloadConfig {
+  repeated DataFileGroup data_file_group = 1;
+
+  reserved 2;
+}
+
+// HTTP headers are described in https://tools.ietf.org/html/rfc7230#section-3.2
+// as key:value, where the value may have a whitespace on each end.
+message ExtraHttpHeader {
+  optional string key = 1;
+  optional string value = 2;
+}
+
+// A FileGroup is a set of files that should be atomically updated.
+// Next id: 29
+message DataFileGroup {
+  // Unique name to identify the group. It should be unique per owner package.
+  // In GMSCore, use the module name as the prefix of the group name.
+  //
+  // Ex: A group name in mdisync module could be named: mdisync-profile-photos.
+  //
+  // This shouldn't ideally be something like "config", and
+  // instead should better define the feature it will be used for.
+  //
+  // Ex: "icing-language-detection-model", "smart-action-detection-model"
+  //
+  // IMPORTANT: this group name will be logged to clearcut, and must never
+  // contain PII.
+  optional string group_name = 1;
+
+  // The name of the package that owns this group. If this field is left empty,
+  // the owner is assumed to be the package name of the host app.
+  //
+  // The files will only be downloaded onto the device if the owner package is
+  // present on the device.
+  //
+  // Ex: "com.google.android.gms", "com.google.android.apps.bugle"
+  optional string owner_package = 6;
+
+  // Client set version number used to identify the file group.
+  //
+  // Note that this does not uniquely identify the contents of the file group.
+  // It simply reflects a snapshot of client config changes.
+  // For example: say there's a file group 'language-detector-model' that
+  // downloads a different file per user locale.
+  // data_file_group {
+  //   file_group_name = 'language-detector-model'
+  //   file_group_version_number = 1
+  //   file {
+  //      url = 'en-model'
+  //   }
+  // }
+  // data_file_group {
+  //   file_group_name = 'language-detector-model'
+  //   file_group_version_number = 1
+  //   file {
+  //      url = 'es-model'
+  //   }
+  // }
+  // Note that even though the actual contents of the file group are different
+  // for each locale, the version is the same because this config was pushed
+  // at the same snapshot.
+  //
+  // Available GMS v18+.
+  optional int32 file_group_version_number = 10;
+
+  reserved 20;
+
+  // Custom metadata attached to the file group.
+  //
+  // This allows clients to include specific metadata about the group for their
+  // own processing purposes. The metadata will be stored with the group and
+  // accessible when the file group is retrieved.
+  //
+  // This property should only be used if absolutely necessary. Please consult
+  // with <internal>@ if you have questions about this property or a potential
+  // use-case.
+  //
+  // Available for aMDD Lib only.
+  optional google.protobuf.Any custom_metadata = 27;
+
+  reserved 22;
+
+  reserved 21;
+
+  enum AllowedReaders {
+    ALL_GOOGLE_APPS = 0;
+    ONLY_GOOGLE_PLAY_SERVICES = 1;
+    ALL_APPS = 2;
+  }
+
+  // Defines who is allowed to read this file group. Currently the options are:
+  //
+  // ALL_GOOGLE_APPS: accessible to all Google 1p Apps.
+  // ONLY_GOOGLE_PLAY_SERVICES: accessible to only GMS Core.
+  //
+  // If this field is not explicitly set it defaults to "ALL_GOOGLE_APPS".
+  //
+  // Available GMS v20+.
+  optional AllowedReaders allowed_readers_enum = 12;
+
+  // Length of time (in seconds) for which a file group version will live after
+  // a newer version became fully downloaded. Clients should set this time
+  // to be more than the time in which they call MDD to refresh their data.
+  // NOTE: MDD will delete the file group version within a day of this time.
+  // Ex: 172800  // 2 Days
+  optional int64 stale_lifetime_secs = 3;
+
+  // The timestamp at which this filegroup should be deleted, even if it is
+  // still active, specified in seconds since epoch.
+  // NOTE: MDD will delete the file group version within a day of this time.
+  optional int64 expiration_date = 11;
+
+  // Specify the conditions under which the file group should be downloaded.
+  optional DownloadConditions download_conditions = 13;
+
+  // Setting this flag to true will mean that the downloaded files will appear
+  // to be in a directory by themselves.
+  // The file name/file path of the exposed file will be the filename set in the
+  // file.relative_file_path field, OR if that field is empty, the file name
+  // from the file.url_to_download field. This enables downloaded files to refer
+  // to each other by name.
+  // It's invalid to set this flag to true if two files end up with the same
+  // file path.
+  // Valid on iOS, cMDD, and aMDD.
+  //
+  // NOTE: For aMDD, this feature is not available if Android Blob Sharing is
+  // enabled or if using an API level below 21 (L). If either case is true, this
+  // option will be ignored.
+  optional bool preserve_filenames_and_isolate_files = 14;
+
+  // List of files in the group.
+  repeated DataFile file = 2;
+
+  // Tag for the network traffic to download this file group.
+  // Tag space is determined by the host app.
+  // For Gmscore, the tag should come from:
+  // <internal>
+  optional int32 traffic_tag = 16;
+
+  // Extra HTTP headers to apply when downloading all files in the group.
+  repeated ExtraHttpHeader group_extra_http_headers = 17;
+
+  reserved 19;
+
+  // Unique identifier of a DataFileGroup config (i.e. a "snapshot") created
+  // when using MDD Ingress API.
+  optional int64 build_id = 23;
+
+  // A fingerprint allowing clients to identify a DataFileGroup
+  // config based on a given set of properties (i.e. a "partition" of
+  // any file group properties). This can be used by clients as an exact match
+  // for a class of DataFileGroups during targeting or as a compatibility check.
+  optional string variant_id = 26;
+
+  // The locales compatible with the file group. This can be different from the
+  // device locale.
+  //
+  // Values in this list may be exact locales (e.g. "en-US") or language-only
+  // ("en-*").
+  // Example 1: locale = ["en-US"]; // compatible with "en-US" only
+  // Example 2: locale = ["en-US", "en-CA"]; // compatible with "en-US" or
+  //                                         // "en-CA"
+  // Example 3: locale = ["en-*"]; // compatible with all "en" locales
+  repeated string locale = 25;
+
+  reserved 28;
+
+  reserved 4, 5, 7, 8, 9, 15, 18, 24, 248813966 /*aMDD extension*/,
+      248606552 /*cMDD extension*/;
+}
+
+// A data file represents all the metadata to download the file and then
+// manage it on the device.
+// Next tag: 22
+//
+// This should not contain any fields that are marked internal, as we compare
+// the protos directly to decide if it is a new version of the file.
+// LINT.IfChange(data_file)
+message DataFile {
+  // A unique identifier of the file within the group, that can be used to
+  // get this file from the group.
+  // Ex: "language-detection-model"
+  optional string file_id = 7;
+
+  // Url from where the file is to be downloaded.
+  // Ex: https://www.gstatic.com/group-name/model_1234.zip
+  optional string url_to_download = 2;
+
+  // Exact size of the file. This is used to check if there is space available
+  // for the file before scheduling the download.
+  // The byte_size is optional. If not set, MDD will not be able check the space
+  // available before schedulding the download.
+  optional int32 byte_size = 4;
+
+  // Enum for checksum types.
+  // NOTE: do not add any new checksum type here, older MDD versions would break
+  // otherwise.
+  enum ChecksumType {
+    // Default checksum is SHA1.
+    DEFAULT = 0;
+
+    // No checksum is provided.
+    // This is NOT currently supported by iMDD. Please contact <internal>@ if
+    // you need this feature.
+    NONE = 1;
+
+    // This is currently only supported by cMDD. If you need it for Android or
+    // iOS, please contact MDD team <internal>@.
+    SHA256 = 2;
+  }
+
+  optional ChecksumType checksum_type = 15;
+
+  // SHA1 checksum to verify the file before it can be used. This is also used
+  // to de-duplicate files between different groups.
+  // For most files, this will be the checksum of the file being downloaded.
+  // For files with download_transform, this should contain the transform of
+  // the file after the transforms have been applied.
+  // The checksum is optional. If not set, the checksum_type must be
+  // ChecksumType.NONE.
+  optional string checksum = 5;
+
+  // The following are <internal> transforms to apply to the downloaded files.
+  // Transforms are bi-directional and defined in terms of what they do on
+  // write. Since these transforms are applied while reading, their
+  // directionality is reversed. Eg, you'll see 'compress' to indicate that the
+  // file should be decompressed.
+
+  // These transforms are applied once by MDD after downloading the file.
+  // Currently only compress is available.
+  // Valid on Android. iOS support is tracked by b/118828045.
+  optional mobstore.proto.Transforms download_transforms = 11;
+
+  // If DataFile has download_transforms, this field must be provided with the
+  // SHA1 checksum of the file before any transform are applied. The original
+  // checksum would also be checked after the download_transforms are applied.
+  optional string downloaded_file_checksum = 14;
+
+  // Exact size of the downloaded file. If the DataFile has download transforms
+  // like compress and zip, the downloaded file size would be different than
+  // the final file size on disk. Client could use
+  // this field to track the downloaded file size and calculate the download
+  // progress percentage. This field is not used by MDD currently.
+  optional int32 downloaded_file_byte_size = 16;
+
+  // These transforms are evaluated by the caller on-the-fly when reading the
+  // data with MobStore. Any transforms installed in the caller's MobStore
+  // instance is available.
+  // Valid on Android and cMDD. iOS support is tracked by b/118759254.
+  optional mobstore.proto.Transforms read_transforms = 12;
+
+  // List of delta files that can be encoded and decoded with base files.
+  // If the device has any base file, the delta file which is much
+  // smaller will be downloaded instead of the full file.
+  // For most clients, only one delta file should be enough. If specifying
+  // multiple delta files, they should be in a sequence from the most recent
+  // base file to the oldest.
+  // This is currently only supported on Android.
+  repeated DeltaFile delta_file = 13;
+
+  enum AndroidSharingType {
+    // The dataFile isn't available for sharing.
+    UNSUPPORTED = 0;
+
+    // If sharing with the Android Blob Sharing Service isn't available, fall
+    // back to normal behavior, i.e. download locally.
+    ANDROID_BLOB_WHEN_AVAILABLE = 1;
+  }
+
+  // Defines whether the file should be shared and how.
+  // NOTE: currently this field is only used by aMDD and has no effect on iMDD.
+  optional AndroidSharingType android_sharing_type = 17;
+
+  // Enum for android sharing checksum types.
+  enum AndroidSharingChecksumType {
+    NOT_SET = 0;
+
+    // If the file group should be shared through the Android Blob Sharing
+    // Service, the checksum type must be set to SHA256.
+    SHA2_256 = 1;
+  }
+
+  optional AndroidSharingChecksumType android_sharing_checksum_type = 18;
+
+  // Checksum used to access files through the Android Blob Sharing Service.
+  optional string android_sharing_checksum = 19;
+
+  // Relative file path and file name to be preserved within the parent
+  // directory when creating symlinks for the file groups that have
+  // preserve_filenames_and_isolate_files set to true.
+  // This filename should NOT start or end with a '/', and it can not contain
+  // the substring '..'.
+  // Working example: "subDir/FileName.txt".
+  optional string relative_file_path = 20;
+
+  // Custom metadata attached to the file.
+  //
+  // This allows clients to include specific metadata about the file for their
+  // own processing purposes. The metadata will be stored with the file and
+  // accessible when the file's file group is retrieved.
+  //
+  // This property should only be used if absolutely necessary. Please consult
+  // with <internal>@ if you have questions about this property or a potential
+  // use-case.
+  //
+  // Available for aMDD Lib only.
+  optional google.protobuf.Any custom_metadata = 21;
+
+  reserved 1, 3, 6, 8, 9;
+}
+// LINT.ThenChange(
+//     <internal>,
+//     <internal>)
+
+// A delta file represents all the metadata to download for a diff file encoded
+// based on a base file
+// LINT.IfChange(delta_file)
+message DeltaFile {
+  // These fields all mirror the similarly-named fields in DataFile.
+  optional string url_to_download = 1;
+  optional int32 byte_size = 2;
+  optional string checksum = 3;
+
+  // Enum of all diff decoders supported
+  enum DiffDecoder {
+    // Default to have no diff decoder specified, will thrown unsupported
+    // exception
+    UNSPECIFIED = 0;
+
+    // VcDIFF decoder
+    // Generic Differencing and Compression Data Format
+    // For more information, please refer to rfc3284
+    // The VcDiff decoder for GMS service:
+    // <internal>
+    VC_DIFF = 1;
+  }
+  // The diff decoder used to generate full file with delta and base file.
+  // For MDD as a GMS service, a VcDiff decoder will be registered and injected
+  // in by default. Using MDD as a library, clients need to register and inject
+  // in a VcDiff decoder, otherwise, an exception will be thrown.
+  optional DiffDecoder diff_decoder = 5;
+
+  // The base file represents to a full file on device. It should contain the
+  // bare minimum fields of a DataFile to identify a DataFile on device.
+  optional BaseFile base_file = 6;
+
+  reserved 4;
+}
+// LINT.ThenChange(
+//     <internal>,
+//     <internal>)
+
+message BaseFile {
+  // SHA1 checksum of the base file to identify a file on device. It should
+  // match the checksum field of the base file used to generate the delta file.
+  optional string checksum = 1;
+}
+
+// LINT.IfChange
+// Next id: 5
+message DownloadConditions {
+  // TODO(b/143548753): The first value in an enum must have a specific prefix.
+  enum DeviceStoragePolicy {
+    // MDD will block download of files in android low storage. Currently MDD
+    // doesn't delete the files in case the device reaches low storage
+    // after the file has been downloaded.
+    BLOCK_DOWNLOAD_IN_LOW_STORAGE = 0;
+
+    // Block download of files only under a lower threshold defined here
+    // <internal>
+    BLOCK_DOWNLOAD_LOWER_THRESHOLD = 1;
+
+    // Set the storage threshold to an extremely low value when downloading.
+    // IMPORTANT: if the download make the device runs out of disk, this could
+    // render the device unusable.
+    // This should only be used for critical use cases such as privacy
+    // violations. Emergency fix should not belong to this category. Please
+    // talks to <internal>@ when you want to use this option.
+    EXTREMELY_LOW_THRESHOLD = 2;
+  }
+
+  // Specify the device storage under which the files should be downloaded.
+  // By default, the files will only be downloaded if the device is not in
+  // low storage.
+  optional DeviceStoragePolicy device_storage_policy = 1;
+
+  // TODO(b/143548753): The first value in an enum must have a specific prefix.
+  enum DeviceNetworkPolicy {
+    // Only download files on wifi.
+    DOWNLOAD_ONLY_ON_WIFI = 0;
+
+    // Allow download on any network including wifi and cellular.
+    DOWNLOAD_ON_ANY_NETWORK = 1;
+
+    // Allow downloading only on wifi first, then after a configurable time
+    // period set in the field download_first_on_wifi_period_secs below,
+    // allow downloading on any network including wifi and cellular.
+    DOWNLOAD_FIRST_ON_WIFI_THEN_ON_ANY_NETWORK = 2;
+  }
+
+  // Specify the device network under which the files should be downloaded.
+  // By default, the files will only be downloaded on wifi.
+  //
+  // If your feature targets below v20 and want to download on cellular in
+  // these versions of gms, also set allow_download_without_wifi = true;
+  optional DeviceNetworkPolicy device_network_policy = 2;
+
+  // This field will only be used when the
+  // DeviceNetworkPolicy = DOWNLOAD_FIRST_ON_WIFI_THEN_ON_ANY_NETWORK
+  // MDD will download the file only on wifi for this period of time. If the
+  // download was not finished, MDD will download on any network including
+  // wifi and cellular.
+  // Ex: 604800  // 7 Days
+  optional int64 download_first_on_wifi_period_secs = 4;
+
+  // TODO(b/143548753): The first value in an enum must have a specific prefix.
+  enum ActivatingCondition {
+    // The download is activated as soon the server side config is received and
+    // the server configured download conditions are satisfied.
+    ALWAYS_ACTIVATED = 0;
+
+    // The download is activated when both server side activation conditions
+    // are satisfied and the client has activated the download on device.
+    //
+    // Clients can activate this group using the activateFileGroup API.
+    // <internal>
+    DEVICE_ACTIVATED = 1;
+  }
+
+  // Specify how the download is activated. By default, the download is
+  // activated as soon as server configured activating conditions are satisfied.
+  optional ActivatingCondition activating_condition = 3;
+}
+// LINT.ThenChange(
+// <internal>,
+// <internal>)
+
+message PhConfig {
+  repeated PhClient ph_client = 1;
+}
+
+// Config for a client that wants to download their data file group using
+// a phenotype flag. It contains the phenotype flag name where the client
+// config is present.
+// This is used by clients that want to download data files conditionally. Its
+// current usage is to download webref slices.
+message PhClient {
+  // The phenotype flag name where the config is present.
+  optional string ph_flag_name = 1;
+}
+
+// ManifestConfig to support on device targeting.
+// The ManifestConfig could be in a payload of a PH flag or it could be in the
+// content of a Manifest file. See <internal> for more
+// details.
+// Each ManifestConfig.Entry will have a Modifier and a corresponding
+// DataFileGroup. The Modifier will be used for on device filtering/targeting.
+message ManifestConfig {
+  message Entry {
+    // All the modifier variables are used for filtering/targeting on the device
+    // side. For example, we can specify the locale "en_US" and does the
+    // targeting on the device based on this locale. If you need to add more
+    // fields to Modifier, please email <internal>@.
+    message Modifier {
+      // Locales for which this DataFileGroup is valid.
+      // Locales defined here are application's specific.
+      // It will be consumed by the application's
+      // ManifestConfigFlagPopulator.Overrider to do on-device targeting. The
+      // Overrider will interprete the locales to select best locale matches.
+      // For example, it can invoke the LanguageMatcher [1] to support
+      // "Local Inheritance" [2].
+      // [1]
+      // <internal>
+      // [2] <internal>
+      repeated string locale = 1;
+
+      // Custom Properties.
+      // Defined by each application. The application needs to provide a
+      // ManifestConfigOverrider
+      // (<internal>
+      // that understands and filters entries based on this Custom Properties.
+      optional google.protobuf.Any custom_properties = 2;
+
+      message Location {
+        // S2CellId (<internal>) associated with this DataFileGroup. It will be
+        // used to do location based targeting on device, optionally filtering
+        // extraneous slices if the user has location permissions enabled.
+        // Otherwise location targeting will be based on a rough estimate from
+        // IP-based geolocation on the server. The type fixed64 is a bit more
+        // efficient than int64 for our purposes. This is because int64 uses
+        // prefix encoding, however, for the S2CellIds the high-order bits
+        // encode the face-ID and as a result we often end up with large
+        // numbers.
+        optional fixed64 s2_cell_id = 1;
+      }
+
+      optional Location location = 3;
+    }
+
+    optional Modifier modifier = 1;
+    optional DataFileGroup data_file_group = 2;
+  }
+
+  message UrlTemplate {
+    // Template to construct a {@code DataFile}'s url_to_download on device.
+    // If the url template should be used, the url_to_download field should be
+    // left unpopulated. If the url template and the url_to_download are both
+    // populated, the template will be ignored.
+    optional string file_url_template = 1;
+  }
+
+  repeated Entry entry = 1;
+
+  // Template definition for constructing URLs on device. It applies to every
+  // DataFile defined in the ManifestConfig.
+  optional UrlTemplate url_template = 2;
+}
+
+// The flag that MDD gets from P/H, and contains information about the manifest
+// file to be downloaded.
+// Next id: 3
+message ManifestFileFlag {
+  // The ID for the manifest file. This should be unique in the host app space.
+  optional string manifest_id = 1;
+
+  // The url to the manifest file on Google hosting service.
+  optional string manifest_file_url = 2;
+}
diff --git a/proto/jarjar-rules.txt b/proto/jarjar-rules.txt
new file mode 100644
index 0000000..035f3f8
--- /dev/null
+++ b/proto/jarjar-rules.txt
@@ -0,0 +1,3 @@
+
+# Use our statically linked protobuf library
+# rule com.google.protobuf.** com.android.adservices.protobuf.@1
diff --git a/proto/metadata.proto b/proto/metadata.proto
new file mode 100644
index 0000000..4b815d2
--- /dev/null
+++ b/proto/metadata.proto
@@ -0,0 +1,673 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+// The main purpose of mirroring external protos here is to separate API and
+// storage protos. The same tag numbers are used for making migration work.
+
+syntax = "proto2";
+
+package mdi.download.internal;
+
+import "google/protobuf/any.proto";
+import "google/protobuf/timestamp.proto";
+import "transform.proto";
+
+option java_package = "com.google.mobiledatadownload.internal";
+option java_outer_classname = "MetadataProto";
+
+// Mirrors mdi.download.ExtraHttpHeader
+//
+// HTTP headers are described in https://tools.ietf.org/html/rfc7230#section-3.2
+// as key:value, where the value may have a whitespace on each end.
+message ExtraHttpHeader {
+  optional string key = 1;
+  optional string value = 2;
+}
+
+// This proto is used to store file group metadata on disk for internal use. It
+// consists of all fields mirrored from DataFileGroup and an extra field for
+// bookkeeping.
+//
+// The tag number of extra fields should start from 1000 to reserve room for
+// growing DataFileGroup.
+//
+// Next id: 1000
+message DataFileGroupInternal {
+  // Extra information that is kept on disk.
+  //
+  // The extension was originally introduced in cl/248813966. We are migrating
+  // away from the extension. However, we still need to read from fields in the
+  // extension. Reuse the same tag number as the extension number to read from
+  // the extension.
+  optional DataFileGroupBookkeeping bookkeeping = 248813966;
+
+  // Unique name to identify the group. It should be unique per owner package.
+  // In GMSCore, use the module name as the prefix of the group name.
+  //
+  // Ex: A group name in mdisync module could be named: mdisync-profile-photos.
+  //
+  // This shouldn't ideally be something like "config", and
+  // instead should better define the feature it will be used for.
+  //
+  // Ex: "icing-language-detection-model", "smart-action-detection-model"
+  optional string group_name = 1;
+
+  // The name of the package that owns this group. If this field is left empty,
+  // the owner is assumed to be the package name of the host app.
+  //
+  // The files will only be downloaded onto the device if the owner package is
+  // present on the device.
+  //
+  // Ex: "com.google.android.gms", "com.google.android.apps.bugle"
+  optional string owner_package = 6;
+
+  // Client set version number used to identify the file group.
+  //
+  // Note that this does not uniquely identify the contents of the file group.
+  // It simply reflects a snapshot of client config changes.
+  // For example: say there's a file group 'language-detector-model' that
+  // downloads a different file per user locale.
+  // data_file_group {
+  //   file_group_name = 'language-detector-model'
+  //   file_group_version_number = 1
+  //   file {
+  //      url = 'en-model'
+  //   }
+  // }
+  // data_file_group {
+  //   file_group_name = 'language-detector-model'
+  //   file_group_version_number = 1
+  //   file {
+  //      url = 'es-model'
+  //   }
+  // }
+  // Note that even though the actual contents of the file group are different
+  // for each locale, the version is the same because this config was pushed
+  // at the same snapshot.
+  //
+  // Available GMS v18+.
+  optional int32 file_group_version_number = 10;
+
+  // DEPRECATED
+  // MDD team recommends to use explicit properties instead.
+  optional google.protobuf.Any custom_property = 20 [deprecated = true];
+
+  // Custom metadata attached to the file group.
+  //
+  // This allows clients to include specific metadata about the group for their
+  // own processing purposes. The metadata will be stored with the group and
+  // accessible when the file group is retrieved.
+  //
+  // This property should only be used if absolutely necessary. Please consult
+  // with <internal>@ if you have questions about this property or a potential
+  // use-case.
+  optional google.protobuf.Any custom_metadata = 27;
+
+  reserved 21;
+
+  // Mirrors mdi.download.DataFileGroup.AllowedReaders
+  enum AllowedReaders {
+    ALL_GOOGLE_APPS = 0;
+    ONLY_GOOGLE_PLAY_SERVICES = 1;
+    ALL_APPS = 2;
+  }
+
+  // Defines who is allowed to read this file group. Currently the options are:
+  //
+  // ALL_GOOGLE_APPS: accessible to all Google 1p Apps.
+  // ONLY_GOOGLE_PLAY_SERVICES: accessible to only GMS Core.
+  //
+  // If this field is not explicitly set it defaults to "ALL_GOOGLE_APPS".
+  //
+  // Available GMS v20+.
+  optional AllowedReaders allowed_readers_enum = 12;
+
+  // Length of time (in seconds) for which a file group version will live after
+  // since a newer version became fully downloaded. Clients should set this time
+  // to be more than the time in which they call MDD to refresh their data.
+  // NOTE: MDD will delete the file group version within a day of this time.
+  // Ex: 172800  // 2 Days
+  optional int64 stale_lifetime_secs = 3;
+
+  // The timestamp at which this filegroup should be deleted, even if it is
+  // still active, specified in seconds since epoch.
+  // NOTE: MDD will delete the file group version within a day of this time.
+  optional int64 expiration_date_secs = 11;
+
+  // Specify the conditions under which the file group should be downloaded.
+  optional DownloadConditions download_conditions = 13;
+
+  // Setting this flag to true will mean that the downloaded files will appear
+  // to be in a directory by themselves.
+  // The file name/file path of the exposed file will be the filename set in the
+  // file.relative_file_path field, OR if that field is empty, the file name
+  // from the file.url_to_download field. This enables downloaded files to refer
+  // to each other by name.
+  // It's invalid to set this flag to true if two files end up with the same
+  // file path.
+  // Valid on iOS, cMDD, and aMDD.
+  //
+  // NOTE: For aMDD, this feature is not available if Android Blob Sharing is
+  // enabled or if using an API level below 21 (L). If either case is true, this
+  // option will be ignored.
+  optional bool preserve_filenames_and_isolate_files = 14;
+
+  // List of files in the group.
+  repeated DataFile file = 2;
+
+  // Tag for the network traffic to download this file group.
+  // Tag space is determined by the host app.
+  // For Gmscore, the tag should come from:
+  // <internal>
+  optional int32 traffic_tag = 16;
+
+  // Extra HTTP headers to apply when downloading all files in the group.
+  repeated ExtraHttpHeader group_extra_http_headers = 17;
+
+  reserved 19;
+
+  // Unique identifier of a DataFileGroup config (i.e. a "snapshot") created
+  // when using MDD Ingress API.
+  optional int64 build_id = 23;
+
+  // A fingerprint allowing clients to identify a DataFileGroup
+  // config based on a given set of properties (i.e. a "partition" of
+  // any file group properties). This can be used by clients as an exact match
+  // for a class of DataFileGroups during targeting or as a compatibility check.
+  optional string variant_id = 26;
+
+  // The locales compatible with the file group. This can be different from the
+  // device locale.
+  //
+  // Values in this list may be exact locales (e.g. "en-US") or language-only
+  // ("en-*").
+  // Example 1: locale = ["en-US"]; // compatible with "en-US" only
+  // Example 2: locale = ["en-US", "en-CA"]; // compatible with "en-US" or
+  //                                         // "en-CA"
+  // Example 3: locale = ["en-*"]; // compatible with all "en" locales
+  repeated string locale = 25;
+
+  reserved 28;
+
+  reserved 4, 5, 7, 8, 9, 15, 18, 22, 24;
+}
+
+// Mirrors mdi.download.DataFile
+//
+// A data file represents all the metadata to download the file and then
+// manage it on the device.
+// Next tag: 22
+//
+// This should not contain any fields that are marked internal, as we compare
+// the protos directly to decide if it is a new version of the file.
+// LINT.IfChange(data_file)
+message DataFile {
+  // A unique identifier of the file within the group, that can be used to
+  // get this file from the group.
+  // Ex: "language-detection-model"
+  optional string file_id = 7;
+
+  // Url from where the file is to be downloaded.
+  // Ex: https://www.gstatic.com/group-name/model_1234.zip
+  optional string url_to_download = 2;
+
+  // Exact size of the file. This is used to check if there is space available
+  // for the file before scheduling the download.
+  // The byte_size is optional. If not set, MDD will try with best effort to get
+  // the file size using the HTTP HEAD request.
+  optional int32 byte_size = 4;
+
+  // Enum for checksum types.
+  // NOTE: do not add any new checksum type here, older MDD versions would break
+  // otherwise.
+  enum ChecksumType {
+    // Default checksum is SHA1.
+    DEFAULT = 0;
+
+    // No checksum is provided.
+    NONE = 1;
+
+    reserved 2 /* SHA256 */;
+  }
+
+  optional ChecksumType checksum_type = 15;
+
+  // SHA1 checksum to verify the file before it can be used. This is also used
+  // to de-duplicate files between different groups.
+  // For most files, this will be the checksum of the file being downloaded.
+  // For files with download_transform, this should contain the transform of
+  // the file after the transforms have been applied.
+  // The checksum is optional. If not set, the checksum_type must be
+  // ChecksumType.NONE.
+  optional string checksum = 5;
+
+  // The following are <internal> transforms to apply to the downloaded files.
+  // Transforms are bi-directional and defined in terms of what they do on
+  // write. Since these transforms are applied while reading, their
+  // directionality is reversed. Eg, you'll see 'compress' to indicate that the
+  // file should be decompressed.
+
+  // These transforms are applied once by MDD after downloading the file.
+  // Currently only compress is available.
+  // Valid on Android. iOS support is tracked by b/118828045.
+  optional mobstore.proto.Transforms download_transforms = 11;
+
+  // If DataFile has download_transforms, this field must be provided with the
+  // SHA1 checksum of the file before any transform are applied. The original
+  // checksum would also be checked after the download_transforms are applied.
+  optional string downloaded_file_checksum = 14;
+
+  // Exact size of the downloaded file. If the DataFile has download transforms
+  // like compress and zip, the downloaded file size would be different than
+  // the final file size on disk. Client could use
+  // this field to track the downloaded file size and calculate the download
+  // progress percentage. This field is not used by MDD currently.
+  optional int32 downloaded_file_byte_size = 16;
+
+  // These transforms are evaluated by the caller on-the-fly when reading the
+  // data with MobStore. Any transforms installed in the caller's MobStore
+  // instance is available.
+  // Valid on Android. iOS support is tracked by b/118759254.
+  optional mobstore.proto.Transforms read_transforms = 12;
+
+  // List of delta files that can be encoded and decoded with base files.
+  // If the device has any base file, the delta file which is much
+  // smaller will be downloaded instead of the full file.
+  // For most clients, only one delta file should be enough. If specifying
+  // multiple delta files, they should be in a sequence from the most recent
+  // base file to the oldest.
+  // This is currently only supported on Android.
+  repeated DeltaFile delta_file = 13;
+
+  enum AndroidSharingType {
+    // The dataFile isn't available for sharing.
+    UNSUPPORTED = 0;
+
+    // If sharing with the Android Blob Sharing Service isn't available, fall
+    // back to normal behavior, i.e. download locally.
+    ANDROID_BLOB_WHEN_AVAILABLE = 1;
+  }
+
+  // Defines whether the file should be shared and how.
+  // NOTE: currently this field is only used by aMDD and has no effect on iMDD.
+  optional AndroidSharingType android_sharing_type = 17;
+
+  // Enum for android sharing checksum types.
+  enum AndroidSharingChecksumType {
+    NOT_SET = 0;
+
+    // If the file group should be shared through the Android Blob Sharing
+    // Service, the checksum type must be set to SHA256.
+    SHA2_256 = 1;
+  }
+
+  optional AndroidSharingChecksumType android_sharing_checksum_type = 18;
+
+  // Checksum used to access files through the Android Blob Sharing Service.
+  optional string android_sharing_checksum = 19;
+
+  // Relative file path and file name to be preserved within the parent
+  // directory when creating symlinks for the file groups that have
+  // preserve_filenames_and_isolate_files set to true.
+  // This filename should NOT start or end with a '/', and it can not contain
+  // the substring '..'.
+  // Working example: "subDir/FileName.txt".
+  optional string relative_file_path = 20;
+
+  // Custom metadata attached to the file.
+  //
+  // This allows clients to include specific metadata about the file for their
+  // own processing purposes. The metadata will be stored with the file and
+  // accessible when the file's file group is retrieved.
+  //
+  // This property should only be used if absolutely necessary. Please consult
+  // with <internal>@ if you have questions about this property or a potential
+  // use-case.
+  optional google.protobuf.Any custom_metadata = 21;
+
+  reserved 1, 3, 6, 8, 9;
+}
+// LINT.ThenChange(
+//     <internal>,
+//     <internal>)
+
+// Mirrors mdi.download.DeltaFile
+//
+// A delta file represents all the metadata to download for a diff file encoded
+// based on a base file
+// LINT.IfChange(delta_file)
+message DeltaFile {
+  // These fields all mirror the similarly-named fields in DataFile.
+  optional string url_to_download = 1;
+  optional int32 byte_size = 2;
+  optional string checksum = 3;
+
+  // Enum of all diff decoders supported
+  enum DiffDecoder {
+    // Default to have no diff decoder specified, will thrown unsupported
+    // exception
+    UNSPECIFIED = 0;
+
+    // VcDIFF decoder
+    // Generic Differencing and Compression Data Format
+    // For more information, please refer to rfc3284
+    // The VcDiff decoder for GMS service:
+    // <internal>
+    VC_DIFF = 1;
+  }
+  // The diff decoder used to generate full file with delta and base file.
+  // For MDD as a GMS service, a VcDiff decoder will be registered and injected
+  // in by default. Using MDD as a library, clients need to register and inject
+  // in a VcDiff decoder, otherwise, an exception will be thrown.
+  optional DiffDecoder diff_decoder = 5;
+
+  // The base file represents to a full file on device. It should contain the
+  // bare minimum fields of a DataFile to identify a DataFile on device.
+  optional BaseFile base_file = 6;
+
+  reserved 4;
+}
+// LINT.ThenChange(
+//     <internal>,
+//     <internal>)
+
+// Mirrors mdi.download.BaseFile
+message BaseFile {
+  // SHA1 checksum of the base file to identify a file on device. It should
+  // match the checksum field of the base file used to generate the delta file.
+  optional string checksum = 1;
+}
+
+// Mirrors mdi.download.DownloadConditions
+//
+// Next id: 5
+message DownloadConditions {
+  // TODO(b/143548753): The first value in an enum must have a specific prefix.
+  enum DeviceStoragePolicy {
+    // MDD will block download of files in android low storage. Currently MDD
+    // doesn't delete the files in case the device reaches low storage
+    // after the file has been downloaded.
+    BLOCK_DOWNLOAD_IN_LOW_STORAGE = 0;
+
+    // Block download of files only under a lower threshold defined here
+    // <internal>
+    BLOCK_DOWNLOAD_LOWER_THRESHOLD = 1;
+
+    // Set the storage threshold to an extremely low value when downloading.
+    // IMPORTANT: if the download make the device runs out of disk, this could
+    // render the device unusable.
+    // This should only be used for critical use cases such as privacy
+    // violations. Emergency fix should not belong to this category. Please
+    // talks to <internal>@ when you want to use this option.
+    EXTREMELY_LOW_THRESHOLD = 2;
+  }
+
+  // Specify the device storage under which the files should be downloaded.
+  // By default, the files will only be downloaded if the device is not in
+  // low storage.
+  optional DeviceStoragePolicy device_storage_policy = 1;
+
+  // TODO(b/143548753): The first value in an enum must have a specific prefix.
+  enum DeviceNetworkPolicy {
+    // Only download files on wifi.
+    DOWNLOAD_ONLY_ON_WIFI = 0;
+
+    // Allow download on any network including wifi and cellular.
+    DOWNLOAD_ON_ANY_NETWORK = 1;
+
+    // Allow downloading only on wifi first, then after a configurable time
+    // period set in the field download_first_on_wifi_period_secs below,
+    // allow downloading on any network including wifi and cellular.
+    DOWNLOAD_FIRST_ON_WIFI_THEN_ON_ANY_NETWORK = 2;
+  }
+
+  // Specify the device network under which the files should be downloaded.
+  // By default, the files will only be downloaded on wifi.
+  //
+  // If your feature targets below v20 and want to download on cellular in
+  // these versions of gms, also set allow_dowload_without_wifi = true;
+  optional DeviceNetworkPolicy device_network_policy = 2;
+
+  // This field will only be used when the
+  // DeviceNetworkPolicy = DOWNLOAD_FIRST_ON_WIFI_THEN_ON_ANY_NETWORK
+  // MDD will download the file only on wifi for this period of time. If the
+  // download was not finished, MDD will download on any network including
+  // wifi and cellular.
+  // Ex: 604800  // 7 Days
+  optional int64 download_first_on_wifi_period_secs = 4;
+
+  // TODO(b/143548753): The first value in an enum must have a specific prefix.
+  enum ActivatingCondition {
+    // The download is activated as soon the server side config is received and
+    // the server configured download conditions are satisfied.
+    ALWAYS_ACTIVATED = 0;
+
+    // The download is activated when both server side activation conditions
+    // are satisfied and the client has activated the download on device.
+    //
+    // Clients can activate this group using the activateFileGroup API.
+    // <internal>
+    DEVICE_ACTIVATED = 1;
+  }
+
+  // Specify how the download is activated. By default, the download is
+  // activated as soon as server configured activating conditions are satisfied.
+  optional ActivatingCondition activating_condition = 3;
+}
+
+// This proto contains extra information about a file group for bookkeeping.
+// Next tag: 6
+message DataFileGroupBookkeeping {
+  // The epoch time (seconds since 1/1/1970) at which this stale file group will
+  // be deleted.
+  optional int64 stale_expiration_date = 1;
+
+  // The timestamp (epoch time, milliseconds since 1/1/1970) that the file group
+  // was first received.
+  //
+  // If this is an update on an existing group, then the timestamp from the old
+  // group is used if no files were updated.
+  optional int64 group_new_files_received_timestamp = 2;
+
+  // The timestamp (epoch time, milliseconds since 1/1/1970) at which the group
+  // was first marked as downloaded.
+  optional int64 group_downloaded_timestamp_in_millis = 3;
+
+  // The timestamp (epoch time, milliseconds since 1/1/1970) that MDD starts
+  // downloading the file group for the first time.
+  optional int64 group_download_started_timestamp_in_millis = 4;
+
+  // The total count of download periodic tasks needed to fully download the
+  // file group.
+  optional int32 download_started_count = 5;
+}
+
+// Key used by mdd to uniquely identify a client group.
+message GroupKey {
+  // The name of the group.
+  optional string group_name = 1;
+
+  // The package name of the group owner. A null value or empty value means
+  // that the group is not associated with any package.
+  optional string owner_package = 2;
+
+  // The account associated to the file group.
+  optional string account = 5;
+
+  // Whether or not all files in a fileGroup have been downloaded.
+  optional bool downloaded = 4;
+
+  // The variant id of the group. A null or empty value indicates that the group
+  // does not have an associated variant.
+  optional string variant_id = 6;
+
+  reserved 3;
+}
+
+// Group Key properties that apply to all groups with that key.
+message GroupKeyProperties {
+  // Whether this group key has been activated on the device.
+  optional bool activated_on_device = 1;
+}
+
+// SharedFile is a internal data structure of the SharedFileManager.
+message SharedFile {
+  optional string file_name = 4;
+  optional FileStatus file_status = 5;
+
+  // This field will be used to determine if a file can be retrieved from the
+  // Android Blob Sharing Service.
+  optional bool android_shared = 8;
+
+  // The maximum expiration date found for a downloaded data file. If
+  // {@code android_shared} is set to true, this field stores the current lease
+  // expiration date. The default value is 0.
+  // See <internal> for more details.
+  optional int64 max_expiration_date_secs = 9;
+
+  // Checksum used to access files through the Android Blob Sharing Service.
+  optional string android_sharing_checksum = 10;
+
+  // If the file is downloaded successfully but fails checksum matching, we will
+  // attempt to delete the file so it can be redownloaded from scratch. To
+  // prevent unnecessary network bandwidth, we keep track of the number of these
+  // attempts in this field and stop after a certain number. (configurable by a
+  // download flag).
+  optional int32 checksum_mismatch_retry_download_count = 11;
+
+  reserved 1, 2, 3, 6, 7;
+}
+
+// Metadata used by
+// com.google.android.libraries.mdi.download.MobileDataDownloadManager
+message MobileDataDownloadManagerMetadata {
+  optional bool mdd_migrated_to_offroad = 1;
+  optional int32 reset_trigger = 2;
+}
+
+// Metadata used by
+// com.google.android.libraries.mdi.download.SharedFileManager
+message SharedFileManagerMetadata {
+  optional bool migrated_to_new_file_key = 1;
+  optional int64 next_file_name = 2;
+}
+
+// Collects all data used by
+// com.google.android.libraries.mdi.download.internal.Migrations
+message MigrationsStore {
+  enum FileKeyVersion {
+    NEW_FILE_KEY = 0;
+    ADD_DOWNLOAD_TRANSFORM = 1;
+    USE_CHECKSUM_ONLY = 2;
+  }
+  optional bool is_migrated_to_new_file_key = 1;
+  optional FileKeyVersion current_version = 2;
+}
+
+// Collects all data used by
+// com.google.android.libraries.mdi.download.internal.FileGroupsMetadata
+message FileGroupsMetadataStore {
+  // Key must be a serialized GroupKey.
+  map<string, DataFileGroupInternal> data_file_groups = 1;
+  // Key must be a serialized GroupKey.
+  map<string, GroupKeyProperties> group_key_properties = 2;
+  repeated DataFileGroupInternal stale_groups = 3;
+}
+
+// Collects all data used by
+// com.google.android.libraries.mdi.download.internal.SharedFilesMetadata
+message SharedFilesMetadataStore {
+  // The key must be a serialized NewFileKey.
+  map<string, SharedFile> shared_files = 1;
+}
+
+enum FileStatus {
+  // The file has never been seen before.
+  NONE = 0;
+  // The file has been subscribed to, but download has not been attempted.
+  SUBSCRIBED = 1;
+  // The file download is currently in progress.
+  DOWNLOAD_IN_PROGRESS = 2;
+  // Downloading the file failed.
+  DOWNLOAD_FAILED = 3;
+  // The file was downloaded completely, and is available for use.
+  DOWNLOAD_COMPLETE = 4;
+  // The file was corrupted or lost after being successfully downloaded.
+  CORRUPTED = 6;
+  // Status returned when their is an error while getting the file status.
+  // This is never saved on disk.
+  INTERNAL_ERROR = 5;
+}
+
+// Key used by the SharedFileManager to identify a shared file.
+message NewFileKey {
+  // These fields all mirror the similarly-named fields in DataFile.
+  optional string url_to_download = 1 [deprecated = true];
+  optional int32 byte_size = 2 [deprecated = true];
+  optional string checksum = 3;
+  optional DataFileGroupInternal.AllowedReaders allowed_readers = 4;
+  optional mobstore.proto.Transforms download_transforms = 5
+      [deprecated = true];
+}
+
+// This proto is used to store state for logging. See details at
+// <internal>
+message LoggingState {
+  // The last time maintenance was run. This should be updated every time
+  // maintenance is run.
+  // Note: the current implementation only uses this to determine the date of
+  // the last log event, but in the future we may want more granular
+  // measurements for this, so we store the timestamp as-is.
+  optional google.protobuf.Timestamp last_maintenance_run_timestamp = 1;
+
+  // File group specific logging state keyed by GroupKey, build id and version
+  // number.
+  repeated FileGroupLoggingState file_group_logging_state = 2;
+
+  // Set to true once the shared preferences migration is complete.
+  // Note: this field isn't strictly necessary at the moment - we could just
+  // check that the file_group_logging_state is empty since no one should write
+  // to the network usage monitor shared prefs since the migration will be
+  // installed at the same cl where the code is removed. However, if we were to
+  // add more fields to FileGroupLoggingState, it would be less straightforward
+  // to check for migration state - so having this boolean makes it a bit safer.
+  optional bool shared_preferences_network_usage_monitor_migration_complete = 3;
+
+  // Info to enable stable sampling. See <internal> for more
+  // info. This field will be set by a migration on first access.
+  optional SamplingInfo sampling_info = 4;
+}
+
+// This proto is used to store state for logging that is specific to a File
+// Group. This includes network usage logging and maybe download tiers (for
+// <internal>).
+message FileGroupLoggingState {
+  optional GroupKey group_key = 1;
+  optional int64 build_id = 2;
+  optional int32 file_group_version_number = 3;
+  optional int64 cellular_usage = 4;
+  optional int64 wifi_usage = 5;
+}
+
+// Next id: 3
+message SamplingInfo {
+  // Random number generated and persisted on device. This number should not
+  // change (unless device storage/mdd is cleared). It is used as a stable
+  // identifier to determine whether MDD should log events.
+  optional int64 stable_log_sampling_salt = 1;
+
+  // When the stable_log_sampling_salt was first set. This will be used during
+  // roll out to determine which devices have enabled stable sampling for a
+  // sufficient time period.
+  optional google.protobuf.Timestamp log_sampling_salt_set_timestamp = 2;
+}
diff --git a/proto/transform.proto b/proto/transform.proto
new file mode 100644
index 0000000..a8b2e85
--- /dev/null
+++ b/proto/transform.proto
@@ -0,0 +1,88 @@
+// Copyright 2022 Google LLC
+//
+// 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.
+syntax = "proto2";
+
+package mobstore.proto;
+
+option java_package = "com.google.mobiledatadownload";
+option java_outer_classname = "TransformProto";
+option objc_class_prefix = "MOB";
+
+// Description of transforms that are to be applied by MobStore to a stream.
+//
+// Following MobStore convention, they are applied in the order in which they
+// appear on write, and reverse on read. Serialization as a URI fragment
+// preserves order.
+//
+// eg "transform=compress+encrypt(aes_gcm_key=12345)"
+message Transforms {
+  repeated Transform transform = 1;
+}
+
+// Specification for an individual transform.
+message Transform {
+  oneof transform {
+    CompressTransform compress = 1;
+    EncryptTransform encrypt = 2;
+    IntegrityTransform integrity = 3;
+    ZipTransform zip = 4;
+    CustomTransform custom = 5;
+  }
+}
+
+// The compression transform. It has no parameters.
+//
+// eg "compress"
+message CompressTransform {}
+
+// The encryption transform. If no params are given, it uses the keystore
+// to manage keys. Alternatively, the key can be stored in the URI itself.
+//
+// eg "encrypt", "encrypt(aes_gcm_key=12345)"
+message EncryptTransform {
+  oneof key {
+    string aes_gcm_key_base64 = 1;
+  }
+}
+
+// The integrity transform. If the hash is included, it can be verified.
+// Otherwise, it can be retrieved after reading or writing with the
+// ComputedUri API.
+//
+// eg "integrity", "integrity(sha256=12345)"
+message IntegrityTransform {
+  oneof hash {
+    string sha256 = 1;
+  }
+}
+
+// The ZIP decompress transform. It requires a target file param.
+message ZipTransform {
+  // required
+  optional string target = 1;
+}
+
+// A custom transform. The transform with the specified name must be registered
+// with MobStore FileStorage.
+message CustomTransform {
+  // required
+  optional string name = 1;
+
+  message SubParam {
+    // required
+    optional string key = 1;
+    optional string value = 2;
+  }
+  repeated SubParam subparam = 2;
+}