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; 17 18 import android.net.Uri; 19 import android.text.TextUtils; 20 import android.util.Log; 21 import com.google.android.libraries.mobiledatadownload.file.common.GcParam; 22 import com.google.android.libraries.mobiledatadownload.file.common.UnsupportedFileStorageOperation; 23 import com.google.android.libraries.mobiledatadownload.file.common.internal.LiteTransformFragments; 24 import com.google.android.libraries.mobiledatadownload.file.spi.Backend; 25 import com.google.android.libraries.mobiledatadownload.file.spi.Monitor; 26 import com.google.android.libraries.mobiledatadownload.file.spi.Transform; 27 import com.google.common.collect.ImmutableList; 28 import com.google.common.collect.Iterables; 29 import com.google.common.collect.Sets; 30 import com.google.errorprone.annotations.CheckReturnValue; 31 import java.io.IOException; 32 import java.util.ArrayList; 33 import java.util.Collections; 34 import java.util.HashMap; 35 import java.util.List; 36 import java.util.ListIterator; 37 import java.util.Map; 38 39 /** 40 * FileStorage is an abstraction over platform File I/O that supports pluggable backends and 41 * transforms. This is the synchronous variant which is useful for background processing and 42 * implementing Openers. 43 * 44 * <p>For testing, it is recommended to use a real backend such as JavaFileBackend, rather than 45 * mock. 46 * 47 * <p>See <internal> for details. 48 */ 49 public final class SynchronousFileStorage { 50 51 private static final String TAG = "MobStore.FileStorage"; 52 53 private final Map<String, Backend> backends = new HashMap<>(); 54 private final Map<String, Transform> transforms = new HashMap<>(); 55 private final List<Monitor> monitors = new ArrayList<>(); 56 57 /** 58 * Constructs a new SynchronousFileStorage with the specified executors, backends, transforms, and 59 * monitors. 60 * 61 * <p>In the case of a collision, the later backend/transform replaces any earlier ones. 62 * 63 * <p>FileStorage is expected to be a singleton provided by dependency injection. Transforms and 64 * backends should be registered once when producing that singleton. 65 * 66 * <p>All monitors are executed between transforms and the backend. For example, if you had a 67 * compression transform, the monitor would see the compressed bytes. 68 * 69 * @param backends Registers these backends. 70 * @param transforms Registers these transforms. 71 * @param monitors Registers these monitors. 72 */ SynchronousFileStorage( List<Backend> backends, List<Transform> transforms, List<Monitor> monitors)73 public SynchronousFileStorage( 74 List<Backend> backends, List<Transform> transforms, List<Monitor> monitors) { 75 registerPlugins(backends, transforms, monitors); 76 } 77 78 /** Constructs a new FileStorage with Transforms but no Monitors. */ SynchronousFileStorage(List<Backend> backends, List<Transform> transforms)79 public SynchronousFileStorage(List<Backend> backends, List<Transform> transforms) { 80 this(backends, transforms, Collections.emptyList()); 81 } 82 83 /** Constructs a new FileStorage with no Transforms or Monitors. */ SynchronousFileStorage(List<Backend> backends)84 public SynchronousFileStorage(List<Backend> backends) { 85 this(backends, Collections.emptyList(), Collections.emptyList()); 86 } 87 88 /** 89 * Registers backends, transforms and monitors to SynchronousFileStorage. 90 * 91 * @throws IllegalArgumentException for attempts to override existing backends or transforms 92 */ registerPlugins( List<Backend> backends, List<Transform> transforms, List<Monitor> monitors)93 private void registerPlugins( 94 List<Backend> backends, List<Transform> transforms, List<Monitor> monitors) { 95 for (Backend backend : backends) { 96 if (TextUtils.isEmpty(backend.name())) { 97 Log.w(TAG, "Cannot register backend, name empty"); 98 continue; 99 } 100 101 Backend oldValue = this.backends.put(backend.name(), backend); 102 if (oldValue != null) { 103 throw new IllegalArgumentException( 104 "Cannot override Backend " 105 + oldValue.getClass().getCanonicalName() 106 + " with " 107 + backend.getClass().getCanonicalName()); 108 } 109 } 110 for (Transform transform : transforms) { 111 if (TextUtils.isEmpty(transform.name())) { 112 Log.w(TAG, "Cannot register transform, name empty"); 113 continue; 114 } 115 Transform oldValue = this.transforms.put(transform.name(), transform); 116 if (oldValue != null) { 117 throw new IllegalArgumentException( 118 "Cannot to override Transform " 119 + oldValue.getClass().getCanonicalName() 120 + " with " 121 + transform.getClass().getCanonicalName()); 122 } 123 } 124 this.monitors.addAll(monitors); 125 } 126 127 /** 128 * Returns a String listing registered backends, transforms and monitors for debugging purposes. 129 */ getDebugInfo()130 public String getDebugInfo() { 131 String backendsDebugString = 132 TextUtils.join( 133 ",\n", 134 Sets.newTreeSet( 135 Iterables.transform( 136 backends.keySet(), 137 key -> 138 String.format( 139 "protocol: %1$s, class: %2$s", 140 key, backends.get(key).getClass().getSimpleName())))); 141 142 String transformsDebugString = 143 TextUtils.join( 144 ",\n", 145 Sets.newTreeSet( 146 Iterables.transform( 147 transforms.values(), transform -> transform.getClass().getSimpleName()))); 148 149 String monitorsDebugString = 150 TextUtils.join( 151 ",\n", 152 Sets.newTreeSet( 153 Iterables.transform(monitors, monitor -> monitor.getClass().getSimpleName()))); 154 155 return String.format( 156 "Registered Mobstore Plugins:\n\nBackends:\n%1$s\n\nTransforms:\n%2$s\n\nMonitors:\n%3$s", 157 backendsDebugString, transformsDebugString, monitorsDebugString); 158 } 159 160 /** 161 * Open URI with an Opener. The Opener determines the return type, eg, a Stream or a Proto and is 162 * responsible for implementing any additional behavior such as locking. 163 * 164 * @param uri The URI to open. 165 * @param opener The generic opener to use. 166 * @param <T> The kind of thing the opener opens. 167 * @return The result of the open operation. 168 */ 169 @CheckReturnValue open(Uri uri, Opener<T> opener)170 public <T> T open(Uri uri, Opener<T> opener) throws IOException { 171 OpenContext context = getContext(uri); 172 return opener.open(context); 173 } 174 175 /** 176 * Deletes the file denoted by {@code uri}. 177 * 178 * @throws IOException if the file could not be deleted for any reason 179 */ deleteFile(Uri uri)180 public void deleteFile(Uri uri) throws IOException { 181 OpenContext context = getContext(uri); 182 context.backend().deleteFile(context.encodedUri()); 183 } 184 185 /** 186 * Deletes the directory denoted by {@code uri}. The directory must be empty in order to be 187 * deleted. 188 * 189 * @throws IOException if the directory could not be deleted for any reason 190 */ deleteDirectory(Uri uri)191 public void deleteDirectory(Uri uri) throws IOException { 192 Backend backend = getBackend(uri.getScheme()); 193 backend.deleteDirectory(stripFragment(uri)); 194 } 195 196 /** 197 * Delete a file or directory and all its contents at a specified location. 198 * 199 * @param uri the location to delete 200 * @return true if and only if the file or directory specified at {@code uri} was deleted. 201 */ 202 @Deprecated // see {@link 203 // com.google.android.libraries.mobiledatadownload.file.openers.RecursiveDeleteOpener} deleteRecursively(Uri uri)204 public boolean deleteRecursively(Uri uri) throws IOException { 205 if (!exists(uri)) { 206 return false; 207 } 208 if (!isDirectory(uri)) { 209 deleteFile(uri); 210 return true; 211 } 212 for (Uri child : children(uri)) { 213 deleteRecursively(child); 214 } 215 deleteDirectory(uri); 216 return true; 217 } 218 219 /** 220 * Tells whether this file or directory exists. 221 * 222 * <p>The last segment of the uri path is interpreted as a file name and may be encoded by a 223 * transform. Callers should consider using {@link #isDirectory}, stripping fragments, or adding a 224 * trailing slash to avoid accidentally encoding a directory name. 225 * 226 * @param uri 227 * @return the success value of the operation. 228 */ 229 @CheckReturnValue exists(Uri uri)230 public boolean exists(Uri uri) throws IOException { 231 OpenContext context = getContext(uri); 232 return context.backend().exists(context.encodedUri()); 233 } 234 235 /** 236 * Tells whether this uri refers to a directory. 237 * 238 * @param uri 239 * @return the success value of the operation. 240 */ 241 @CheckReturnValue isDirectory(Uri uri)242 public boolean isDirectory(Uri uri) throws IOException { 243 Backend backend = getBackend(uri.getScheme()); 244 return backend.isDirectory(stripFragment(uri)); 245 } 246 247 /** 248 * Creates a new directory. Any non-existent parent directories will also be created. 249 * 250 * @throws IOException if the directory could not be created for any reason 251 */ createDirectory(Uri uri)252 public void createDirectory(Uri uri) throws IOException { 253 Backend backend = getBackend(uri.getScheme()); 254 backend.createDirectory(stripFragment(uri)); 255 } 256 257 /** 258 * Gets the file size. 259 * 260 * <p>If the uri refers to a directory or non-existent, returns 0. 261 * 262 * @param uri 263 * @return the size in bytes of the file. 264 */ 265 @CheckReturnValue fileSize(Uri uri)266 public long fileSize(Uri uri) throws IOException { 267 OpenContext context = getContext(uri); 268 return context.backend().fileSize(context.encodedUri()); 269 } 270 271 /** 272 * Renames the file or directory from one location to another. This can only be performed if the 273 * schemes of the Uris map to the same backend instance. 274 * 275 * <p>The last segment of the uri path is interpreted as a file name and may be encoded by a 276 * transform. Callers should ensure a trailing slash is included for directory names or strip 277 * transforms to avoid accidentally encoding a directory name. 278 * 279 * @throws IOException if the file could not be renamed for any reason 280 */ rename(Uri from, Uri to)281 public void rename(Uri from, Uri to) throws IOException { 282 OpenContext fromContext = getContext(from); 283 OpenContext toContext = getContext(to); 284 // Even if it's the same provider, require that the backend instances be the same 285 // for a rename operation. (Can make less restrictive if necessary.) 286 if (fromContext.backend() != toContext.backend()) { 287 throw new UnsupportedFileStorageOperation("Cannot rename file across backends"); 288 } 289 fromContext.backend().rename(fromContext.encodedUri(), toContext.encodedUri()); 290 } 291 292 /** 293 * Lists children of a parent directory Uri. 294 * 295 * @param parentUri The parent directory to list. 296 * @return the list of children. 297 */ 298 @CheckReturnValue children(Uri parentUri)299 public Iterable<Uri> children(Uri parentUri) throws IOException { 300 Backend backend = getBackend(parentUri.getScheme()); 301 List<Transform> enabledTransforms = getEnabledTransforms(parentUri); 302 List<Uri> result = new ArrayList<Uri>(); 303 String encodedFragment = parentUri.getEncodedFragment(); 304 for (Uri child : backend.children(stripFragment(parentUri))) { 305 Uri decodedChild = 306 decodeFilename( 307 enabledTransforms, child.buildUpon().encodedFragment(encodedFragment).build()); 308 result.add(decodedChild); 309 } 310 return result; 311 } 312 313 /** Retrieves the {@link GcParam} associated with the given URI. */ getGcParam(Uri uri)314 public GcParam getGcParam(Uri uri) throws IOException { 315 OpenContext context = getContext(uri); 316 return context.backend().getGcParam(context.encodedUri()); 317 } 318 319 /** Sets the {@link GcParam} associated with the given URI. */ setGcParam(Uri uri, GcParam param)320 public void setGcParam(Uri uri, GcParam param) throws IOException { 321 OpenContext context = getContext(uri); 322 context.backend().setGcParam(context.encodedUri(), param); 323 } 324 getContext(Uri uri)325 private OpenContext getContext(Uri uri) throws IOException { 326 List<Transform> enabledTransforms = getEnabledTransforms(uri); 327 return OpenContext.builder() 328 .setStorage(this) 329 .setBackend(getBackend(uri.getScheme())) 330 .setMonitors(monitors) 331 .setTransforms(enabledTransforms) 332 .setOriginalUri(uri) 333 .setEncodedUri(encodeFilename(enabledTransforms, uri)) 334 .build(); 335 } 336 getBackend(String scheme)337 private Backend getBackend(String scheme) throws IOException { 338 Backend backend = backends.get(scheme); 339 if (backend == null) { 340 throw new UnsupportedFileStorageOperation( 341 String.format("Cannot open, unregistered backend: %s", scheme)); 342 } 343 return backend; 344 } 345 getEnabledTransforms(Uri uri)346 private ImmutableList<Transform> getEnabledTransforms(Uri uri) 347 throws UnsupportedFileStorageOperation { 348 ImmutableList.Builder<Transform> builder = ImmutableList.builder(); 349 for (String name : LiteTransformFragments.parseTransformNames(uri)) { 350 Transform transform = transforms.get(name); 351 if (transform == null) { 352 throw new UnsupportedFileStorageOperation("No such transform: " + name + ": " + uri); 353 } 354 builder.add(transform); 355 } 356 return builder.build().reverse(); 357 } 358 stripFragment(Uri uri)359 private static final Uri stripFragment(Uri uri) { 360 return uri.buildUpon().fragment(null).build(); 361 } 362 363 /** 364 * Give transforms the opportunity to encode the file part (last segment for file operations) of 365 * the uri. Also strips fragment. 366 */ encodeFilename(List<Transform> transforms, Uri uri)367 private static final Uri encodeFilename(List<Transform> transforms, Uri uri) { 368 if (transforms.isEmpty()) { 369 return uri; 370 } 371 List<String> segments = new ArrayList<String>(uri.getPathSegments()); 372 // This Uri implementation's getPathSegments() ignores trailing "/". 373 if (segments.isEmpty() || uri.getPath().endsWith("/")) { 374 return uri; 375 } 376 String filename = segments.get(segments.size() - 1); 377 // Reverse transforms, restoring their original order. (In all other places the reverse order 378 // is more convenient.) 379 for (ListIterator<Transform> iter = transforms.listIterator(transforms.size()); 380 iter.hasPrevious(); ) { 381 Transform transform = iter.previous(); 382 filename = transform.encode(uri, filename); 383 } 384 segments.set(segments.size() - 1, filename); 385 return uri.buildUpon().path(TextUtils.join("/", segments)).encodedFragment(null).build(); 386 } 387 388 /** 389 * Give transforms the opportunity to decode the file part (last segment for file operations) of 390 * the uri. Reverses encodeFilename(). 391 */ decodeFilename(List<Transform> transforms, Uri uri)392 private static final Uri decodeFilename(List<Transform> transforms, Uri uri) { 393 if (transforms.isEmpty()) { 394 return uri; 395 } 396 List<String> segments = new ArrayList<String>(uri.getPathSegments()); 397 // This Uri implementation's getPathSegments() ignores trailing "/". 398 if (segments.isEmpty() || uri.getPath().endsWith("/")) { 399 return uri; 400 } 401 String filename = Iterables.getLast(segments); 402 for (Transform transform : transforms) { 403 filename = transform.decode(uri, filename); 404 } 405 segments.set(segments.size() - 1, filename); 406 return uri.buildUpon().path(TextUtils.join("/", segments)).build(); 407 } 408 } 409