Tomasz Mikolajewski | dbd6b8b | 2016-09-29 15:27:37 +0900 | [diff] [blame] | 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; |
Tomasz Mikolajewski | dbd6b8b | 2016-09-29 15:27:37 +0900 | [diff] [blame] | 24 | import android.net.Uri; |
Tomasz Mikolajewski | dbd6b8b | 2016-09-29 15:27:37 +0900 | [diff] [blame] | 25 | import android.os.CancellationSignal; |
Tomasz Mikolajewski | dbd6b8b | 2016-09-29 15:27:37 +0900 | [diff] [blame] | 26 | import android.os.ParcelFileDescriptor; |
Tomasz Mikolajewski | dbd6b8b | 2016-09-29 15:27:37 +0900 | [diff] [blame] | 27 | import android.provider.DocumentsContract.Document; |
Tomasz Mikolajewski | dbd6b8b | 2016-09-29 15:27:37 +0900 | [diff] [blame] | 28 | import android.support.annotation.Nullable; |
Steve McKay | ccc18de | 2016-10-19 11:12:42 -0700 | [diff] [blame] | 29 | import android.system.ErrnoException; |
| 30 | import android.system.Os; |
| 31 | import android.system.OsConstants; |
| 32 | import android.text.TextUtils; |
Tomasz Mikolajewski | dbd6b8b | 2016-09-29 15:27:37 +0900 | [diff] [blame] | 33 | import android.webkit.MimeTypeMap; |
| 34 | |
Steve McKay | ccc18de | 2016-10-19 11:12:42 -0700 | [diff] [blame] | 35 | import com.android.internal.util.Preconditions; |
Tomasz Mikolajewski | 977cf48 | 2016-10-12 14:51:39 +0900 | [diff] [blame] | 36 | |
Tomasz Mikolajewski | dbd6b8b | 2016-09-29 15:27:37 +0900 | [diff] [blame] | 37 | import java.io.Closeable; |
| 38 | import java.io.File; |
| 39 | import java.io.FileNotFoundException; |
Tomasz Mikolajewski | dbd6b8b | 2016-09-29 15:27:37 +0900 | [diff] [blame] | 40 | import java.util.HashMap; |
Tomasz Mikolajewski | dbd6b8b | 2016-09-29 15:27:37 +0900 | [diff] [blame] | 41 | import java.util.List; |
| 42 | import java.util.Locale; |
| 43 | import java.util.Map; |
Tomasz Mikolajewski | b33955b | 2016-11-30 16:08:04 +0900 | [diff] [blame] | 44 | import java.util.concurrent.LinkedBlockingQueue; |
| 45 | import java.util.concurrent.ThreadPoolExecutor; |
| 46 | import java.util.concurrent.TimeUnit; |
Tomasz Mikolajewski | dbd6b8b | 2016-09-29 15:27:37 +0900 | [diff] [blame] | 47 | import java.util.zip.ZipEntry; |
Tomasz Mikolajewski | dbd6b8b | 2016-09-29 15:27:37 +0900 | [diff] [blame] | 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 | */ |
Tomasz Mikolajewski | d683f97 | 2017-01-24 18:54:42 +0900 | [diff] [blame^] | 55 | public abstract class Archive implements Closeable { |
Tomasz Mikolajewski | dbd6b8b | 2016-09-29 15:27:37 +0900 | [diff] [blame] | 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 | |
Tomasz Mikolajewski | d683f97 | 2017-01-24 18:54:42 +0900 | [diff] [blame^] | 66 | final Context mContext; |
| 67 | final Uri mArchiveUri; |
| 68 | final int mArchiveMode; |
| 69 | final Uri mNotificationUri; |
| 70 | final ThreadPoolExecutor mExecutor; |
| 71 | final Map<String, ZipEntry> mEntries; |
| 72 | final Map<String, List<ZipEntry>> mTree; |
Tomasz Mikolajewski | dbd6b8b | 2016-09-29 15:27:37 +0900 | [diff] [blame] | 73 | |
Tomasz Mikolajewski | d683f97 | 2017-01-24 18:54:42 +0900 | [diff] [blame^] | 74 | Archive( |
Tomasz Mikolajewski | dbd6b8b | 2016-09-29 15:27:37 +0900 | [diff] [blame] | 75 | Context context, |
Tomasz Mikolajewski | dbd6b8b | 2016-09-29 15:27:37 +0900 | [diff] [blame] | 76 | Uri archiveUri, |
Tomasz Mikolajewski | d683f97 | 2017-01-24 18:54:42 +0900 | [diff] [blame^] | 77 | int archiveMode, |
| 78 | @Nullable Uri notificationUri) { |
Tomasz Mikolajewski | dbd6b8b | 2016-09-29 15:27:37 +0900 | [diff] [blame] | 79 | mContext = context; |
| 80 | mArchiveUri = archiveUri; |
Tomasz Mikolajewski | d683f97 | 2017-01-24 18:54:42 +0900 | [diff] [blame^] | 81 | mArchiveMode = archiveMode; |
Tomasz Mikolajewski | dbd6b8b | 2016-09-29 15:27:37 +0900 | [diff] [blame] | 82 | mNotificationUri = notificationUri; |
Tomasz Mikolajewski | b33955b | 2016-11-30 16:08:04 +0900 | [diff] [blame] | 83 | |
| 84 | // At most 8 active threads. All threads idling for more than a minute will |
| 85 | // be closed. |
| 86 | mExecutor = new ThreadPoolExecutor(8, 8, 60, TimeUnit.SECONDS, |
Tomasz Mikolajewski | e1fcc69 | 2016-12-02 10:29:38 +0900 | [diff] [blame] | 87 | new LinkedBlockingQueue<Runnable>()); |
Tomasz Mikolajewski | b33955b | 2016-11-30 16:08:04 +0900 | [diff] [blame] | 88 | mExecutor.allowCoreThreadTimeOut(true); |
Tomasz Mikolajewski | dbd6b8b | 2016-09-29 15:27:37 +0900 | [diff] [blame] | 89 | |
Steve McKay | ccc18de | 2016-10-19 11:12:42 -0700 | [diff] [blame] | 90 | mTree = new HashMap<>(); |
Steve McKay | ccc18de | 2016-10-19 11:12:42 -0700 | [diff] [blame] | 91 | mEntries = new HashMap<>(); |
Tomasz Mikolajewski | dbd6b8b | 2016-09-29 15:27:37 +0900 | [diff] [blame] | 92 | } |
| 93 | |
| 94 | /** |
Tomasz Mikolajewski | 24c29fc | 2016-10-17 10:34:53 +0900 | [diff] [blame] | 95 | * Returns a valid, normalized path for an entry. |
| 96 | */ |
| 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 | /** |
Tomasz Mikolajewski | 977cf48 | 2016-10-12 14:51:39 +0900 | [diff] [blame] | 108 | * Returns true if the file descriptor is seekable. |
| 109 | * @param descriptor File descriptor to check. |
| 110 | */ |
| 111 | public static boolean canSeek(ParcelFileDescriptor descriptor) { |
| 112 | try { |
| 113 | return Os.lseek(descriptor.getFileDescriptor(), 0, |
Daichi Hirono | fefc600 | 2016-12-15 12:36:00 +0900 | [diff] [blame] | 114 | OsConstants.SEEK_CUR) == 0; |
Tomasz Mikolajewski | 977cf48 | 2016-10-12 14:51:39 +0900 | [diff] [blame] | 115 | } catch (ErrnoException e) { |
| 116 | return false; |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | /** |
Tomasz Mikolajewski | dbd6b8b | 2016-09-29 15:27:37 +0900 | [diff] [blame] | 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 | */ |
| 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 | final List<ZipEntry> parentList = mTree.get(parsedParentId.mPath); |
| 140 | if (parentList == null) { |
| 141 | throw new FileNotFoundException(); |
| 142 | } |
| 143 | for (final ZipEntry entry : parentList) { |
| 144 | addCursorRow(result, entry); |
| 145 | } |
| 146 | return result; |
| 147 | } |
| 148 | |
| 149 | /** |
| 150 | * Returns a MIME type of a document within an archive. |
| 151 | * |
| 152 | * @see DocumentsProvider.getDocumentType(String) |
| 153 | */ |
| 154 | public String getDocumentType(String documentId) throws FileNotFoundException { |
| 155 | final ArchiveId parsedId = ArchiveId.fromDocumentId(documentId); |
| 156 | MorePreconditions.checkArgumentEquals(mArchiveUri, parsedId.mArchiveUri, |
| 157 | "Mismatching archive Uri. Expected: %s, actual: %s."); |
| 158 | |
| 159 | final ZipEntry entry = mEntries.get(parsedId.mPath); |
| 160 | if (entry == null) { |
| 161 | throw new FileNotFoundException(); |
| 162 | } |
| 163 | return getMimeTypeForEntry(entry); |
| 164 | } |
| 165 | |
| 166 | /** |
| 167 | * Returns true if a document within an archive is a child or any descendant of the archive |
| 168 | * document or another document within the archive. |
| 169 | * |
| 170 | * @see DocumentsProvider.isChildDocument(String, String) |
| 171 | */ |
| 172 | public boolean isChildDocument(String parentDocumentId, String documentId) { |
| 173 | final ArchiveId parsedParentId = ArchiveId.fromDocumentId(parentDocumentId); |
| 174 | final ArchiveId parsedId = ArchiveId.fromDocumentId(documentId); |
| 175 | MorePreconditions.checkArgumentEquals(mArchiveUri, parsedParentId.mArchiveUri, |
| 176 | "Mismatching archive Uri. Expected: %s, actual: %s."); |
| 177 | |
| 178 | final ZipEntry entry = mEntries.get(parsedId.mPath); |
| 179 | if (entry == null) { |
| 180 | return false; |
| 181 | } |
| 182 | |
Tomasz Mikolajewski | dbd6b8b | 2016-09-29 15:27:37 +0900 | [diff] [blame] | 183 | final ZipEntry parentEntry = mEntries.get(parsedParentId.mPath); |
| 184 | if (parentEntry == null || !parentEntry.isDirectory()) { |
| 185 | return false; |
| 186 | } |
| 187 | |
Tomasz Mikolajewski | dbd6b8b | 2016-09-29 15:27:37 +0900 | [diff] [blame] | 188 | // Add a trailing slash even if it's not a directory, so it's easy to check if the |
| 189 | // entry is a descendant. |
Tomasz Mikolajewski | 24c29fc | 2016-10-17 10:34:53 +0900 | [diff] [blame] | 190 | String pathWithSlash = entry.isDirectory() ? getEntryPath(entry) |
| 191 | : getEntryPath(entry) + "/"; |
| 192 | |
| 193 | return pathWithSlash.startsWith(parsedParentId.mPath) && |
| 194 | !parsedParentId.mPath.equals(pathWithSlash); |
Tomasz Mikolajewski | dbd6b8b | 2016-09-29 15:27:37 +0900 | [diff] [blame] | 195 | } |
| 196 | |
| 197 | /** |
| 198 | * Returns metadata of a document within an archive. |
| 199 | * |
| 200 | * @see DocumentsProvider.queryDocument(String, String[]) |
| 201 | */ |
| 202 | public Cursor queryDocument(String documentId, @Nullable String[] projection) |
| 203 | throws FileNotFoundException { |
| 204 | final ArchiveId parsedId = ArchiveId.fromDocumentId(documentId); |
| 205 | MorePreconditions.checkArgumentEquals(mArchiveUri, parsedId.mArchiveUri, |
| 206 | "Mismatching archive Uri. Expected: %s, actual: %s."); |
| 207 | |
| 208 | final ZipEntry entry = mEntries.get(parsedId.mPath); |
| 209 | if (entry == null) { |
| 210 | throw new FileNotFoundException(); |
| 211 | } |
| 212 | |
| 213 | final MatrixCursor result = new MatrixCursor( |
| 214 | projection != null ? projection : DEFAULT_PROJECTION); |
| 215 | if (mNotificationUri != null) { |
| 216 | result.setNotificationUri(mContext.getContentResolver(), mNotificationUri); |
| 217 | } |
| 218 | addCursorRow(result, entry); |
| 219 | return result; |
| 220 | } |
| 221 | |
| 222 | /** |
| 223 | * Opens a file within an archive. |
| 224 | * |
| 225 | * @see DocumentsProvider.openDocument(String, String, CancellationSignal)) |
| 226 | */ |
Tomasz Mikolajewski | d683f97 | 2017-01-24 18:54:42 +0900 | [diff] [blame^] | 227 | abstract public ParcelFileDescriptor openDocument( |
Tomasz Mikolajewski | dbd6b8b | 2016-09-29 15:27:37 +0900 | [diff] [blame] | 228 | String documentId, String mode, @Nullable final CancellationSignal signal) |
Tomasz Mikolajewski | d683f97 | 2017-01-24 18:54:42 +0900 | [diff] [blame^] | 229 | throws FileNotFoundException; |
Tomasz Mikolajewski | dbd6b8b | 2016-09-29 15:27:37 +0900 | [diff] [blame] | 230 | |
| 231 | /** |
| 232 | * Opens a thumbnail of a file within an archive. |
| 233 | * |
| 234 | * @see DocumentsProvider.openDocumentThumbnail(String, Point, CancellationSignal)) |
| 235 | */ |
Tomasz Mikolajewski | d683f97 | 2017-01-24 18:54:42 +0900 | [diff] [blame^] | 236 | abstract public AssetFileDescriptor openDocumentThumbnail( |
Tomasz Mikolajewski | dbd6b8b | 2016-09-29 15:27:37 +0900 | [diff] [blame] | 237 | String documentId, Point sizeHint, final CancellationSignal signal) |
Tomasz Mikolajewski | d683f97 | 2017-01-24 18:54:42 +0900 | [diff] [blame^] | 238 | throws FileNotFoundException; |
Tomasz Mikolajewski | dbd6b8b | 2016-09-29 15:27:37 +0900 | [diff] [blame] | 239 | |
Tomasz Mikolajewski | d683f97 | 2017-01-24 18:54:42 +0900 | [diff] [blame^] | 240 | /** |
| 241 | * Creates an archive id for the passed path. |
| 242 | */ |
| 243 | public ArchiveId createArchiveId(String path) { |
| 244 | return new ArchiveId(mArchiveUri, mArchiveMode, path); |
Tomasz Mikolajewski | dbd6b8b | 2016-09-29 15:27:37 +0900 | [diff] [blame] | 245 | } |
| 246 | |
| 247 | /** |
Tomasz Mikolajewski | b33955b | 2016-11-30 16:08:04 +0900 | [diff] [blame] | 248 | * Closes an archive. |
Tomasz Mikolajewski | dbd6b8b | 2016-09-29 15:27:37 +0900 | [diff] [blame] | 249 | * |
| 250 | * <p>This method does not block until shutdown. Once called, other methods should not be |
Tomasz Mikolajewski | b33955b | 2016-11-30 16:08:04 +0900 | [diff] [blame] | 251 | * called. Any active pipes will be terminated. |
Tomasz Mikolajewski | dbd6b8b | 2016-09-29 15:27:37 +0900 | [diff] [blame] | 252 | */ |
| 253 | @Override |
| 254 | public void close() { |
Tomasz Mikolajewski | b33955b | 2016-11-30 16:08:04 +0900 | [diff] [blame] | 255 | mExecutor.shutdownNow(); |
Tomasz Mikolajewski | dbd6b8b | 2016-09-29 15:27:37 +0900 | [diff] [blame] | 256 | } |
| 257 | |
Tomasz Mikolajewski | d683f97 | 2017-01-24 18:54:42 +0900 | [diff] [blame^] | 258 | void addCursorRow(MatrixCursor cursor, ZipEntry entry) { |
Tomasz Mikolajewski | dbd6b8b | 2016-09-29 15:27:37 +0900 | [diff] [blame] | 259 | final MatrixCursor.RowBuilder row = cursor.newRow(); |
Tomasz Mikolajewski | d683f97 | 2017-01-24 18:54:42 +0900 | [diff] [blame^] | 260 | final ArchiveId parsedId = createArchiveId(getEntryPath(entry)); |
Tomasz Mikolajewski | dbd6b8b | 2016-09-29 15:27:37 +0900 | [diff] [blame] | 261 | row.add(Document.COLUMN_DOCUMENT_ID, parsedId.toDocumentId()); |
| 262 | |
| 263 | final File file = new File(entry.getName()); |
| 264 | row.add(Document.COLUMN_DISPLAY_NAME, file.getName()); |
| 265 | row.add(Document.COLUMN_SIZE, entry.getSize()); |
| 266 | |
| 267 | final String mimeType = getMimeTypeForEntry(entry); |
| 268 | row.add(Document.COLUMN_MIME_TYPE, mimeType); |
| 269 | |
| 270 | final int flags = mimeType.startsWith("image/") ? Document.FLAG_SUPPORTS_THUMBNAIL : 0; |
| 271 | row.add(Document.COLUMN_FLAGS, flags); |
| 272 | } |
| 273 | |
Tomasz Mikolajewski | d683f97 | 2017-01-24 18:54:42 +0900 | [diff] [blame^] | 274 | static String getMimeTypeForEntry(ZipEntry entry) { |
Tomasz Mikolajewski | dbd6b8b | 2016-09-29 15:27:37 +0900 | [diff] [blame] | 275 | if (entry.isDirectory()) { |
| 276 | return Document.MIME_TYPE_DIR; |
| 277 | } |
| 278 | |
| 279 | final int lastDot = entry.getName().lastIndexOf('.'); |
| 280 | if (lastDot >= 0) { |
| 281 | final String extension = entry.getName().substring(lastDot + 1).toLowerCase(Locale.US); |
| 282 | final String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); |
| 283 | if (mimeType != null) { |
| 284 | return mimeType; |
| 285 | } |
| 286 | } |
| 287 | |
| 288 | return "application/octet-stream"; |
| 289 | } |
| 290 | |
| 291 | // TODO: Upstream to the Preconditions class. |
Tomasz Mikolajewski | d683f97 | 2017-01-24 18:54:42 +0900 | [diff] [blame^] | 292 | static class MorePreconditions { |
Tomasz Mikolajewski | dbd6b8b | 2016-09-29 15:27:37 +0900 | [diff] [blame] | 293 | static void checkArgumentEquals(String expected, @Nullable String actual, |
| 294 | String message) { |
| 295 | if (!TextUtils.equals(expected, actual)) { |
| 296 | throw new IllegalArgumentException(String.format(message, |
| 297 | String.valueOf(expected), String.valueOf(actual))); |
| 298 | } |
| 299 | } |
| 300 | |
| 301 | static void checkArgumentEquals(Uri expected, @Nullable Uri actual, |
| 302 | String message) { |
| 303 | checkArgumentEquals(expected.toString(), actual.toString(), message); |
| 304 | } |
| 305 | } |
| 306 | }; |