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
17package com.android.documentsui.archives;
18
19import android.content.Context;
20import android.net.Uri;
21import android.os.CancellationSignal;
22import android.os.OperationCanceledException;
23import android.os.ParcelFileDescriptor.AutoCloseOutputStream;
24import android.os.ParcelFileDescriptor;
25import android.provider.DocumentsContract.Document;
26import android.support.annotation.Nullable;
27import android.util.Log;
28
29import com.android.internal.annotations.GuardedBy;
30import android.support.annotation.VisibleForTesting;
31
32import libcore.io.IoUtils;
33
34import java.io.FileDescriptor;
35import java.io.FileNotFoundException;
36import java.io.FileOutputStream;
37import java.io.IOException;
38import java.io.InputStream;
39import java.util.ArrayList;
40import java.util.HashSet;
41import java.util.List;
42import java.util.Set;
43import java.util.concurrent.ExecutorService;
44import java.util.concurrent.Executors;
45import java.util.concurrent.RejectedExecutionException;
46import java.util.concurrent.TimeUnit;
47import java.util.zip.ZipEntry;
48import java.util.zip.ZipOutputStream;
49
50/**
51 * Provides basic implementation for creating archives.
52 *
53 * <p>This class is thread safe.
54 */
55public class WriteableArchive extends Archive {
56    private static final String TAG = "WriteableArchive";
57
58    @GuardedBy("mEntries")
59    private final Set<String> mPendingEntries = new HashSet<>();
60    private final ExecutorService mExecutor = Executors.newSingleThreadExecutor();
61    @GuardedBy("mEntries")
62    private final ZipOutputStream mZipOutputStream;
63    private final AutoCloseOutputStream mOutputStream;
64
65    /**
66     * Takes ownership of the passed file descriptor.
67     */
68    private WriteableArchive(
69            Context context,
70            ParcelFileDescriptor fd,
71            Uri archiveUri,
72            int accessMode,
73            @Nullable Uri notificationUri)
74            throws IOException {
75        super(context, archiveUri, accessMode, notificationUri);
76        if (!supportsAccessMode(accessMode)) {
77            throw new IllegalStateException("Unsupported access mode.");
78        }
79
80        addEntry(null /* no parent */, new ZipEntry("/"));  // Root entry.
81        mOutputStream = new AutoCloseOutputStream(fd);
82        mZipOutputStream = new ZipOutputStream(mOutputStream);
83    }
84
85    private void addEntry(@Nullable ZipEntry parentEntry, ZipEntry entry) {
86        final String entryPath = getEntryPath(entry);
87        synchronized (mEntries) {
88            if (entry.isDirectory()) {
89                if (!mTree.containsKey(entryPath)) {
90                    mTree.put(entryPath, new ArrayList<ZipEntry>());
91                }
92            }
93            mEntries.put(entryPath, entry);
94            if (parentEntry != null) {
95                mTree.get(getEntryPath(parentEntry)).add(entry);
96            }
97        }
98    }
99
100    /**
101     * @see ParcelFileDescriptor
102     */
103    public static boolean supportsAccessMode(int accessMode) {
104        return accessMode == ParcelFileDescriptor.MODE_WRITE_ONLY;
105    }
106
107    /**
108     * Creates a DocumentsArchive instance for writing into an archive file passed
109     * as a file descriptor.
110     *
111     * This method takes ownership for the passed descriptor. The caller must
112     * not use it after passing.
113     *
114     * @param context Context of the provider.
115     * @param descriptor File descriptor for the archive's contents.
116     * @param archiveUri Uri of the archive document.
117     * @param accessMode Access mode for the archive {@see ParcelFileDescriptor}.
118     * @param Uri notificationUri Uri for notifying that the archive file has changed.
119     */
120    @VisibleForTesting
121    public static WriteableArchive createForParcelFileDescriptor(
122            Context context, ParcelFileDescriptor descriptor, Uri archiveUri, int accessMode,
123            @Nullable Uri notificationUri)
124            throws IOException {
125        try {
126            return new WriteableArchive(context, descriptor, archiveUri, accessMode,
127                    notificationUri);
128        } catch (Exception e) {
129            // Since the method takes ownership of the passed descriptor, close it
130            // on exception.
131            IoUtils.closeQuietly(descriptor);
132            throw e;
133        }
134    }
135
136    @Override
137    @VisibleForTesting
138    public String createDocument(String parentDocumentId, String mimeType, String displayName)
139            throws FileNotFoundException {
140        final ArchiveId parsedParentId = ArchiveId.fromDocumentId(parentDocumentId);
141        MorePreconditions.checkArgumentEquals(mArchiveUri, parsedParentId.mArchiveUri,
142                "Mismatching archive Uri. Expected: %s, actual: %s.");
143
144        final boolean isDirectory = Document.MIME_TYPE_DIR.equals(mimeType);
145        ZipEntry entry;
146        String entryPath;
147
148        synchronized (mEntries) {
149            final ZipEntry parentEntry = mEntries.get(parsedParentId.mPath);
150
151            if (parentEntry == null) {
152                throw new FileNotFoundException();
153            }
154
155            if (displayName.indexOf("/") != -1 || ".".equals(displayName) || "..".equals(displayName)) {
156                throw new IllegalStateException("Display name contains invalid characters.");
157            }
158
159            if ("".equals(displayName)) {
160                throw new IllegalStateException("Display name cannot be empty.");
161            }
162
163
164            assert(parentEntry.getName().endsWith("/"));
165            final String parentName = "/".equals(parentEntry.getName()) ? "" : parentEntry.getName();
166            final String entryName = parentName + displayName + (isDirectory ? "/" : "");
167            entry = new ZipEntry(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.putNextEntry(entry);
189                }
190            } catch (IOException e) {
191                throw new IllegalStateException(
192                        "Failed to create a file in the archive: " + entryPath, e);
193            }
194        }
195
196        return createArchiveId(entryPath).toDocumentId();
197    }
198
199    @Override
200    public ParcelFileDescriptor openDocument(
201            String documentId, String mode, @Nullable final CancellationSignal signal)
202            throws FileNotFoundException {
203        MorePreconditions.checkArgumentEquals("w", mode,
204                "Invalid mode. Only writing \"w\" supported, but got: \"%s\".");
205        final ArchiveId parsedId = ArchiveId.fromDocumentId(documentId);
206        MorePreconditions.checkArgumentEquals(mArchiveUri, parsedId.mArchiveUri,
207                "Mismatching archive Uri. Expected: %s, actual: %s.");
208
209        final ZipEntry entry;
210        synchronized (mEntries) {
211            entry = mEntries.get(parsedId.mPath);
212            if (entry == null) {
213                throw new FileNotFoundException();
214            }
215
216            if (!mPendingEntries.contains(parsedId.mPath)) {
217                throw new IllegalStateException("Files can be written only once.");
218            }
219            mPendingEntries.remove(parsedId.mPath);
220        }
221
222        ParcelFileDescriptor[] pipe;
223        try {
224            pipe = ParcelFileDescriptor.createReliablePipe();
225        } catch (IOException e) {
226            // Ideally we'd simply throw IOException to the caller, but for consistency
227            // with DocumentsProvider::openDocument, converting it to IllegalStateException.
228            throw new IllegalStateException("Failed to open the document.", e);
229        }
230        final ParcelFileDescriptor inputPipe = pipe[0];
231
232        try {
233            mExecutor.execute(
234                    new Runnable() {
235                        @Override
236                        public void run() {
237                            try (final ParcelFileDescriptor.AutoCloseInputStream inputStream =
238                                    new ParcelFileDescriptor.AutoCloseInputStream(inputPipe)) {
239                                try {
240                                    synchronized (mEntries) {
241                                        mZipOutputStream.putNextEntry(entry);
242                                        final byte buffer[] = new byte[32 * 1024];
243                                        int bytes;
244                                        long size = 0;
245                                        while ((bytes = inputStream.read(buffer)) != -1) {
246                                            if (signal != null) {
247                                                signal.throwIfCanceled();
248                                            }
249                                            mZipOutputStream.write(buffer, 0, bytes);
250                                            size += bytes;
251                                        }
252                                        entry.setSize(size);
253                                        mZipOutputStream.closeEntry();
254                                    }
255                                } catch (IOException e) {
256                                    // Catch the exception before the outer try-with-resource closes
257                                    // the pipe with close() instead of closeWithError().
258                                    try {
259                                        Log.e(TAG, "Failed while writing to a file.", e);
260                                        inputPipe.closeWithError("Writing failure.");
261                                    } catch (IOException e2) {
262                                        Log.e(TAG, "Failed to close the pipe after an error.", e2);
263                                    }
264                                }
265                            } catch (OperationCanceledException e) {
266                                // Cancelled gracefully.
267                            } catch (IOException e) {
268                                // Input stream auto-close error. Close quietly.
269                            }
270                        }
271                    });
272        } catch (RejectedExecutionException e) {
273            IoUtils.closeQuietly(pipe[0]);
274            IoUtils.closeQuietly(pipe[1]);
275            throw new IllegalStateException("Failed to initialize pipe.");
276        }
277
278        return pipe[1];
279    }
280
281    /**
282     * Closes the archive. Blocks until all enqueued pipes are completed.
283     */
284    @Override
285    public void close() {
286        // Waits until all enqueued pipe requests are completed.
287        mExecutor.shutdown();
288        try {
289            final boolean result = mExecutor.awaitTermination(
290                    Long.MAX_VALUE, TimeUnit.MILLISECONDS);
291            assert(result);
292        } catch (InterruptedException e) {
293            Log.e(TAG, "Opened files failed to be fullly written.", e);
294        }
295
296        // Flush all pending entries. They will all have empty size.
297        synchronized (mEntries) {
298            for (final String path : mPendingEntries) {
299                try {
300                    mZipOutputStream.putNextEntry(mEntries.get(path));
301                    mZipOutputStream.closeEntry();
302                } catch (IOException e) {
303                    Log.e(TAG, "Failed to flush empty entries.", e);
304                }
305            }
306
307            try {
308                mZipOutputStream.close();
309            } catch (IOException e) {
310                Log.e(TAG, "Failed while closing the ZIP file.", e);
311            }
312        }
313
314        IoUtils.closeQuietly(mOutputStream);
315    }
316};
317