• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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