blob: 855ec855484a4519b24ebaedf65d4fe6b60de1ef [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.integration.downloader;
import static com.google.common.base.Preconditions.checkArgument;
import static java.util.concurrent.TimeUnit.MILLISECONDS;
import android.net.Uri;
import com.google.android.downloader.DownloadDestination;
import com.google.android.downloader.DownloadMetadata;
import com.google.android.libraries.mobiledatadownload.file.OpenContext;
import com.google.android.libraries.mobiledatadownload.file.Opener;
import com.google.android.libraries.mobiledatadownload.file.SynchronousFileStorage;
import com.google.android.libraries.mobiledatadownload.file.common.ReleasableResource;
import com.google.android.libraries.mobiledatadownload.file.common.UnsupportedFileStorageOperation;
import com.google.android.libraries.mobiledatadownload.file.openers.RandomAccessFileOpener;
import com.google.common.base.Optional;
import com.google.common.util.concurrent.ListenableFuture;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.channels.FileChannel;
import java.nio.channels.WritableByteChannel;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeoutException;
/**
* A MobStore Opener for <internal>'s {@link DownloadDestination}.
*
* <p>This creates a {@link DownloadDestination} that supports writing data and connects to a shared
* PDS instance to handle metadata updates.
*
* <pre>{@code
* Downloader downloader = new Downloader(...);
* DownloadMetadataStore metadataStore = ...;
* SynchronousFileStorage storage = new SynchronousFileStorage(...);
*
* DownloadDestination destination = storage.open(
* targetFileUri,
* DownloadDestinationOpener.create(metadataStore));
*
* downloader.execute(
* downloader
* .newRequestBuilder(urlToDownload, destination)
* .build());
* }</pre>
*/
public final class DownloadDestinationOpener implements Opener<DownloadDestination> {
private static final long TIMEOUT_MS = 1000;
/** Implementation of {@link DownloadDestination} created by the opener. */
private static final class DownloadDestinationImpl implements DownloadDestination {
// We need to touch two underlying files (metadata from DownloadMetadataStore and the downloaded
// file). Define a lock to keep the access of these files synchronized.
private final Object lock = new Object();
private final Uri onDeviceUri;
private final DownloadMetadataStore metadataStore;
private final SynchronousFileStorage fileStorage;
private DownloadDestinationImpl(
Uri onDeviceUri, SynchronousFileStorage fileStorage, DownloadMetadataStore metadataStore) {
this.onDeviceUri = onDeviceUri;
this.metadataStore = metadataStore;
this.fileStorage = fileStorage;
}
@Override
public long numExistingBytes() throws IOException {
return fileStorage.fileSize(onDeviceUri);
}
@Override
public DownloadMetadata readMetadata() throws IOException {
synchronized (lock) {
Optional<DownloadMetadata> existingMetadata =
blockingGet(metadataStore.read(onDeviceUri), "Failed to read metadata.");
// Return existing metadata, or a new instance.
return existingMetadata.or(DownloadMetadata::create);
}
}
@Override
public WritableByteChannel openByteChannel(long byteOffset, DownloadMetadata metadata)
throws IOException {
// Ensure that metadata is not null
checkArgument(metadata != null, "Received null metadata to store");
// Check that offset is in range
long fileSize = numExistingBytes();
checkArgument(
byteOffset >= 0 && byteOffset <= fileSize,
"Offset for write (%s) out of range of existing file size (%s bytes)",
byteOffset,
fileSize);
synchronized (lock) {
// Update metadata first.
blockingGet(metadataStore.upsert(onDeviceUri, metadata), "Failed to update metadata.");
// Use ReleasableResource to ensure channel is setup properly before returning it.
try (ReleasableResource<RandomAccessFile> file =
ReleasableResource.create(
fileStorage.open(onDeviceUri, RandomAccessFileOpener.createForReadWrite()))) {
// Get channel and seek to correct offset.
FileChannel channel = file.get().getChannel();
channel.position(byteOffset);
// Release ownership -- caller is responsible for closing the channel.
file.release();
return channel;
}
}
}
@Override
public void clear() throws IOException {
synchronized (lock) {
// clear metadata and delete file.
blockingGet(metadataStore.delete(onDeviceUri), "Failed to clear metadata.");
fileStorage.deleteFile(onDeviceUri);
}
}
/**
* Helper method for async call error handling.
*
* <p>Exceptions due to an async call failure are handled and wrapped in an IOException.
*/
private static <V> V blockingGet(ListenableFuture<V> future, String errorMessage)
throws IOException {
try {
return future.get(TIMEOUT_MS, MILLISECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException(errorMessage, e.getCause());
} catch (ExecutionException e) {
throw new IOException(errorMessage, e.getCause());
} catch (TimeoutException | CancellationException e) {
throw new IOException(errorMessage, e);
}
}
}
private final DownloadMetadataStore metadataStore;
private DownloadDestinationOpener(DownloadMetadataStore metadataStore) {
this.metadataStore = metadataStore;
}
@Override
public DownloadDestination open(OpenContext openContext) throws IOException {
if (openContext.hasTransforms()) {
throw new UnsupportedFileStorageOperation(
"Transforms are not supported by this Opener: " + openContext.originalUri());
}
// Check whether or not the file uri is a directory.
if (openContext.storage().isDirectory(openContext.originalUri())) {
throw new IOException(
new IllegalArgumentException("Requested file download is already a directory."));
}
return new DownloadDestinationImpl(
openContext.originalUri(), openContext.storage(), metadataStore);
}
public static DownloadDestinationOpener create(DownloadMetadataStore metadataStore) {
return new DownloadDestinationOpener(metadataStore);
}
}