blob: 1a55e9db469a653153b07d802b3b4a8a109afb59 [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;
import android.net.Uri;
import android.text.TextUtils;
import android.util.Log;
import com.google.android.libraries.mobiledatadownload.file.common.GcParam;
import com.google.android.libraries.mobiledatadownload.file.common.UnsupportedFileStorageOperation;
import com.google.android.libraries.mobiledatadownload.file.common.internal.LiteTransformFragments;
import com.google.android.libraries.mobiledatadownload.file.spi.Backend;
import com.google.android.libraries.mobiledatadownload.file.spi.Monitor;
import com.google.android.libraries.mobiledatadownload.file.spi.Transform;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import com.google.common.collect.Sets;
import com.google.errorprone.annotations.CheckReturnValue;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.ListIterator;
import java.util.Map;
/**
* FileStorage is an abstraction over platform File I/O that supports pluggable backends and
* transforms. This is the synchronous variant which is useful for background processing and
* implementing Openers.
*
* <p>For testing, it is recommended to use a real backend such as JavaFileBackend, rather than
* mock.
*
* <p>See <internal> for details.
*/
public final class SynchronousFileStorage {
private static final String TAG = "MobStore.FileStorage";
private final Map<String, Backend> backends = new HashMap<>();
private final Map<String, Transform> transforms = new HashMap<>();
private final List<Monitor> monitors = new ArrayList<>();
/**
* Constructs a new SynchronousFileStorage with the specified executors, backends, transforms, and
* monitors.
*
* <p>In the case of a collision, the later backend/transform replaces any earlier ones.
*
* <p>FileStorage is expected to be a singleton provided by dependency injection. Transforms and
* backends should be registered once when producing that singleton.
*
* <p>All monitors are executed between transforms and the backend. For example, if you had a
* compression transform, the monitor would see the compressed bytes.
*
* @param backends Registers these backends.
* @param transforms Registers these transforms.
* @param monitors Registers these monitors.
*/
public SynchronousFileStorage(
List<Backend> backends, List<Transform> transforms, List<Monitor> monitors) {
registerPlugins(backends, transforms, monitors);
}
/** Constructs a new FileStorage with Transforms but no Monitors. */
public SynchronousFileStorage(List<Backend> backends, List<Transform> transforms) {
this(backends, transforms, Collections.emptyList());
}
/** Constructs a new FileStorage with no Transforms or Monitors. */
public SynchronousFileStorage(List<Backend> backends) {
this(backends, Collections.emptyList(), Collections.emptyList());
}
/**
* Registers backends, transforms and monitors to SynchronousFileStorage.
*
* @throws IllegalArgumentException for attempts to override existing backends or transforms
*/
private void registerPlugins(
List<Backend> backends, List<Transform> transforms, List<Monitor> monitors) {
for (Backend backend : backends) {
if (TextUtils.isEmpty(backend.name())) {
Log.w(TAG, "Cannot register backend, name empty");
continue;
}
Backend oldValue = this.backends.put(backend.name(), backend);
if (oldValue != null) {
throw new IllegalArgumentException(
"Cannot override Backend "
+ oldValue.getClass().getCanonicalName()
+ " with "
+ backend.getClass().getCanonicalName());
}
}
for (Transform transform : transforms) {
if (TextUtils.isEmpty(transform.name())) {
Log.w(TAG, "Cannot register transform, name empty");
continue;
}
Transform oldValue = this.transforms.put(transform.name(), transform);
if (oldValue != null) {
throw new IllegalArgumentException(
"Cannot to override Transform "
+ oldValue.getClass().getCanonicalName()
+ " with "
+ transform.getClass().getCanonicalName());
}
}
this.monitors.addAll(monitors);
}
/**
* Returns a String listing registered backends, transforms and monitors for debugging purposes.
*/
public String getDebugInfo() {
String backendsDebugString =
TextUtils.join(
",\n",
Sets.newTreeSet(
Iterables.transform(
backends.keySet(),
key ->
String.format(
"protocol: %1$s, class: %2$s",
key, backends.get(key).getClass().getSimpleName()))));
String transformsDebugString =
TextUtils.join(
",\n",
Sets.newTreeSet(
Iterables.transform(
transforms.values(), transform -> transform.getClass().getSimpleName())));
String monitorsDebugString =
TextUtils.join(
",\n",
Sets.newTreeSet(
Iterables.transform(monitors, monitor -> monitor.getClass().getSimpleName())));
return String.format(
"Registered Mobstore Plugins:\n\nBackends:\n%1$s\n\nTransforms:\n%2$s\n\nMonitors:\n%3$s",
backendsDebugString, transformsDebugString, monitorsDebugString);
}
/**
* Open URI with an Opener. The Opener determines the return type, eg, a Stream or a Proto and is
* responsible for implementing any additional behavior such as locking.
*
* @param uri The URI to open.
* @param opener The generic opener to use.
* @param <T> The kind of thing the opener opens.
* @return The result of the open operation.
*/
@CheckReturnValue
public <T> T open(Uri uri, Opener<T> opener) throws IOException {
OpenContext context = getContext(uri);
return opener.open(context);
}
/**
* Deletes the file denoted by {@code uri}.
*
* @throws IOException if the file could not be deleted for any reason
*/
public void deleteFile(Uri uri) throws IOException {
OpenContext context = getContext(uri);
context.backend().deleteFile(context.encodedUri());
}
/**
* Deletes the directory denoted by {@code uri}. The directory must be empty in order to be
* deleted.
*
* @throws IOException if the directory could not be deleted for any reason
*/
public void deleteDirectory(Uri uri) throws IOException {
Backend backend = getBackend(uri.getScheme());
backend.deleteDirectory(stripFragment(uri));
}
/**
* Delete a file or directory and all its contents at a specified location.
*
* @param uri the location to delete
* @return true if and only if the file or directory specified at {@code uri} was deleted.
*/
@Deprecated // see {@link
// com.google.android.libraries.mobiledatadownload.file.openers.RecursiveDeleteOpener}
public boolean deleteRecursively(Uri uri) throws IOException {
if (!exists(uri)) {
return false;
}
if (!isDirectory(uri)) {
deleteFile(uri);
return true;
}
for (Uri child : children(uri)) {
deleteRecursively(child);
}
deleteDirectory(uri);
return true;
}
/**
* Tells whether this file or directory exists.
*
* <p>The last segment of the uri path is interpreted as a file name and may be encoded by a
* transform. Callers should consider using {@link #isDirectory}, stripping fragments, or adding a
* trailing slash to avoid accidentally encoding a directory name.
*
* @param uri
* @return the success value of the operation.
*/
@CheckReturnValue
public boolean exists(Uri uri) throws IOException {
OpenContext context = getContext(uri);
return context.backend().exists(context.encodedUri());
}
/**
* Tells whether this uri refers to a directory.
*
* @param uri
* @return the success value of the operation.
*/
@CheckReturnValue
public boolean isDirectory(Uri uri) throws IOException {
Backend backend = getBackend(uri.getScheme());
return backend.isDirectory(stripFragment(uri));
}
/**
* Creates a new directory. Any non-existent parent directories will also be created.
*
* @throws IOException if the directory could not be created for any reason
*/
public void createDirectory(Uri uri) throws IOException {
Backend backend = getBackend(uri.getScheme());
backend.createDirectory(stripFragment(uri));
}
/**
* Gets the file size.
*
* <p>If the uri refers to a directory or non-existent, returns 0.
*
* @param uri
* @return the size in bytes of the file.
*/
@CheckReturnValue
public long fileSize(Uri uri) throws IOException {
OpenContext context = getContext(uri);
return context.backend().fileSize(context.encodedUri());
}
/**
* Renames the file or directory from one location to another. This can only be performed if the
* schemes of the Uris map to the same backend instance.
*
* <p>The last segment of the uri path is interpreted as a file name and may be encoded by a
* transform. Callers should ensure a trailing slash is included for directory names or strip
* transforms to avoid accidentally encoding a directory name.
*
* @throws IOException if the file could not be renamed for any reason
*/
public void rename(Uri from, Uri to) throws IOException {
OpenContext fromContext = getContext(from);
OpenContext toContext = getContext(to);
// Even if it's the same provider, require that the backend instances be the same
// for a rename operation. (Can make less restrictive if necessary.)
if (fromContext.backend() != toContext.backend()) {
throw new UnsupportedFileStorageOperation("Cannot rename file across backends");
}
fromContext.backend().rename(fromContext.encodedUri(), toContext.encodedUri());
}
/**
* Lists children of a parent directory Uri.
*
* @param parentUri The parent directory to list.
* @return the list of children.
*/
@CheckReturnValue
public Iterable<Uri> children(Uri parentUri) throws IOException {
Backend backend = getBackend(parentUri.getScheme());
List<Transform> enabledTransforms = getEnabledTransforms(parentUri);
List<Uri> result = new ArrayList<Uri>();
String encodedFragment = parentUri.getEncodedFragment();
for (Uri child : backend.children(stripFragment(parentUri))) {
Uri decodedChild =
decodeFilename(
enabledTransforms, child.buildUpon().encodedFragment(encodedFragment).build());
result.add(decodedChild);
}
return result;
}
/** Retrieves the {@link GcParam} associated with the given URI. */
public GcParam getGcParam(Uri uri) throws IOException {
OpenContext context = getContext(uri);
return context.backend().getGcParam(context.encodedUri());
}
/** Sets the {@link GcParam} associated with the given URI. */
public void setGcParam(Uri uri, GcParam param) throws IOException {
OpenContext context = getContext(uri);
context.backend().setGcParam(context.encodedUri(), param);
}
private OpenContext getContext(Uri uri) throws IOException {
List<Transform> enabledTransforms = getEnabledTransforms(uri);
return OpenContext.builder()
.setStorage(this)
.setBackend(getBackend(uri.getScheme()))
.setMonitors(monitors)
.setTransforms(enabledTransforms)
.setOriginalUri(uri)
.setEncodedUri(encodeFilename(enabledTransforms, uri))
.build();
}
private Backend getBackend(String scheme) throws IOException {
Backend backend = backends.get(scheme);
if (backend == null) {
throw new UnsupportedFileStorageOperation(
String.format("Cannot open, unregistered backend: %s", scheme));
}
return backend;
}
private ImmutableList<Transform> getEnabledTransforms(Uri uri)
throws UnsupportedFileStorageOperation {
ImmutableList.Builder<Transform> builder = ImmutableList.builder();
for (String name : LiteTransformFragments.parseTransformNames(uri)) {
Transform transform = transforms.get(name);
if (transform == null) {
throw new UnsupportedFileStorageOperation("No such transform: " + name + ": " + uri);
}
builder.add(transform);
}
return builder.build().reverse();
}
private static final Uri stripFragment(Uri uri) {
return uri.buildUpon().fragment(null).build();
}
/**
* Give transforms the opportunity to encode the file part (last segment for file operations) of
* the uri. Also strips fragment.
*/
private static final Uri encodeFilename(List<Transform> transforms, Uri uri) {
if (transforms.isEmpty()) {
return uri;
}
List<String> segments = new ArrayList<String>(uri.getPathSegments());
// This Uri implementation's getPathSegments() ignores trailing "/".
if (segments.isEmpty() || uri.getPath().endsWith("/")) {
return uri;
}
String filename = segments.get(segments.size() - 1);
// Reverse transforms, restoring their original order. (In all other places the reverse order
// is more convenient.)
for (ListIterator<Transform> iter = transforms.listIterator(transforms.size());
iter.hasPrevious(); ) {
Transform transform = iter.previous();
filename = transform.encode(uri, filename);
}
segments.set(segments.size() - 1, filename);
return uri.buildUpon().path(TextUtils.join("/", segments)).encodedFragment(null).build();
}
/**
* Give transforms the opportunity to decode the file part (last segment for file operations) of
* the uri. Reverses encodeFilename().
*/
private static final Uri decodeFilename(List<Transform> transforms, Uri uri) {
if (transforms.isEmpty()) {
return uri;
}
List<String> segments = new ArrayList<String>(uri.getPathSegments());
// This Uri implementation's getPathSegments() ignores trailing "/".
if (segments.isEmpty() || uri.getPath().endsWith("/")) {
return uri;
}
String filename = Iterables.getLast(segments);
for (Transform transform : transforms) {
filename = transform.decode(uri, filename);
}
segments.set(segments.size() - 1, filename);
return uri.buildUpon().path(TextUtils.join("/", segments)).build();
}
}