blob: 528e8e5a1e742ae79488ab1c01be2847ec10f808 [file] [log] [blame]
Tomasz Mikolajewskidc235d22017-01-25 15:07:31 +09001/*
2 * Copyright (C) 2017 The Android Open Source Project
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
17package com.android.documentsui.archives;
18
19import android.content.Context;
20import android.net.Uri;
21import android.os.CancellationSignal;
felkachang1556c362018-08-30 16:21:59 +080022import android.os.FileUtils;
Tomasz Mikolajewskidc235d22017-01-25 15:07:31 +090023import android.os.OperationCanceledException;
24import android.os.ParcelFileDescriptor;
felkachang1556c362018-08-30 16:21:59 +080025import android.os.ParcelFileDescriptor.AutoCloseOutputStream;
Tomasz Mikolajewskidc235d22017-01-25 15:07:31 +090026import android.provider.DocumentsContract.Document;
Tomasz Mikolajewskidc235d22017-01-25 15:07:31 +090027import android.util.Log;
28
Jeff Sharkeya4ff00f2018-07-09 14:57:51 -060029import androidx.annotation.GuardedBy;
felkachang1556c362018-08-30 16:21:59 +080030import androidx.annotation.Nullable;
KOUSHIK PANUGANTI6ca7acc2018-04-17 16:00:10 -070031import androidx.annotation.VisibleForTesting;
Tomasz Mikolajewskidc235d22017-01-25 15:07:31 +090032
Tomasz Mikolajewskidc235d22017-01-25 15:07:31 +090033import java.io.FileNotFoundException;
Tomasz Mikolajewskidc235d22017-01-25 15:07:31 +090034import java.io.IOException;
felkachang1556c362018-08-30 16:21:59 +080035
Tomasz Mikolajewskidc235d22017-01-25 15:07:31 +090036import java.util.ArrayList;
37import java.util.HashSet;
Tomasz Mikolajewskidc235d22017-01-25 15:07:31 +090038import java.util.Set;
39import java.util.concurrent.ExecutorService;
40import java.util.concurrent.Executors;
41import java.util.concurrent.RejectedExecutionException;
42import java.util.concurrent.TimeUnit;
Tomasz Mikolajewskidc235d22017-01-25 15:07:31 +090043import java.util.zip.ZipOutputStream;
44
felkachang1556c362018-08-30 16:21:59 +080045import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
46
Tomasz Mikolajewskidc235d22017-01-25 15:07:31 +090047/**
48 * Provides basic implementation for creating archives.
49 *
50 * <p>This class is thread safe.
51 */
52public class WriteableArchive extends Archive {
53 private static final String TAG = "WriteableArchive";
54
55 @GuardedBy("mEntries")
56 private final Set<String> mPendingEntries = new HashSet<>();
57 private final ExecutorService mExecutor = Executors.newSingleThreadExecutor();
58 @GuardedBy("mEntries")
59 private final ZipOutputStream mZipOutputStream;
Tomasz Mikolajewskia18ea7e2017-02-14 16:05:38 +090060 private final AutoCloseOutputStream mOutputStream;
Tomasz Mikolajewskidc235d22017-01-25 15:07:31 +090061
Tomasz Mikolajewskia18ea7e2017-02-14 16:05:38 +090062 /**
63 * Takes ownership of the passed file descriptor.
64 */
Tomasz Mikolajewskidc235d22017-01-25 15:07:31 +090065 private WriteableArchive(
66 Context context,
Tomasz Mikolajewskia18ea7e2017-02-14 16:05:38 +090067 ParcelFileDescriptor fd,
Tomasz Mikolajewskidc235d22017-01-25 15:07:31 +090068 Uri archiveUri,
69 int accessMode,
70 @Nullable Uri notificationUri)
71 throws IOException {
72 super(context, archiveUri, accessMode, notificationUri);
73 if (!supportsAccessMode(accessMode)) {
74 throw new IllegalStateException("Unsupported access mode.");
75 }
76
felkachang1556c362018-08-30 16:21:59 +080077 addEntry(null /* no parent */, new ZipArchiveEntry("/")); // Root entry.
Tomasz Mikolajewskia18ea7e2017-02-14 16:05:38 +090078 mOutputStream = new AutoCloseOutputStream(fd);
79 mZipOutputStream = new ZipOutputStream(mOutputStream);
Tomasz Mikolajewskidc235d22017-01-25 15:07:31 +090080 }
81
felkachang1556c362018-08-30 16:21:59 +080082 private void addEntry(@Nullable ZipArchiveEntry parentEntry, ZipArchiveEntry entry) {
Tomasz Mikolajewskidc235d22017-01-25 15:07:31 +090083 final String entryPath = getEntryPath(entry);
84 synchronized (mEntries) {
85 if (entry.isDirectory()) {
86 if (!mTree.containsKey(entryPath)) {
felkachang1556c362018-08-30 16:21:59 +080087 mTree.put(entryPath, new ArrayList<ZipArchiveEntry>());
Tomasz Mikolajewskidc235d22017-01-25 15:07:31 +090088 }
89 }
90 mEntries.put(entryPath, entry);
91 if (parentEntry != null) {
92 mTree.get(getEntryPath(parentEntry)).add(entry);
93 }
94 }
95 }
96
97 /**
98 * @see ParcelFileDescriptor
99 */
100 public static boolean supportsAccessMode(int accessMode) {
101 return accessMode == ParcelFileDescriptor.MODE_WRITE_ONLY;
102 }
103
104 /**
105 * Creates a DocumentsArchive instance for writing into an archive file passed
106 * as a file descriptor.
107 *
108 * This method takes ownership for the passed descriptor. The caller must
109 * not use it after passing.
110 *
111 * @param context Context of the provider.
112 * @param descriptor File descriptor for the archive's contents.
113 * @param archiveUri Uri of the archive document.
114 * @param accessMode Access mode for the archive {@see ParcelFileDescriptor}.
felkachang1556c362018-08-30 16:21:59 +0800115 * @param notificationUri notificationUri Uri for notifying that the archive file has changed.
Tomasz Mikolajewskidc235d22017-01-25 15:07:31 +0900116 */
117 @VisibleForTesting
118 public static WriteableArchive createForParcelFileDescriptor(
119 Context context, ParcelFileDescriptor descriptor, Uri archiveUri, int accessMode,
120 @Nullable Uri notificationUri)
121 throws IOException {
Tomasz Mikolajewskidc235d22017-01-25 15:07:31 +0900122 try {
Tomasz Mikolajewskia18ea7e2017-02-14 16:05:38 +0900123 return new WriteableArchive(context, descriptor, archiveUri, accessMode,
124 notificationUri);
Tomasz Mikolajewskidc235d22017-01-25 15:07:31 +0900125 } catch (Exception e) {
126 // Since the method takes ownership of the passed descriptor, close it
127 // on exception.
Jeff Sharkey94785ef2018-07-09 16:37:41 -0600128 FileUtils.closeQuietly(descriptor);
Tomasz Mikolajewskidc235d22017-01-25 15:07:31 +0900129 throw e;
130 }
131 }
132
133 @Override
134 @VisibleForTesting
135 public String createDocument(String parentDocumentId, String mimeType, String displayName)
136 throws FileNotFoundException {
137 final ArchiveId parsedParentId = ArchiveId.fromDocumentId(parentDocumentId);
138 MorePreconditions.checkArgumentEquals(mArchiveUri, parsedParentId.mArchiveUri,
139 "Mismatching archive Uri. Expected: %s, actual: %s.");
140
141 final boolean isDirectory = Document.MIME_TYPE_DIR.equals(mimeType);
felkachang1556c362018-08-30 16:21:59 +0800142 ZipArchiveEntry entry;
Tomasz Mikolajewskidc235d22017-01-25 15:07:31 +0900143 String entryPath;
144
145 synchronized (mEntries) {
felkachang1556c362018-08-30 16:21:59 +0800146 final ZipArchiveEntry parentEntry = mEntries.get(parsedParentId.mPath);
Tomasz Mikolajewskidc235d22017-01-25 15:07:31 +0900147
148 if (parentEntry == null) {
149 throw new FileNotFoundException();
150 }
151
felkachang1556c362018-08-30 16:21:59 +0800152 if (displayName.indexOf("/") != -1 || ".".equals(displayName)
153 || "..".equals(displayName)) {
Tomasz Mikolajewskidc235d22017-01-25 15:07:31 +0900154 throw new IllegalStateException("Display name contains invalid characters.");
155 }
156
157 if ("".equals(displayName)) {
158 throw new IllegalStateException("Display name cannot be empty.");
159 }
160
161
162 assert(parentEntry.getName().endsWith("/"));
felkachang1556c362018-08-30 16:21:59 +0800163 final String parentName = "/".equals(parentEntry.getName())
164 ? "" : parentEntry.getName();
Tomasz Mikolajewskidc235d22017-01-25 15:07:31 +0900165 final String entryName = parentName + displayName + (isDirectory ? "/" : "");
felkachang1556c362018-08-30 16:21:59 +0800166 entry = new ZipArchiveEntry(entryName);
Tomasz Mikolajewskidc235d22017-01-25 15:07:31 +0900167 entryPath = getEntryPath(entry);
168 entry.setSize(0);
169
170 if (mEntries.get(entryPath) != null) {
171 throw new IllegalStateException("The document already exist: " + entryPath);
172 }
173 addEntry(parentEntry, entry);
174 }
175
176 if (!isDirectory) {
177 // For files, the contents will be written via openDocument. Since the contents
178 // must be immediately followed by the contents, defer adding the header until
179 // openDocument. All pending entires which haven't been written will be added
180 // to the ZIP file in close().
181 synchronized (mEntries) {
182 mPendingEntries.add(entryPath);
183 }
184 } else {
185 try {
186 synchronized (mEntries) {
187 mZipOutputStream.putNextEntry(entry);
188 }
189 } catch (IOException e) {
190 throw new IllegalStateException(
191 "Failed to create a file in the archive: " + entryPath, e);
192 }
193 }
194
195 return createArchiveId(entryPath).toDocumentId();
196 }
197
198 @Override
199 public ParcelFileDescriptor openDocument(
200 String documentId, String mode, @Nullable final CancellationSignal signal)
201 throws FileNotFoundException {
202 MorePreconditions.checkArgumentEquals("w", mode,
203 "Invalid mode. Only writing \"w\" supported, but got: \"%s\".");
204 final ArchiveId parsedId = ArchiveId.fromDocumentId(documentId);
205 MorePreconditions.checkArgumentEquals(mArchiveUri, parsedId.mArchiveUri,
206 "Mismatching archive Uri. Expected: %s, actual: %s.");
207
felkachang1556c362018-08-30 16:21:59 +0800208 final ZipArchiveEntry entry;
Tomasz Mikolajewskidc235d22017-01-25 15:07:31 +0900209 synchronized (mEntries) {
210 entry = mEntries.get(parsedId.mPath);
211 if (entry == null) {
212 throw new FileNotFoundException();
213 }
214
215 if (!mPendingEntries.contains(parsedId.mPath)) {
216 throw new IllegalStateException("Files can be written only once.");
217 }
218 mPendingEntries.remove(parsedId.mPath);
219 }
220
221 ParcelFileDescriptor[] pipe;
222 try {
223 pipe = ParcelFileDescriptor.createReliablePipe();
224 } catch (IOException e) {
225 // Ideally we'd simply throw IOException to the caller, but for consistency
226 // with DocumentsProvider::openDocument, converting it to IllegalStateException.
227 throw new IllegalStateException("Failed to open the document.", e);
228 }
229 final ParcelFileDescriptor inputPipe = pipe[0];
230
231 try {
232 mExecutor.execute(
233 new Runnable() {
234 @Override
235 public void run() {
236 try (final ParcelFileDescriptor.AutoCloseInputStream inputStream =
237 new ParcelFileDescriptor.AutoCloseInputStream(inputPipe)) {
238 try {
239 synchronized (mEntries) {
240 mZipOutputStream.putNextEntry(entry);
241 final byte buffer[] = new byte[32 * 1024];
242 int bytes;
243 long size = 0;
244 while ((bytes = inputStream.read(buffer)) != -1) {
245 if (signal != null) {
246 signal.throwIfCanceled();
247 }
248 mZipOutputStream.write(buffer, 0, bytes);
249 size += bytes;
250 }
251 entry.setSize(size);
252 mZipOutputStream.closeEntry();
253 }
254 } catch (IOException e) {
255 // Catch the exception before the outer try-with-resource closes
256 // the pipe with close() instead of closeWithError().
257 try {
258 Log.e(TAG, "Failed while writing to a file.", e);
259 inputPipe.closeWithError("Writing failure.");
260 } catch (IOException e2) {
261 Log.e(TAG, "Failed to close the pipe after an error.", e2);
262 }
263 }
264 } catch (OperationCanceledException e) {
265 // Cancelled gracefully.
266 } catch (IOException e) {
267 // Input stream auto-close error. Close quietly.
268 }
269 }
270 });
271 } catch (RejectedExecutionException e) {
Jeff Sharkey94785ef2018-07-09 16:37:41 -0600272 FileUtils.closeQuietly(pipe[0]);
273 FileUtils.closeQuietly(pipe[1]);
Tomasz Mikolajewskidc235d22017-01-25 15:07:31 +0900274 throw new IllegalStateException("Failed to initialize pipe.");
275 }
276
277 return pipe[1];
278 }
279
280 /**
281 * Closes the archive. Blocks until all enqueued pipes are completed.
282 */
283 @Override
284 public void close() {
285 // Waits until all enqueued pipe requests are completed.
286 mExecutor.shutdown();
287 try {
288 final boolean result = mExecutor.awaitTermination(
289 Long.MAX_VALUE, TimeUnit.MILLISECONDS);
290 assert(result);
291 } catch (InterruptedException e) {
292 Log.e(TAG, "Opened files failed to be fullly written.", e);
293 }
294
295 // Flush all pending entries. They will all have empty size.
296 synchronized (mEntries) {
297 for (final String path : mPendingEntries) {
298 try {
299 mZipOutputStream.putNextEntry(mEntries.get(path));
300 mZipOutputStream.closeEntry();
301 } catch (IOException e) {
302 Log.e(TAG, "Failed to flush empty entries.", e);
303 }
304 }
305
306 try {
307 mZipOutputStream.close();
308 } catch (IOException e) {
309 Log.e(TAG, "Failed while closing the ZIP file.", e);
310 }
311 }
Tomasz Mikolajewskia18ea7e2017-02-14 16:05:38 +0900312
Jeff Sharkey94785ef2018-07-09 16:37:41 -0600313 FileUtils.closeQuietly(mOutputStream);
Tomasz Mikolajewskidc235d22017-01-25 15:07:31 +0900314 }
felkachang1556c362018-08-30 16:21:59 +0800315}