1 /*
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 
17 package com.android.documentsui.archives;
18 
19 import android.content.Context;
20 import android.net.Uri;
21 import android.os.CancellationSignal;
22 import android.os.FileUtils;
23 import android.os.OperationCanceledException;
24 import android.os.ParcelFileDescriptor;
25 import android.os.ParcelFileDescriptor.AutoCloseOutputStream;
26 import android.provider.DocumentsContract.Document;
27 import android.util.Log;
28 
29 import androidx.annotation.GuardedBy;
30 import androidx.annotation.Nullable;
31 import androidx.annotation.VisibleForTesting;
32 
33 import java.io.FileNotFoundException;
34 import java.io.IOException;
35 
36 import java.util.ArrayList;
37 import java.util.HashSet;
38 import java.util.Set;
39 import java.util.concurrent.ExecutorService;
40 import java.util.concurrent.Executors;
41 import java.util.concurrent.RejectedExecutionException;
42 import java.util.concurrent.TimeUnit;
43 
44 import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
45 import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream;
46 
47 /**
48  * Provides basic implementation for creating archives.
49  *
50  * <p>This class is thread safe.
51  */
52 public 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 ZipArchiveOutputStream mZipOutputStream;
60     private final AutoCloseOutputStream mOutputStream;
61 
62     /**
63      * Takes ownership of the passed file descriptor.
64      */
WriteableArchive( Context context, ParcelFileDescriptor fd, Uri archiveUri, int accessMode, @Nullable Uri notificationUri)65     private WriteableArchive(
66             Context context,
67             ParcelFileDescriptor fd,
68             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 
77         addEntry(null /* no parent */, new ZipArchiveEntry("/"));  // Root entry.
78         mOutputStream = new AutoCloseOutputStream(fd);
79         mZipOutputStream = new ZipArchiveOutputStream(mOutputStream);
80     }
81 
addEntry(@ullable ZipArchiveEntry parentEntry, ZipArchiveEntry entry)82     private void addEntry(@Nullable ZipArchiveEntry parentEntry, ZipArchiveEntry entry) {
83         final String entryPath = getEntryPath(entry);
84         synchronized (mEntries) {
85             if (entry.isDirectory()) {
86                 if (!mTree.containsKey(entryPath)) {
87                     mTree.put(entryPath, new ArrayList<>());
88                 }
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      */
supportsAccessMode(int accessMode)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}.
115      * @param notificationUri notificationUri Uri for notifying that the archive file has changed.
116      */
117     @VisibleForTesting
createForParcelFileDescriptor( Context context, ParcelFileDescriptor descriptor, Uri archiveUri, int accessMode, @Nullable Uri notificationUri)118     public static WriteableArchive createForParcelFileDescriptor(
119             Context context, ParcelFileDescriptor descriptor, Uri archiveUri, int accessMode,
120             @Nullable Uri notificationUri)
121             throws IOException {
122         try {
123             return new WriteableArchive(context, descriptor, archiveUri, accessMode,
124                     notificationUri);
125         } catch (Exception e) {
126             // Since the method takes ownership of the passed descriptor, close it
127             // on exception.
128             FileUtils.closeQuietly(descriptor);
129             throw e;
130         }
131     }
132 
133     @Override
134     @VisibleForTesting
createDocument(String parentDocumentId, String mimeType, String displayName)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);
142         ZipArchiveEntry entry;
143         String entryPath;
144 
145         synchronized (mEntries) {
146             final ZipArchiveEntry parentEntry =
147                     (ZipArchiveEntry) mEntries.get(parsedParentId.mPath);
148 
149             if (parentEntry == null) {
150                 throw new FileNotFoundException();
151             }
152 
153             if (displayName.indexOf("/") != -1 || ".".equals(displayName)
154                     || "..".equals(displayName)) {
155                 throw new IllegalStateException("Display name contains invalid characters.");
156             }
157 
158             if ("".equals(displayName)) {
159                 throw new IllegalStateException("Display name cannot be empty.");
160             }
161 
162 
163             assert(parentEntry.getName().endsWith("/"));
164             final String parentName = "/".equals(parentEntry.getName())
165                     ? "" : parentEntry.getName();
166             final String entryName = parentName + displayName + (isDirectory ? "/" : "");
167             entry = new ZipArchiveEntry(entryName);
168             entryPath = getEntryPath(entry);
169             entry.setSize(0);
170 
171             if (mEntries.get(entryPath) != null) {
172                 throw new IllegalStateException("The document already exist: " + entryPath);
173             }
174             addEntry(parentEntry, entry);
175         }
176 
177         if (!isDirectory) {
178             // For files, the contents will be written via openDocument. Since the contents
179             // must be immediately followed by the contents, defer adding the header until
180             // openDocument. All pending entires which haven't been written will be added
181             // to the ZIP file in close().
182             synchronized (mEntries) {
183                 mPendingEntries.add(entryPath);
184             }
185         } else {
186             try {
187                 synchronized (mEntries) {
188                     mZipOutputStream.putArchiveEntry(entry);
189                     mZipOutputStream.closeArchiveEntry();
190                 }
191             } catch (IOException e) {
192                 throw new IllegalStateException(
193                         "Failed to create a file in the archive: " + entryPath, e);
194             }
195         }
196 
197         return createArchiveId(entryPath).toDocumentId();
198     }
199 
200     @Override
openDocument( String documentId, String mode, @Nullable final CancellationSignal signal)201     public ParcelFileDescriptor openDocument(
202             String documentId, String mode, @Nullable final CancellationSignal signal)
203             throws FileNotFoundException {
204         MorePreconditions.checkArgumentEquals("w", mode,
205                 "Invalid mode. Only writing \"w\" supported, but got: \"%s\".");
206         final ArchiveId parsedId = ArchiveId.fromDocumentId(documentId);
207         MorePreconditions.checkArgumentEquals(mArchiveUri, parsedId.mArchiveUri,
208                 "Mismatching archive Uri. Expected: %s, actual: %s.");
209 
210         final ZipArchiveEntry entry;
211         synchronized (mEntries) {
212             entry = (ZipArchiveEntry) mEntries.get(parsedId.mPath);
213             if (entry == null) {
214                 throw new FileNotFoundException();
215             }
216 
217             if (!mPendingEntries.contains(parsedId.mPath)) {
218                 throw new IllegalStateException("Files can be written only once.");
219             }
220             mPendingEntries.remove(parsedId.mPath);
221         }
222 
223         ParcelFileDescriptor[] pipe;
224         try {
225             pipe = ParcelFileDescriptor.createReliablePipe();
226         } catch (IOException e) {
227             // Ideally we'd simply throw IOException to the caller, but for consistency
228             // with DocumentsProvider::openDocument, converting it to IllegalStateException.
229             throw new IllegalStateException("Failed to open the document.", e);
230         }
231         final ParcelFileDescriptor inputPipe = pipe[0];
232 
233         try {
234             mExecutor.execute(
235                     new Runnable() {
236                         @Override
237                         public void run() {
238                             try (final ParcelFileDescriptor.AutoCloseInputStream inputStream =
239                                     new ParcelFileDescriptor.AutoCloseInputStream(inputPipe)) {
240                                 try {
241                                     synchronized (mEntries) {
242                                         mZipOutputStream.putArchiveEntry(entry);
243                                         final byte buffer[] = new byte[32 * 1024];
244                                         int bytes;
245                                         long size = 0;
246                                         while ((bytes = inputStream.read(buffer)) != -1) {
247                                             if (signal != null) {
248                                                 signal.throwIfCanceled();
249                                             }
250                                             mZipOutputStream.write(buffer, 0, bytes);
251                                             size += bytes;
252                                         }
253                                         entry.setSize(size);
254                                         mZipOutputStream.closeArchiveEntry();
255                                     }
256                                 } catch (IOException e) {
257                                     // Catch the exception before the outer try-with-resource closes
258                                     // the pipe with close() instead of closeWithError().
259                                     try {
260                                         Log.e(TAG, "Failed while writing to a file.", e);
261                                         inputPipe.closeWithError("Writing failure.");
262                                     } catch (IOException e2) {
263                                         Log.e(TAG, "Failed to close the pipe after an error.", e2);
264                                     }
265                                 }
266                             } catch (OperationCanceledException e) {
267                                 // Cancelled gracefully.
268                             } catch (IOException e) {
269                                 // Input stream auto-close error. Close quietly.
270                             }
271                         }
272                     });
273         } catch (RejectedExecutionException e) {
274             FileUtils.closeQuietly(pipe[0]);
275             FileUtils.closeQuietly(pipe[1]);
276             throw new IllegalStateException("Failed to initialize pipe.");
277         }
278 
279         return pipe[1];
280     }
281 
282     /**
283      * Closes the archive. Blocks until all enqueued pipes are completed.
284      */
285     @Override
close()286     public void close() {
287         // Waits until all enqueued pipe requests are completed.
288         mExecutor.shutdown();
289         try {
290             final boolean result = mExecutor.awaitTermination(
291                     Long.MAX_VALUE, TimeUnit.MILLISECONDS);
292             assert(result);
293         } catch (InterruptedException e) {
294             Log.e(TAG, "Opened files failed to be fullly written.", e);
295         }
296 
297         // Flush all pending entries. They will all have empty size.
298         synchronized (mEntries) {
299             for (final String path : mPendingEntries) {
300                 try {
301                     mZipOutputStream.putArchiveEntry(mEntries.get(path));
302                     mZipOutputStream.closeArchiveEntry();
303                 } catch (IOException e) {
304                     Log.e(TAG, "Failed to flush empty entries.", e);
305                 }
306             }
307 
308             try {
309                 mZipOutputStream.close();
310             } catch (IOException e) {
311                 Log.e(TAG, "Failed while closing the ZIP file.", e);
312             }
313         }
314 
315         FileUtils.closeQuietly(mOutputStream);
316     }
317 }
318