blob: 7c0f47147572ca14939dc30e6c3e51b54180f7d3 [file] [log] [blame]
/*
* 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);
}
}
}