1 /*
2  * Copyright (C) 2015 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.content.res.AssetFileDescriptor;
21 import android.database.Cursor;
22 import android.database.MatrixCursor;
23 import android.graphics.Point;
24 import android.net.Uri;
25 import android.os.CancellationSignal;
26 import android.os.ParcelFileDescriptor;
27 import android.os.storage.StorageManager;
28 import android.provider.DocumentsContract;
29 import android.provider.DocumentsContract.Document;
30 import android.support.annotation.Nullable;
31 import android.system.ErrnoException;
32 import android.system.Os;
33 import android.system.OsConstants;
34 import android.text.TextUtils;
35 import android.webkit.MimeTypeMap;
36 
37 import com.android.internal.annotations.GuardedBy;
38 import com.android.internal.util.Preconditions;
39 
40 import java.io.Closeable;
41 import java.io.File;
42 import java.io.FileNotFoundException;
43 import java.util.HashMap;
44 import java.util.List;
45 import java.util.Locale;
46 import java.util.Map;
47 import java.util.concurrent.LinkedBlockingQueue;
48 import java.util.zip.ZipEntry;
49 
50 /**
51  * Provides basic implementation for creating, extracting and accessing
52  * files within archives exposed by a document provider.
53  *
54  * <p>This class is thread safe.
55  */
56 public abstract class Archive implements Closeable {
57     private static final String TAG = "Archive";
58 
59     public static final String[] DEFAULT_PROJECTION = new String[] {
60             Document.COLUMN_DOCUMENT_ID,
61             Document.COLUMN_DISPLAY_NAME,
62             Document.COLUMN_MIME_TYPE,
63             Document.COLUMN_SIZE,
64             Document.COLUMN_FLAGS
65     };
66 
67     final Context mContext;
68     final Uri mArchiveUri;
69     final int mAccessMode;
70     final Uri mNotificationUri;
71 
72     // The container as well as values are guarded by mEntries.
73     @GuardedBy("mEntries")
74     final Map<String, ZipEntry> mEntries;
75 
76     // The container as well as values and elements of values are guarded by mEntries.
77     @GuardedBy("mEntries")
78     final Map<String, List<ZipEntry>> mTree;
79 
Archive( Context context, Uri archiveUri, int accessMode, @Nullable Uri notificationUri)80     Archive(
81             Context context,
82             Uri archiveUri,
83             int accessMode,
84             @Nullable Uri notificationUri) {
85         mContext = context;
86         mArchiveUri = archiveUri;
87         mAccessMode = accessMode;
88         mNotificationUri = notificationUri;
89 
90         mTree = new HashMap<>();
91         mEntries = new HashMap<>();
92     }
93 
94     /**
95      * Returns a valid, normalized path for an entry.
96      */
getEntryPath(ZipEntry entry)97     public static String getEntryPath(ZipEntry entry) {
98         Preconditions.checkArgument(entry.isDirectory() == entry.getName().endsWith("/"),
99                 "Ill-formated ZIP-file.");
100         if (entry.getName().startsWith("/")) {
101             return entry.getName();
102         } else {
103             return "/" + entry.getName();
104         }
105     }
106 
107     /**
108      * Returns true if the file descriptor is seekable.
109      * @param descriptor File descriptor to check.
110      */
canSeek(ParcelFileDescriptor descriptor)111     public static boolean canSeek(ParcelFileDescriptor descriptor) {
112         try {
113             return Os.lseek(descriptor.getFileDescriptor(), 0,
114                     OsConstants.SEEK_CUR) == 0;
115         } catch (ErrnoException e) {
116             return false;
117         }
118     }
119 
120     /**
121      * Lists child documents of an archive or a directory within an
122      * archive. Must be called only for archives with supported mime type,
123      * or for documents within archives.
124      *
125      * @see DocumentsProvider.queryChildDocuments(String, String[], String)
126      */
queryChildDocuments(String documentId, @Nullable String[] projection, @Nullable String sortOrder)127     public Cursor queryChildDocuments(String documentId, @Nullable String[] projection,
128             @Nullable String sortOrder) throws FileNotFoundException {
129         final ArchiveId parsedParentId = ArchiveId.fromDocumentId(documentId);
130         MorePreconditions.checkArgumentEquals(mArchiveUri, parsedParentId.mArchiveUri,
131                 "Mismatching archive Uri. Expected: %s, actual: %s.");
132 
133         final MatrixCursor result = new MatrixCursor(
134                 projection != null ? projection : DEFAULT_PROJECTION);
135         if (mNotificationUri != null) {
136             result.setNotificationUri(mContext.getContentResolver(), mNotificationUri);
137         }
138 
139         synchronized (mEntries) {
140             final List<ZipEntry> parentList = mTree.get(parsedParentId.mPath);
141             if (parentList == null) {
142                 throw new FileNotFoundException();
143             }
144             for (final ZipEntry entry : parentList) {
145                 addCursorRow(result, entry);
146             }
147         }
148         return result;
149     }
150 
151     /**
152      * Returns a MIME type of a document within an archive.
153      *
154      * @see DocumentsProvider.getDocumentType(String)
155      */
getDocumentType(String documentId)156     public String getDocumentType(String documentId) throws FileNotFoundException {
157         final ArchiveId parsedId = ArchiveId.fromDocumentId(documentId);
158         MorePreconditions.checkArgumentEquals(mArchiveUri, parsedId.mArchiveUri,
159                 "Mismatching archive Uri. Expected: %s, actual: %s.");
160 
161         synchronized (mEntries) {
162             final ZipEntry entry = mEntries.get(parsedId.mPath);
163             if (entry == null) {
164                 throw new FileNotFoundException();
165             }
166             return getMimeTypeForEntry(entry);
167         }
168     }
169 
170     /**
171      * Returns true if a document within an archive is a child or any descendant of the archive
172      * document or another document within the archive.
173      *
174      * @see DocumentsProvider.isChildDocument(String, String)
175      */
isChildDocument(String parentDocumentId, String documentId)176     public boolean isChildDocument(String parentDocumentId, String documentId) {
177         final ArchiveId parsedParentId = ArchiveId.fromDocumentId(parentDocumentId);
178         final ArchiveId parsedId = ArchiveId.fromDocumentId(documentId);
179         MorePreconditions.checkArgumentEquals(mArchiveUri, parsedParentId.mArchiveUri,
180                 "Mismatching archive Uri. Expected: %s, actual: %s.");
181 
182         synchronized (mEntries) {
183             final ZipEntry entry = mEntries.get(parsedId.mPath);
184             if (entry == null) {
185                 return false;
186             }
187 
188             final ZipEntry parentEntry = mEntries.get(parsedParentId.mPath);
189             if (parentEntry == null || !parentEntry.isDirectory()) {
190                 return false;
191             }
192 
193             // Add a trailing slash even if it's not a directory, so it's easy to check if the
194             // entry is a descendant.
195             String pathWithSlash = entry.isDirectory() ? getEntryPath(entry)
196                     : getEntryPath(entry) + "/";
197 
198             return pathWithSlash.startsWith(parsedParentId.mPath) &&
199                     !parsedParentId.mPath.equals(pathWithSlash);
200         }
201     }
202 
203     /**
204      * Returns metadata of a document within an archive.
205      *
206      * @see DocumentsProvider.queryDocument(String, String[])
207      */
queryDocument(String documentId, @Nullable String[] projection)208     public Cursor queryDocument(String documentId, @Nullable String[] projection)
209             throws FileNotFoundException {
210         final ArchiveId parsedId = ArchiveId.fromDocumentId(documentId);
211         MorePreconditions.checkArgumentEquals(mArchiveUri, parsedId.mArchiveUri,
212                 "Mismatching archive Uri. Expected: %s, actual: %s.");
213 
214         synchronized (mEntries) {
215             final ZipEntry entry = mEntries.get(parsedId.mPath);
216             if (entry == null) {
217                 throw new FileNotFoundException();
218             }
219 
220             final MatrixCursor result = new MatrixCursor(
221                     projection != null ? projection : DEFAULT_PROJECTION);
222             if (mNotificationUri != null) {
223                 result.setNotificationUri(mContext.getContentResolver(), mNotificationUri);
224             }
225             addCursorRow(result, entry);
226             return result;
227         }
228     }
229 
230     /**
231      * Creates a file within an archive.
232      *
233      * @see DocumentsProvider.createDocument(String, String, String))
234      */
createDocument(String parentDocumentId, String mimeType, String displayName)235     public String createDocument(String parentDocumentId, String mimeType, String displayName)
236             throws FileNotFoundException {
237         throw new UnsupportedOperationException("Creating documents not supported.");
238     }
239 
240     /**
241      * Opens a file within an archive.
242      *
243      * @see DocumentsProvider.openDocument(String, String, CancellationSignal))
244      */
openDocument( String documentId, String mode, @Nullable final CancellationSignal signal)245     public ParcelFileDescriptor openDocument(
246             String documentId, String mode, @Nullable final CancellationSignal signal)
247             throws FileNotFoundException {
248         throw new UnsupportedOperationException("Opening not supported.");
249     }
250 
251     /**
252      * Opens a thumbnail of a file within an archive.
253      *
254      * @see DocumentsProvider.openDocumentThumbnail(String, Point, CancellationSignal))
255      */
openDocumentThumbnail( String documentId, Point sizeHint, final CancellationSignal signal)256     public AssetFileDescriptor openDocumentThumbnail(
257             String documentId, Point sizeHint, final CancellationSignal signal)
258             throws FileNotFoundException {
259         throw new UnsupportedOperationException("Thumbnails not supported.");
260     }
261 
262     /**
263      * Creates an archive id for the passed path.
264      */
createArchiveId(String path)265     public ArchiveId createArchiveId(String path) {
266         return new ArchiveId(mArchiveUri, mAccessMode, path);
267     }
268 
269     /**
270      * Not thread safe.
271      */
addCursorRow(MatrixCursor cursor, ZipEntry entry)272     void addCursorRow(MatrixCursor cursor, ZipEntry entry) {
273         final MatrixCursor.RowBuilder row = cursor.newRow();
274         final ArchiveId parsedId = createArchiveId(getEntryPath(entry));
275         row.add(Document.COLUMN_DOCUMENT_ID, parsedId.toDocumentId());
276 
277         final File file = new File(entry.getName());
278         row.add(Document.COLUMN_DISPLAY_NAME, file.getName());
279         row.add(Document.COLUMN_SIZE, entry.getSize());
280 
281         final String mimeType = getMimeTypeForEntry(entry);
282         row.add(Document.COLUMN_MIME_TYPE, mimeType);
283 
284         final int flags = mimeType.startsWith("image/") ? Document.FLAG_SUPPORTS_THUMBNAIL : 0;
285         row.add(Document.COLUMN_FLAGS, flags);
286     }
287 
getMimeTypeForEntry(ZipEntry entry)288     static String getMimeTypeForEntry(ZipEntry entry) {
289         if (entry.isDirectory()) {
290             return Document.MIME_TYPE_DIR;
291         }
292 
293         final int lastDot = entry.getName().lastIndexOf('.');
294         if (lastDot >= 0) {
295             final String extension = entry.getName().substring(lastDot + 1).toLowerCase(Locale.US);
296             final String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
297             if (mimeType != null) {
298                 return mimeType;
299             }
300         }
301 
302         return "application/octet-stream";
303     }
304 
305     // TODO: Upstream to the Preconditions class.
306     // TODO: Move to a separate file.
307     public static class MorePreconditions {
checkArgumentEquals(String expected, @Nullable String actual, String message)308         static void checkArgumentEquals(String expected, @Nullable String actual,
309                 String message) {
310             if (!TextUtils.equals(expected, actual)) {
311                 throw new IllegalArgumentException(String.format(message,
312                         String.valueOf(expected), String.valueOf(actual)));
313             }
314         }
315 
checkArgumentEquals(Uri expected, @Nullable Uri actual, String message)316         static void checkArgumentEquals(Uri expected, @Nullable Uri actual,
317                 String message) {
318             checkArgumentEquals(expected.toString(), actual.toString(), message);
319         }
320     }
321 };
322