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;
+}