/* * Copyright 2022 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either 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 com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.errorprone.annotations.concurrent.GuardedBy; import java.io.Closeable; import java.io.File; import java.io.IOException; import java.io.InputStream; import javax.annotation.Nullable; /** 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}. */ @CanIgnoreReturnValue 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. */ @CanIgnoreReturnValue 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. */ @CanIgnoreReturnValue 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} * *
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} * *
URI may belong to a different authority.
*/
@Override
public Pair 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;
}
}
}