| /* |
| * 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); |
| } |
| } |