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