| /* |
| * Copyright (C) 2015 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.documentsui.archives; |
| |
| import android.content.Context; |
| import android.content.res.AssetFileDescriptor; |
| import android.database.Cursor; |
| import android.database.MatrixCursor; |
| import android.graphics.Point; |
| import android.net.Uri; |
| import android.os.CancellationSignal; |
| import android.os.ParcelFileDescriptor; |
| import android.provider.DocumentsContract.Document; |
| import android.system.ErrnoException; |
| import android.system.Os; |
| import android.system.OsConstants; |
| import android.text.TextUtils; |
| import android.webkit.MimeTypeMap; |
| |
| import androidx.annotation.GuardedBy; |
| import androidx.annotation.Nullable; |
| import androidx.core.util.Preconditions; |
| |
| import java.io.Closeable; |
| import java.io.File; |
| import java.io.FileNotFoundException; |
| import java.util.HashMap; |
| import java.util.List; |
| import java.util.Locale; |
| import java.util.Map; |
| |
| import org.apache.commons.compress.archivers.ArchiveEntry; |
| import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; |
| |
| /** |
| * Provides basic implementation for creating, extracting and accessing |
| * files within archives exposed by a document provider. |
| * |
| * <p>This class is thread safe. |
| */ |
| public abstract class Archive implements Closeable { |
| private static final String TAG = "Archive"; |
| |
| public static final String[] DEFAULT_PROJECTION = new String[] { |
| Document.COLUMN_DOCUMENT_ID, |
| Document.COLUMN_DISPLAY_NAME, |
| Document.COLUMN_MIME_TYPE, |
| Document.COLUMN_SIZE, |
| Document.COLUMN_FLAGS |
| }; |
| |
| final Context mContext; |
| final Uri mArchiveUri; |
| final int mAccessMode; |
| final Uri mNotificationUri; |
| |
| // The container as well as values are guarded by mEntries. |
| @GuardedBy("mEntries") |
| final Map<String, ArchiveEntry> mEntries; |
| |
| // The container as well as values and elements of values are guarded by mEntries. |
| @GuardedBy("mEntries") |
| final Map<String, List<ArchiveEntry>> mTree; |
| |
| Archive( |
| Context context, |
| Uri archiveUri, |
| int accessMode, |
| @Nullable Uri notificationUri) { |
| mContext = context; |
| mArchiveUri = archiveUri; |
| mAccessMode = accessMode; |
| mNotificationUri = notificationUri; |
| |
| mTree = new HashMap<>(); |
| mEntries = new HashMap<>(); |
| } |
| |
| /** |
| * Returns a valid, normalized path for an entry. |
| */ |
| public static String getEntryPath(ArchiveEntry entry) { |
| if (entry instanceof ZipArchiveEntry) { |
| /** |
| * Some of archive entry doesn't have the same naming rule. |
| * For example: The name of 7 zip directory entry doesn't end with '/'. |
| * Only check for Zip archive. |
| */ |
| Preconditions.checkArgument(entry.isDirectory() == entry.getName().endsWith("/"), |
| "Ill-formated ZIP-file."); |
| } |
| if (entry.getName().startsWith("/")) { |
| return entry.getName(); |
| } else { |
| return "/" + entry.getName(); |
| } |
| } |
| |
| /** |
| * Returns true if the file descriptor is seekable. |
| * @param descriptor File descriptor to check. |
| */ |
| public static boolean canSeek(ParcelFileDescriptor descriptor) { |
| try { |
| return Os.lseek(descriptor.getFileDescriptor(), 0, |
| OsConstants.SEEK_CUR) == 0; |
| } catch (ErrnoException e) { |
| return false; |
| } |
| } |
| |
| /** |
| * Lists child documents of an archive or a directory within an |
| * archive. Must be called only for archives with supported mime type, |
| * or for documents within archives. |
| * |
| * @see DocumentsProvider.queryChildDocuments(String, String[], String) |
| */ |
| public Cursor queryChildDocuments(String documentId, @Nullable String[] projection, |
| @Nullable String sortOrder) throws FileNotFoundException { |
| final ArchiveId parsedParentId = ArchiveId.fromDocumentId(documentId); |
| MorePreconditions.checkArgumentEquals(mArchiveUri, parsedParentId.mArchiveUri, |
| "Mismatching archive Uri. Expected: %s, actual: %s."); |
| |
| final MatrixCursor result = new MatrixCursor( |
| projection != null ? projection : DEFAULT_PROJECTION); |
| if (mNotificationUri != null) { |
| result.setNotificationUri(mContext.getContentResolver(), mNotificationUri); |
| } |
| |
| synchronized (mEntries) { |
| final List<ArchiveEntry> parentList = mTree.get(parsedParentId.mPath); |
| if (parentList == null) { |
| throw new FileNotFoundException(); |
| } |
| for (final ArchiveEntry entry : parentList) { |
| addCursorRow(result, entry); |
| } |
| } |
| return result; |
| } |
| |
| /** |
| * Returns a MIME type of a document within an archive. |
| * |
| * @see DocumentsProvider.getDocumentType(String) |
| */ |
| public String getDocumentType(String documentId) throws FileNotFoundException { |
| final ArchiveId parsedId = ArchiveId.fromDocumentId(documentId); |
| MorePreconditions.checkArgumentEquals(mArchiveUri, parsedId.mArchiveUri, |
| "Mismatching archive Uri. Expected: %s, actual: %s."); |
| |
| synchronized (mEntries) { |
| final ArchiveEntry entry = mEntries.get(parsedId.mPath); |
| if (entry == null) { |
| throw new FileNotFoundException(); |
| } |
| return getMimeTypeForEntry(entry); |
| } |
| } |
| |
| /** |
| * Returns true if a document within an archive is a child or any descendant of the archive |
| * document or another document within the archive. |
| * |
| * @see DocumentsProvider.isChildDocument(String, String) |
| */ |
| public boolean isChildDocument(String parentDocumentId, String documentId) { |
| final ArchiveId parsedParentId = ArchiveId.fromDocumentId(parentDocumentId); |
| final ArchiveId parsedId = ArchiveId.fromDocumentId(documentId); |
| MorePreconditions.checkArgumentEquals(mArchiveUri, parsedParentId.mArchiveUri, |
| "Mismatching archive Uri. Expected: %s, actual: %s."); |
| |
| synchronized (mEntries) { |
| final ArchiveEntry entry = mEntries.get(parsedId.mPath); |
| if (entry == null) { |
| return false; |
| } |
| |
| final ArchiveEntry parentEntry = mEntries.get(parsedParentId.mPath); |
| if (parentEntry == null || !parentEntry.isDirectory()) { |
| return false; |
| } |
| |
| // Add a trailing slash even if it's not a directory, so it's easy to check if the |
| // entry is a descendant. |
| String pathWithSlash = entry.isDirectory() ? getEntryPath(entry) |
| : getEntryPath(entry) + "/"; |
| |
| return pathWithSlash.startsWith(parsedParentId.mPath) && |
| !parsedParentId.mPath.equals(pathWithSlash); |
| } |
| } |
| |
| /** |
| * Returns metadata of a document within an archive. |
| * |
| * @see DocumentsProvider.queryDocument(String, String[]) |
| */ |
| public Cursor queryDocument(String documentId, @Nullable String[] projection) |
| throws FileNotFoundException { |
| final ArchiveId parsedId = ArchiveId.fromDocumentId(documentId); |
| MorePreconditions.checkArgumentEquals(mArchiveUri, parsedId.mArchiveUri, |
| "Mismatching archive Uri. Expected: %s, actual: %s."); |
| |
| synchronized (mEntries) { |
| final ArchiveEntry entry = mEntries.get(parsedId.mPath); |
| if (entry == null) { |
| throw new FileNotFoundException(); |
| } |
| |
| final MatrixCursor result = new MatrixCursor( |
| projection != null ? projection : DEFAULT_PROJECTION); |
| if (mNotificationUri != null) { |
| result.setNotificationUri(mContext.getContentResolver(), mNotificationUri); |
| } |
| addCursorRow(result, entry); |
| return result; |
| } |
| } |
| |
| /** |
| * Creates a file within an archive. |
| * |
| * @see DocumentsProvider.createDocument(String, String, String)) |
| */ |
| public String createDocument(String parentDocumentId, String mimeType, String displayName) |
| throws FileNotFoundException { |
| throw new UnsupportedOperationException("Creating documents not supported."); |
| } |
| |
| /** |
| * Opens a file within an archive. |
| * |
| * @see DocumentsProvider.openDocument(String, String, CancellationSignal)) |
| */ |
| public ParcelFileDescriptor openDocument( |
| String documentId, String mode, @Nullable final CancellationSignal signal) |
| throws FileNotFoundException { |
| throw new UnsupportedOperationException("Opening not supported."); |
| } |
| |
| /** |
| * Opens a thumbnail of a file within an archive. |
| * |
| * @see DocumentsProvider.openDocumentThumbnail(String, Point, CancellationSignal)) |
| */ |
| public AssetFileDescriptor openDocumentThumbnail( |
| String documentId, Point sizeHint, final CancellationSignal signal) |
| throws FileNotFoundException { |
| throw new UnsupportedOperationException("Thumbnails not supported."); |
| } |
| |
| /** |
| * Creates an archive id for the passed path. |
| */ |
| public ArchiveId createArchiveId(String path) { |
| return new ArchiveId(mArchiveUri, mAccessMode, path); |
| } |
| |
| /** |
| * Not thread safe. |
| */ |
| void addCursorRow(MatrixCursor cursor, ArchiveEntry entry) { |
| final MatrixCursor.RowBuilder row = cursor.newRow(); |
| final ArchiveId parsedId = createArchiveId(getEntryPath(entry)); |
| row.add(Document.COLUMN_DOCUMENT_ID, parsedId.toDocumentId()); |
| |
| final File file = new File(entry.getName()); |
| row.add(Document.COLUMN_DISPLAY_NAME, file.getName()); |
| row.add(Document.COLUMN_SIZE, entry.getSize()); |
| |
| final String mimeType = getMimeTypeForEntry(entry); |
| row.add(Document.COLUMN_MIME_TYPE, mimeType); |
| |
| int flags = mimeType.startsWith("image/") ? Document.FLAG_SUPPORTS_THUMBNAIL : 0; |
| if (MetadataReader.isSupportedMimeType(mimeType)) { |
| flags |= Document.FLAG_SUPPORTS_METADATA; |
| } |
| row.add(Document.COLUMN_FLAGS, flags); |
| } |
| |
| static String getMimeTypeForEntry(ArchiveEntry entry) { |
| if (entry.isDirectory()) { |
| return Document.MIME_TYPE_DIR; |
| } |
| |
| final int lastDot = entry.getName().lastIndexOf('.'); |
| if (lastDot >= 0) { |
| final String extension = entry.getName().substring(lastDot + 1).toLowerCase(Locale.US); |
| final String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); |
| if (mimeType != null) { |
| return mimeType; |
| } |
| } |
| |
| return "application/octet-stream"; |
| } |
| |
| // TODO: Upstream to the Preconditions class. |
| // TODO: Move to a separate file. |
| public static class MorePreconditions { |
| static void checkArgumentEquals(String expected, @Nullable String actual, |
| String message) { |
| if (!TextUtils.equals(expected, actual)) { |
| throw new IllegalArgumentException(String.format(message, |
| String.valueOf(expected), String.valueOf(actual))); |
| } |
| } |
| |
| static void checkArgumentEquals(Uri expected, @Nullable Uri actual, |
| String message) { |
| checkArgumentEquals(expected.toString(), actual.toString(), message); |
| } |
| } |
| } |