blob: da6bc2ed9bc17e16234101e8d98f0668cd89911b [file] [log] [blame]
/*
* Copyright 2022 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either 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();
}
}
}