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