1 /* 2 * Copyright 2022 Google LLC 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 package com.google.android.libraries.mobiledatadownload.file.backends; 17 18 import android.accounts.Account; 19 import android.content.Context; 20 import android.net.Uri; 21 import android.os.Build; 22 import com.google.android.libraries.mobiledatadownload.file.common.MalformedUriException; 23 import com.google.android.libraries.mobiledatadownload.file.common.internal.LiteTransformFragments; 24 import com.google.android.libraries.mobiledatadownload.file.common.internal.Preconditions; 25 import com.google.android.libraries.mobiledatadownload.file.transforms.TransformProtos; 26 import com.google.common.collect.ImmutableList; 27 import com.google.errorprone.annotations.CanIgnoreReturnValue; 28 import com.google.mobiledatadownload.TransformProto; 29 import java.io.File; 30 import java.util.Arrays; 31 import java.util.Collections; 32 import java.util.HashSet; 33 import java.util.List; 34 import java.util.Set; 35 import java.util.concurrent.ExecutionException; 36 import java.util.regex.Pattern; 37 import javax.annotation.Nullable; 38 39 /** Helper class for "android:" URIs. */ 40 public final class AndroidUri { 41 42 /** 43 * Returns an android: scheme URI builder for package {@code packageName}. If no setter is called 44 * before {@link Builder#build}, the resultant URI will point to the common internal app storage, 45 * i.e. "android://<packageName>/files/common/shared/" 46 * 47 * @param context The android environment. 48 */ builder(Context context)49 public static Builder builder(Context context) { 50 return new Builder(context); 51 } 52 AndroidUri()53 private AndroidUri() {} 54 55 // Module names are non-empty strings of [a-z] with interleaved underscores 56 private static final Pattern MODULE_PATTERN = Pattern.compile("[a-z]+(_[a-z]+)*"); 57 58 // Name registered for the Android backend 59 static final String SCHEME_NAME = "android"; 60 61 // URI path fragments with special meaning 62 static final String FILES_LOCATION = "files"; 63 static final String MANAGED_LOCATION = "managed"; 64 static final String CACHE_LOCATION = "cache"; 65 // See https://developer.android.com/training/articles/direct-boot.html 66 static final String DIRECT_BOOT_FILES_LOCATION = "directboot-files"; 67 static final String DIRECT_BOOT_CACHE_LOCATION = "directboot-cache"; 68 static final String EXTERNAL_LOCATION = "external"; 69 70 // The "managed" location maps to a subdirectory within /files/. 71 static final String MANAGED_FILES_DIR_SUBDIRECTORY = "managed"; 72 73 static final String COMMON_MODULE = "common"; 74 static final Account SHARED_ACCOUNT = AccountSerialization.SHARED_ACCOUNT; 75 76 // Module names reserved for future use or that are otherwise disallowed. Note that ImmutableSet 77 // is avoided in order to avoid guava dependency. 78 private static final Set<String> RESERVED_MODULES = 79 Collections.unmodifiableSet( 80 new HashSet<>( 81 Arrays.asList( 82 "default", "unused", "special", "reserved", "shared", "virtual", "managed"))); 83 84 private static final Set<String> VALID_LOCATIONS = 85 Collections.unmodifiableSet( 86 new HashSet<>( 87 Arrays.asList( 88 FILES_LOCATION, 89 CACHE_LOCATION, 90 MANAGED_LOCATION, 91 DIRECT_BOOT_FILES_LOCATION, 92 DIRECT_BOOT_CACHE_LOCATION, 93 EXTERNAL_LOCATION))); 94 95 /** 96 * Validates the {@code location} of an Android URI path; "files" and "directboot" are the only 97 * valid strings. 98 */ validateLocation(String location)99 static void validateLocation(String location) { 100 Preconditions.checkArgument( 101 VALID_LOCATIONS.contains(location), 102 "The only supported locations are %s: %s", 103 VALID_LOCATIONS, 104 location); 105 } 106 /** 107 * Validates the {@code module} of an Android URI path. Any non-empty string of [a-z] with 108 * interleaved underscores that is not listed as reserved is valid. 109 */ validateModule(String module)110 static void validateModule(String module) { 111 Preconditions.checkArgument( 112 MODULE_PATTERN.matcher(module).matches(), "Module must match [a-z]+(_[a-z]+)*: %s", module); 113 Preconditions.checkArgument( 114 !RESERVED_MODULES.contains(module), 115 "Module name is reserved and cannot be used: %s", 116 module); 117 } 118 119 /** 120 * Validates the {@code unusedRelativePath} of an Android URI path. At present time this is a 121 * no-op. 122 * 123 * @param unusedRelativePath Not used. 124 */ validateRelativePath(String unusedRelativePath)125 static void validateRelativePath(String unusedRelativePath) { 126 // No-op 127 } 128 129 /** Builder for Android Uris. */ 130 public static class Builder { 131 132 // URI authority; required 133 private final Context context; 134 135 // URI path components; optional 136 private String packageName; // TODO: should default be ""? 137 private String location = AndroidUri.FILES_LOCATION; 138 private String module = AndroidUri.COMMON_MODULE; 139 private Account account = AndroidUri.SHARED_ACCOUNT; 140 private String relativePath = ""; 141 142 private final ImmutableList.Builder<String> encodedSpecs = ImmutableList.builder(); 143 Builder(Context context)144 private Builder(Context context) { 145 Preconditions.checkArgument(context != null, "Context cannot be null"); 146 this.context = context; 147 this.packageName = context.getPackageName(); 148 } 149 150 /** 151 * Sets the package to use in the android uri AUTHORITY. Default is context.getPackageName(). 152 */ 153 @CanIgnoreReturnValue setPackage(String packageName)154 public Builder setPackage(String packageName) { 155 this.packageName = packageName; 156 return this; 157 } 158 159 @CanIgnoreReturnValue setLocation(String location)160 private Builder setLocation(String location) { 161 AndroidUri.validateLocation(location); 162 this.location = location; 163 return this; 164 } 165 166 @CanIgnoreReturnValue setManagedLocation()167 public Builder setManagedLocation() { 168 return setLocation(MANAGED_LOCATION); 169 } 170 171 @CanIgnoreReturnValue setExternalLocation()172 public Builder setExternalLocation() { 173 return setLocation(EXTERNAL_LOCATION); 174 } 175 176 @CanIgnoreReturnValue setDirectBootFilesLocation()177 public Builder setDirectBootFilesLocation() { 178 return setLocation(DIRECT_BOOT_FILES_LOCATION); 179 } 180 181 @CanIgnoreReturnValue setDirectBootCacheLocation()182 public Builder setDirectBootCacheLocation() { 183 return setLocation(DIRECT_BOOT_CACHE_LOCATION); 184 } 185 186 /** Internal location, aka "files", is the default location. */ 187 @CanIgnoreReturnValue setInternalLocation()188 public Builder setInternalLocation() { 189 return setLocation(FILES_LOCATION); 190 } 191 192 @CanIgnoreReturnValue setCacheLocation()193 public Builder setCacheLocation() { 194 return setLocation(CACHE_LOCATION); 195 } 196 197 @CanIgnoreReturnValue setModule(String module)198 public Builder setModule(String module) { 199 AndroidUri.validateModule(module); 200 this.module = module; 201 return this; 202 } 203 204 /** 205 * Sets the account. AndroidUri.SHARED_ACCOUNT is the default, and it shows up as "shared" on 206 * the filesystem. 207 * 208 * <p>This method performs some account validation. Android Account itself requires that both 209 * the type and name fields be present. In addition to this requirement, this backend requires 210 * that the type contain no colons (as these are the delimiter used internally for the account 211 * serialization), and that neither the type nor the name include any slashes (as these are file 212 * separators). 213 * 214 * <p>The account will be URL encoded in its URI representation (so, eg, "<internal>@gmail.com" 215 * will appear as "you%40gmail.com"), but not in the file path representation used to access 216 * disk. 217 * 218 * <p>Note the Linux filesystem accepts filenames composed of any bytes except "/" and NULL. 219 * 220 * @param account The account to set. 221 * @return The fluent Builder. 222 */ 223 @CanIgnoreReturnValue setAccount(Account account)224 public Builder setAccount(Account account) { 225 AccountSerialization.serialize(account); // performs validation internally 226 this.account = account; 227 return this; 228 } 229 230 /** 231 * Sets the component of the path after location, module and account. A single leading slash 232 * will be trimmed if present. 233 */ 234 @CanIgnoreReturnValue setRelativePath(String relativePath)235 public Builder setRelativePath(String relativePath) { 236 if (relativePath.startsWith("/")) { 237 relativePath = relativePath.substring(1); 238 } 239 AndroidUri.validateRelativePath(relativePath); 240 this.relativePath = relativePath; 241 return this; 242 } 243 244 /** 245 * Updates builder with multiple fields from file param: location, module, account and relative 246 * path. This method will fail on "managed" paths (see {@link fromFile(File, AccountManager)}). 247 */ 248 @CanIgnoreReturnValue fromFile(File file)249 public Builder fromFile(File file) { 250 return fromAbsolutePath(file.getAbsolutePath(), /* accountManager= */ null); 251 } 252 253 /** 254 * Updates builder with multiple fields from file param: location, module, account and relative 255 * path. A non-null {@code accountManager} is required to handle "managed" paths. 256 */ 257 @CanIgnoreReturnValue fromFile(File file, @Nullable AccountManager accountManager)258 public Builder fromFile(File file, @Nullable AccountManager accountManager) { 259 return fromAbsolutePath(file.getAbsolutePath(), accountManager); 260 } 261 262 /** 263 * Updates builder with multiple fields from absolute path param: location, module, account and 264 * relative path. This method will fail on "managed" paths (see {@link fromAbsolutePath(String, 265 * AccountManager)}). 266 */ 267 @CanIgnoreReturnValue fromAbsolutePath(String absolutePath)268 public Builder fromAbsolutePath(String absolutePath) { 269 return fromAbsolutePath(absolutePath, /* accountManager= */ null); 270 } 271 272 /** 273 * Updates builder with multiple fields from absolute path param: location, module, account and 274 * relative path. A non-null {@code accountManager} is required to handle "managed" paths. 275 */ 276 // TODO(b/129467051): remove requirement for segments after 0th (logical location) 277 @CanIgnoreReturnValue fromAbsolutePath(String absolutePath, @Nullable AccountManager accountManager)278 public Builder fromAbsolutePath(String absolutePath, @Nullable AccountManager accountManager) { 279 // Get the file's path within internal files, /module/account</relativePath> 280 File filesDir = AndroidFileEnvironment.getFilesDirWithPreNWorkaround(context); 281 String filesDirPath = filesDir.getAbsolutePath(); 282 String cacheDirPath = context.getCacheDir().getAbsolutePath(); 283 String managedDirPath = new File(filesDir, MANAGED_FILES_DIR_SUBDIRECTORY).getAbsolutePath(); 284 String externalDirPath = null; 285 File externalFilesDir = context.getExternalFilesDir(null); 286 if (externalFilesDir != null) { 287 externalDirPath = externalFilesDir.getAbsolutePath(); 288 } 289 String directBootFilesPath = null; 290 String directBootCachePath = null; 291 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { 292 // TODO(b/143610872): run after checking other dirs to minimize impact of new Context()'s 293 File dpsDataDir = AndroidFileEnvironment.getDeviceProtectedDataDir(context); 294 directBootFilesPath = new File(dpsDataDir, "files").getAbsolutePath(); 295 directBootCachePath = new File(dpsDataDir, "cache").getAbsolutePath(); 296 } 297 298 String internalPath; 299 if (absolutePath.startsWith(managedDirPath)) { 300 // managedDirPath must be checked before filesDirPath because filesDirPath is a prefix. 301 setLocation(AndroidUri.MANAGED_LOCATION); 302 internalPath = absolutePath.substring(managedDirPath.length()); 303 } else if (absolutePath.startsWith(filesDirPath)) { 304 setLocation(AndroidUri.FILES_LOCATION); 305 internalPath = absolutePath.substring(filesDirPath.length()); 306 } else if (absolutePath.startsWith(cacheDirPath)) { 307 setLocation(AndroidUri.CACHE_LOCATION); 308 internalPath = absolutePath.substring(cacheDirPath.length()); 309 } else if (externalDirPath != null && absolutePath.startsWith(externalDirPath)) { 310 setLocation(AndroidUri.EXTERNAL_LOCATION); 311 internalPath = absolutePath.substring(externalDirPath.length()); 312 } else if (directBootFilesPath != null && absolutePath.startsWith(directBootFilesPath)) { 313 setLocation(AndroidUri.DIRECT_BOOT_FILES_LOCATION); 314 internalPath = absolutePath.substring(directBootFilesPath.length()); 315 } else if (directBootCachePath != null && absolutePath.startsWith(directBootCachePath)) { 316 setLocation(AndroidUri.DIRECT_BOOT_CACHE_LOCATION); 317 internalPath = absolutePath.substring(directBootCachePath.length()); 318 } else { 319 throw new IllegalArgumentException( 320 "Path must be in app-private files dir or external files dir: " + absolutePath); 321 } 322 323 // Extract components according to android: file layout. The 0th element of split() will be 324 // an empty string preceding the first character "/" 325 List<String> pathFragments = Arrays.asList(internalPath.split(File.separator)); 326 Preconditions.checkArgument( 327 pathFragments.size() >= 3, 328 "Path must be in module and account subdirectories: %s", 329 absolutePath); 330 setModule(pathFragments.get(1)); 331 332 String accountStr = pathFragments.get(2); 333 if (MANAGED_LOCATION.equals(location) && !AccountSerialization.isSharedAccount(accountStr)) { 334 int accountId; 335 try { 336 accountId = Integer.parseInt(accountStr); 337 } catch (NumberFormatException e) { 338 throw new IllegalArgumentException(e); 339 } 340 341 // Blocks on disk IO to read account table. 342 // TODO(b/115940396): surface bad account as FileNotFoundException (change API signature?) 343 Preconditions.checkArgument(accountManager != null, "AccountManager cannot be null"); 344 try { 345 setAccount(accountManager.getAccount(accountId).get()); 346 } catch (InterruptedException e) { 347 Thread.currentThread().interrupt(); 348 throw new IllegalArgumentException(new MalformedUriException(e)); 349 } catch (ExecutionException e) { 350 throw new IllegalArgumentException(new MalformedUriException(e.getCause())); 351 } 352 } else { 353 setAccount(AccountSerialization.deserialize(accountStr)); 354 } 355 356 setRelativePath(internalPath.substring(module.length() + accountStr.length() + 2)); 357 return this; 358 } 359 360 @CanIgnoreReturnValue withTransform(TransformProto.Transform spec)361 public Builder withTransform(TransformProto.Transform spec) { 362 encodedSpecs.add(TransformProtos.toEncodedSpec(spec)); 363 return this; 364 } 365 366 // TODO(b/115940396): add MalformedUriException to signature build()367 public Uri build() { 368 String uriPath = 369 "/" 370 + location 371 + "/" 372 + module 373 + "/" 374 + AccountSerialization.serialize(account) 375 + "/" 376 + relativePath; 377 String fragment = LiteTransformFragments.joinTransformSpecs(encodedSpecs.build()); 378 379 return new Uri.Builder() 380 .scheme(AndroidUri.SCHEME_NAME) 381 .authority(packageName) 382 .path(uriPath) 383 .encodedFragment(fragment) 384 .build(); 385 } 386 } 387 } 388