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.content.res.AssetFileDescriptor;
21import android.graphics.Point;
22import android.media.ExifInterface;
23import android.net.Uri;
24import android.os.Bundle;
25import android.os.CancellationSignal;
26import android.os.OperationCanceledException;
27import android.os.ParcelFileDescriptor;
28import android.os.storage.StorageManager;
29import android.provider.DocumentsContract;
30import android.support.annotation.Nullable;
31import android.util.Log;
32import android.util.jar.StrictJarFile;
33
34import com.android.internal.annotations.GuardedBy;
35import com.android.internal.util.Preconditions;
36
37import libcore.io.IoUtils;
38
39import java.io.File;
40import java.io.FileDescriptor;
41import java.io.FileNotFoundException;
42import java.io.FileOutputStream;
43import java.io.IOException;
44import java.io.InputStream;
45import java.util.ArrayList;
46import java.util.HashSet;
47import java.util.Iterator;
48import java.util.List;
49import java.util.Set;
50import java.util.Stack;
51import java.util.concurrent.TimeUnit;
52import java.util.zip.ZipEntry;
53
54/**
55 * Provides basic implementation for extracting and accessing
56 * files within archives exposed by a document provider.
57 *
58 * <p>This class is thread safe.
59 */
60public class ReadableArchive extends Archive {
61    private static final String TAG = "ReadableArchive";
62
63    private final StorageManager mStorageManager;
64    private final StrictJarFile mZipFile;
65
66    private ReadableArchive(
67            Context context,
68            @Nullable File file,
69            @Nullable FileDescriptor fd,
70            Uri archiveUri,
71            int accessMode,
72            @Nullable Uri notificationUri)
73            throws IOException {
74        super(context, archiveUri, accessMode, notificationUri);
75        if (!supportsAccessMode(accessMode)) {
76            throw new IllegalStateException("Unsupported access mode.");
77        }
78
79        mStorageManager = mContext.getSystemService(StorageManager.class);
80
81        mZipFile = file != null ?
82                new StrictJarFile(file.getPath(), false /* verify */,
83                        false /* signatures */) :
84                new StrictJarFile(fd, false /* verify */, false /* signatures */);
85
86        ZipEntry entry;
87        String entryPath;
88        final Iterator<ZipEntry> it = mZipFile.iterator();
89        final Stack<ZipEntry> stack = new Stack<>();
90        while (it.hasNext()) {
91            entry = it.next();
92            if (entry.isDirectory() != entry.getName().endsWith("/")) {
93                throw new IOException(
94                        "Directories must have a trailing slash, and files must not.");
95            }
96            entryPath = getEntryPath(entry);
97            if (mEntries.containsKey(entryPath)) {
98                throw new IOException("Multiple entries with the same name are not supported.");
99            }
100            mEntries.put(entryPath, entry);
101            if (entry.isDirectory()) {
102                mTree.put(entryPath, new ArrayList<ZipEntry>());
103            }
104            if (!"/".equals(entryPath)) { // Skip root, as it doesn't have a parent.
105                stack.push(entry);
106            }
107        }
108
109        int delimiterIndex;
110        String parentPath;
111        ZipEntry parentEntry;
112        List<ZipEntry> parentList;
113
114        // Go through all directories recursively and build a tree structure.
115        while (stack.size() > 0) {
116            entry = stack.pop();
117
118            entryPath = getEntryPath(entry);
119            delimiterIndex = entryPath.lastIndexOf('/', entry.isDirectory()
120                    ? entryPath.length() - 2 : entryPath.length() - 1);
121            parentPath = entryPath.substring(0, delimiterIndex) + "/";
122
123            parentList = mTree.get(parentPath);
124
125            if (parentList == null) {
126                // The ZIP file doesn't contain all directories leading to the entry.
127                // It's rare, but can happen in a valid ZIP archive. In such case create a
128                // fake ZipEntry and add it on top of the stack to process it next.
129                parentEntry = new ZipEntry(parentPath);
130                parentEntry.setSize(0);
131                parentEntry.setTime(entry.getTime());
132                mEntries.put(parentPath, parentEntry);
133
134                if (!"/".equals(parentPath)) {
135                    stack.push(parentEntry);
136                }
137
138                parentList = new ArrayList<>();
139                mTree.put(parentPath, parentList);
140            }
141
142            parentList.add(entry);
143        }
144    }
145
146    /**
147     * @see ParcelFileDescriptor
148     */
149    public static boolean supportsAccessMode(int accessMode) {
150        return accessMode == ParcelFileDescriptor.MODE_READ_ONLY;
151    }
152
153    /**
154     * Creates a DocumentsArchive instance for opening, browsing and accessing
155     * documents within the archive passed as a file descriptor.
156     *
157     * If the file descriptor is not seekable, then a snapshot will be created.
158     *
159     * This method takes ownership for the passed descriptor. The caller must
160     * not use it after passing.
161     *
162     * @param context Context of the provider.
163     * @param descriptor File descriptor for the archive's contents.
164     * @param archiveUri Uri of the archive document.
165     * @param accessMode Access mode for the archive {@see ParcelFileDescriptor}.
166     * @param Uri notificationUri Uri for notifying that the archive file has changed.
167     */
168    public static ReadableArchive createForParcelFileDescriptor(
169            Context context, ParcelFileDescriptor descriptor, Uri archiveUri, int accessMode,
170            @Nullable Uri notificationUri)
171            throws IOException {
172        FileDescriptor fd = null;
173        try {
174            if (canSeek(descriptor)) {
175                fd = new FileDescriptor();
176                fd.setInt$(descriptor.detachFd());
177                return new ReadableArchive(context, null, fd, archiveUri, accessMode,
178                        notificationUri);
179            }
180
181            // Fallback for non-seekable file descriptors.
182            File snapshotFile = null;
183            try {
184                // Create a copy of the archive, as ZipFile doesn't operate on streams.
185                // Moreover, ZipInputStream would be inefficient for large files on
186                // pipes.
187                snapshotFile = File.createTempFile("com.android.documentsui.snapshot{",
188                        "}.zip", context.getCacheDir());
189
190                try (
191                    final FileOutputStream outputStream =
192                            new ParcelFileDescriptor.AutoCloseOutputStream(
193                                    ParcelFileDescriptor.open(
194                                            snapshotFile, ParcelFileDescriptor.MODE_WRITE_ONLY));
195                    final ParcelFileDescriptor.AutoCloseInputStream inputStream =
196                            new ParcelFileDescriptor.AutoCloseInputStream(descriptor);
197                ) {
198                    final byte[] buffer = new byte[32 * 1024];
199                    int bytes;
200                    while ((bytes = inputStream.read(buffer)) != -1) {
201                        outputStream.write(buffer, 0, bytes);
202                    }
203                    outputStream.flush();
204                }
205                return new ReadableArchive(context, snapshotFile, null, archiveUri, accessMode,
206                        notificationUri);
207            } finally {
208                // On UNIX the file will be still available for processes which opened it, even
209                // after deleting it. Remove it ASAP, as it won't be used by anyone else.
210                if (snapshotFile != null) {
211                    snapshotFile.delete();
212                }
213            }
214        } catch (Exception e) {
215            // Since the method takes ownership of the passed descriptor, close it
216            // on exception.
217            IoUtils.closeQuietly(descriptor);
218            IoUtils.closeQuietly(fd);
219            throw e;
220        }
221    }
222
223    @Override
224    public ParcelFileDescriptor openDocument(
225            String documentId, String mode, @Nullable final CancellationSignal signal)
226            throws FileNotFoundException {
227        MorePreconditions.checkArgumentEquals("r", mode,
228                "Invalid mode. Only reading \"r\" supported, but got: \"%s\".");
229        final ArchiveId parsedId = ArchiveId.fromDocumentId(documentId);
230        MorePreconditions.checkArgumentEquals(mArchiveUri, parsedId.mArchiveUri,
231                "Mismatching archive Uri. Expected: %s, actual: %s.");
232
233        final ZipEntry entry = mEntries.get(parsedId.mPath);
234        if (entry == null) {
235            throw new FileNotFoundException();
236        }
237
238        try {
239            return mStorageManager.openProxyFileDescriptor(
240                    ParcelFileDescriptor.MODE_READ_ONLY, new Proxy(mZipFile, entry));
241        } catch (IOException e) {
242            throw new IllegalStateException(e);
243        }
244    }
245
246    @Override
247    public AssetFileDescriptor openDocumentThumbnail(
248            String documentId, Point sizeHint, final CancellationSignal signal)
249            throws FileNotFoundException {
250        final ArchiveId parsedId = ArchiveId.fromDocumentId(documentId);
251        MorePreconditions.checkArgumentEquals(mArchiveUri, parsedId.mArchiveUri,
252                "Mismatching archive Uri. Expected: %s, actual: %s.");
253        Preconditions.checkArgument(getDocumentType(documentId).startsWith("image/"),
254                "Thumbnails only supported for image/* MIME type.");
255
256        final ZipEntry entry = mEntries.get(parsedId.mPath);
257        if (entry == null) {
258            throw new FileNotFoundException();
259        }
260
261        InputStream inputStream = null;
262        try {
263            inputStream = mZipFile.getInputStream(entry);
264            final ExifInterface exif = new ExifInterface(inputStream);
265            if (exif.hasThumbnail()) {
266                Bundle extras = null;
267                switch (exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, -1)) {
268                    case ExifInterface.ORIENTATION_ROTATE_90:
269                        extras = new Bundle(1);
270                        extras.putInt(DocumentsContract.EXTRA_ORIENTATION, 90);
271                        break;
272                    case ExifInterface.ORIENTATION_ROTATE_180:
273                        extras = new Bundle(1);
274                        extras.putInt(DocumentsContract.EXTRA_ORIENTATION, 180);
275                        break;
276                    case ExifInterface.ORIENTATION_ROTATE_270:
277                        extras = new Bundle(1);
278                        extras.putInt(DocumentsContract.EXTRA_ORIENTATION, 270);
279                        break;
280                }
281                final long[] range = exif.getThumbnailRange();
282                return new AssetFileDescriptor(
283                        openDocument(documentId, "r", signal), range[0], range[1], extras);
284            }
285        } catch (IOException e) {
286            // Ignore the exception, as reading the EXIF may legally fail.
287            Log.e(TAG, "Failed to obtain thumbnail from EXIF.", e);
288        } finally {
289            IoUtils.closeQuietly(inputStream);
290        }
291
292        return new AssetFileDescriptor(
293                openDocument(documentId, "r", signal), 0, entry.getSize(), null);
294    }
295
296    /**
297     * Closes an archive.
298     *
299     * <p>This method does not block until shutdown. Once called, other methods should not be
300     * called. Any active pipes will be terminated.
301     */
302    @Override
303    public void close() {
304        try {
305            mZipFile.close();
306        } catch (IOException e) {
307            // Silent close.
308        }
309    }
310};
311