/* * Copyright 2022 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either 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.errorprone.annotations.CanIgnoreReturnValue; 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:///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 RESERVED_MODULES = Collections.unmodifiableSet( new HashSet<>( Arrays.asList( "default", "unused", "special", "reserved", "shared", "virtual", "managed"))); private static final Set 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 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(). */ @CanIgnoreReturnValue public Builder setPackage(String packageName) { this.packageName = packageName; return this; } @CanIgnoreReturnValue private Builder setLocation(String location) { AndroidUri.validateLocation(location); this.location = location; return this; } @CanIgnoreReturnValue public Builder setManagedLocation() { return setLocation(MANAGED_LOCATION); } @CanIgnoreReturnValue public Builder setExternalLocation() { return setLocation(EXTERNAL_LOCATION); } @CanIgnoreReturnValue public Builder setDirectBootFilesLocation() { return setLocation(DIRECT_BOOT_FILES_LOCATION); } @CanIgnoreReturnValue public Builder setDirectBootCacheLocation() { return setLocation(DIRECT_BOOT_CACHE_LOCATION); } /** Internal location, aka "files", is the default location. */ @CanIgnoreReturnValue public Builder setInternalLocation() { return setLocation(FILES_LOCATION); } @CanIgnoreReturnValue public Builder setCacheLocation() { return setLocation(CACHE_LOCATION); } @CanIgnoreReturnValue 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. * *

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). * *

The account will be URL encoded in its URI representation (so, eg, "@gmail.com" * will appear as "you%40gmail.com"), but not in the file path representation used to access * disk. * *

Note the Linux filesystem accepts filenames composed of any bytes except "/" and NULL. * * @param account The account to set. * @return The fluent Builder. */ @CanIgnoreReturnValue 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. */ @CanIgnoreReturnValue 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)}). */ @CanIgnoreReturnValue 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. */ @CanIgnoreReturnValue 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)}). */ @CanIgnoreReturnValue 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) @CanIgnoreReturnValue public Builder fromAbsolutePath(String absolutePath, @Nullable AccountManager accountManager) { // Get the file's path within internal files, /module/account 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 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; } @CanIgnoreReturnValue 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(); } } }