Merge "Adding ActivityInputHandler and tests." into nyc-andromeda-dev
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 9ea7b54..2e678b7 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -108,6 +108,13 @@
android:authorities="com.android.documentsui.lastAccessed"
android:exported="false"/>
+ <provider
+ android:name=".archives.ArchivesProvider"
+ android:authorities="com.android.documentsui.archives"
+ android:grantUriPermissions="true"
+ android:permission="android.permission.MANAGE_DOCUMENTS"
+ android:exported="true"/>
+
<receiver android:name=".PackageReceiver">
<intent-filter>
<action android:name="android.intent.action.PACKAGE_FULLY_REMOVED" />
diff --git a/res/layout/item_doc_grid.xml b/res/layout/item_doc_grid.xml
index 9ffccad..0fa9685 100644
--- a/res/layout/item_doc_grid.xml
+++ b/res/layout/item_doc_grid.xml
@@ -98,7 +98,7 @@
android:textColor="@color/item_title" />
<TextView
- android:id="@+id/size"
+ android:id="@+id/details"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_toEndOf="@id/icon_mime_sm"
@@ -115,7 +115,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@android:id/title"
- android:layout_toEndOf="@id/size"
+ android:layout_toEndOf="@id/details"
android:singleLine="true"
android:ellipsize="end"
android:textAlignment="viewStart"
diff --git a/res/values-kn-rIN/strings.xml b/res/values-kn-rIN/strings.xml
index d2031ac..3062896 100644
--- a/res/values-kn-rIN/strings.xml
+++ b/res/values-kn-rIN/strings.xml
@@ -23,7 +23,7 @@
<string name="menu_create_dir" msgid="2413624798689091042">"ಹೊಸ ಫೋಲ್ಡರ್"</string>
<string name="menu_grid" msgid="1453636521731880680">"ಗ್ರಿಡ್ ವೀಕ್ಷಣೆ"</string>
<string name="menu_list" msgid="6714267452146410402">"ಪಟ್ಟಿ ವೀಕ್ಷಣೆ"</string>
- <string name="menu_search" msgid="1876699106790719849">"ಹುಡುಕು"</string>
+ <string name="menu_search" msgid="1876699106790719849">"ಹುಡುಕಿ"</string>
<string name="menu_settings" msgid="6520844520117939047">"ಸಂಗ್ರಹಣೆ ಸೆಟ್ಟಿಂಗ್ಗಳು"</string>
<string name="menu_open" msgid="9092138100049759315">"ತೆರೆ"</string>
<string name="menu_open_with" msgid="5507647065467520229">"ಇದರ ಮೂಲಕ ತೆರೆಯಿರಿ"</string>
diff --git a/src/com/android/documentsui/AbstractActionHandler.java b/src/com/android/documentsui/AbstractActionHandler.java
index 0809a37..f541775 100644
--- a/src/com/android/documentsui/AbstractActionHandler.java
+++ b/src/com/android/documentsui/AbstractActionHandler.java
@@ -25,6 +25,7 @@
import android.support.annotation.VisibleForTesting;
import com.android.documentsui.AbstractActionHandler.CommonAddons;
+import com.android.documentsui.archives.ArchivesProvider;
import com.android.documentsui.base.BooleanConsumer;
import com.android.documentsui.base.DocumentInfo;
import com.android.documentsui.base.DocumentStack;
@@ -32,8 +33,8 @@
import com.android.documentsui.base.RootInfo;
import com.android.documentsui.base.Shared;
import com.android.documentsui.base.State;
-import com.android.documentsui.dirlist.AnimationView;
import com.android.documentsui.dirlist.AnimationView.AnimationType;
+import com.android.documentsui.dirlist.AnimationView;
import com.android.documentsui.dirlist.DocumentDetails;
import com.android.documentsui.files.LauncherActivity;
import com.android.documentsui.files.OpenUriForViewTask;
@@ -158,10 +159,20 @@
@Override
public void openContainerDocument(DocumentInfo doc) {
assert(doc.isContainer());
+ DocumentInfo currentDoc = null;
- mActivity.notifyDirectoryNavigated(doc.derivedUri);
+ if (doc.isDirectory()) {
+ // Regular directory.
+ currentDoc = doc;
+ } else if (doc.isArchive()) {
+ // Archive.
+ currentDoc = mDocs.getArchiveDocument(doc.derivedUri);
+ }
- mState.pushDocument(doc);
+ assert(currentDoc != null);
+ mActivity.notifyDirectoryNavigated(currentDoc.derivedUri);
+ mState.pushDocument(currentDoc);
+
// Show an opening animation only if pressing "back" would get us back to the
// previous directory. Especially after opening a root document, pressing
// back, wouldn't go to the previous root, but close the activity.
diff --git a/src/com/android/documentsui/DocumentsAccess.java b/src/com/android/documentsui/DocumentsAccess.java
index cd9c4c9..5d72ab8 100644
--- a/src/com/android/documentsui/DocumentsAccess.java
+++ b/src/com/android/documentsui/DocumentsAccess.java
@@ -22,6 +22,7 @@
import android.provider.DocumentsContract;
import android.util.Log;
+import com.android.documentsui.archives.ArchivesProvider;
import com.android.documentsui.base.DocumentInfo;
import com.android.documentsui.base.RootInfo;
@@ -35,6 +36,7 @@
@Nullable DocumentInfo getRootDocument(RootInfo root);
@Nullable DocumentInfo getRootDocument(Uri uri);
@Nullable DocumentInfo getDocument(Uri uri);
+ @Nullable DocumentInfo getArchiveDocument(Uri uri);
public static DocumentsAccess create(Context context) {
return new RuntimeDocumentAccess(context);
@@ -76,5 +78,10 @@
return null;
}
+
+ @Override
+ public DocumentInfo getArchiveDocument(Uri uri) {
+ return getDocument(ArchivesProvider.buildUriForArchive(uri));
+ }
}
}
diff --git a/src/com/android/documentsui/archives/Archive.java b/src/com/android/documentsui/archives/Archive.java
new file mode 100644
index 0000000..35ecb54
--- /dev/null
+++ b/src/com/android/documentsui/archives/Archive.java
@@ -0,0 +1,501 @@
+/*
+ * 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.media.ExifInterface;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.CancellationSignal;
+import android.os.OperationCanceledException;
+import android.os.ParcelFileDescriptor;
+import android.provider.DocumentsContract;
+import android.provider.DocumentsContract.Document;
+import android.provider.DocumentsProvider;
+import android.support.annotation.Nullable;
+import android.util.Log;
+import android.webkit.MimeTypeMap;
+
+import libcore.io.IoUtils;
+
+import com.android.internal.util.Preconditions;
+
+import java.io.Closeable;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.lang.IllegalArgumentException;
+import java.lang.IllegalStateException;
+import java.lang.UnsupportedOperationException;
+import android.text.TextUtils;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Stack;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+import java.util.zip.ZipInputStream;
+
+/**
+ * Provides basic implementation for creating, extracting and accessing
+ * files within archives exposed by a document provider.
+ *
+ * <p>This class is thread safe.
+ */
+public 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
+ };
+
+ private final Context mContext;
+ private final Uri mArchiveUri;
+ private final Uri mNotificationUri;
+ private final ZipFile mZipFile;
+ private final ExecutorService mExecutor;
+ private final Map<String, ZipEntry> mEntries;
+ private final Map<String, List<ZipEntry>> mTree;
+
+ private Archive(
+ Context context,
+ File file,
+ Uri archiveUri,
+ @Nullable Uri notificationUri)
+ throws IOException {
+ mContext = context;
+ mArchiveUri = archiveUri;
+ mNotificationUri = notificationUri;
+ mZipFile = new ZipFile(file);
+ mExecutor = Executors.newSingleThreadExecutor();
+
+ // Build the tree structure in memory.
+ mTree = new HashMap<String, List<ZipEntry>>();
+ mTree.put("/", new ArrayList<ZipEntry>());
+
+ mEntries = new HashMap<String, ZipEntry>();
+ ZipEntry entry;
+ final List<? extends ZipEntry> entries = Collections.list(mZipFile.entries());
+ final Stack<ZipEntry> stack = new Stack<>();
+ for (int i = entries.size() - 1; i >= 0; i--) {
+ entry = entries.get(i);
+ if (entry.isDirectory() != entry.getName().endsWith("/")) {
+ throw new IOException(
+ "Directories must have a trailing slash, and files must not.");
+ }
+ if (mEntries.containsKey(entry.getName())) {
+ throw new IOException("Multiple entries with the same name are not supported.");
+ }
+ mEntries.put(entry.getName(), entry);
+ if (entry.isDirectory()) {
+ mTree.put(entry.getName(), new ArrayList<ZipEntry>());
+ }
+ stack.push(entry);
+ }
+
+ int delimiterIndex;
+ String parentPath;
+ ZipEntry parentEntry;
+ List<ZipEntry> parentList;
+
+ while (stack.size() > 0) {
+ entry = stack.pop();
+
+ delimiterIndex = entry.getName().lastIndexOf('/', entry.isDirectory()
+ ? entry.getName().length() - 2 : entry.getName().length() - 1);
+ parentPath =
+ delimiterIndex != -1 ? entry.getName().substring(0, delimiterIndex) + "/" : "/";
+ parentList = mTree.get(parentPath);
+
+ if (parentList == null) {
+ parentEntry = mEntries.get(parentPath);
+ if (parentEntry == null) {
+ // The ZIP file doesn't contain all directories leading to the entry.
+ // It's rare, but can happen in a valid ZIP archive. In such case create a
+ // fake ZipEntry and add it on top of the stack to process it next.
+ parentEntry = new ZipEntry(parentPath);
+ parentEntry.setSize(0);
+ parentEntry.setTime(entry.getTime());
+ mEntries.put(parentPath, parentEntry);
+ stack.push(parentEntry);
+ }
+ parentList = new ArrayList<ZipEntry>();
+ mTree.put(parentPath, parentList);
+ }
+
+ parentList.add(entry);
+ }
+ }
+
+ /**
+ * Creates a DocumentsArchive instance for opening, browsing and accessing
+ * documents within the archive passed as a file descriptor.
+ *
+ * @param context Context of the provider.
+ * @param descriptor File descriptor for the archive's contents.
+ * @param archiveUri Uri of the archive document.
+ * @param Uri notificationUri Uri for notifying that the archive file has changed.
+ */
+ public static Archive createForParcelFileDescriptor(
+ Context context, ParcelFileDescriptor descriptor, Uri archiveUri,
+ @Nullable Uri notificationUri)
+ throws IOException {
+ File snapshotFile = null;
+ try {
+ // Create a copy of the archive, as ZipFile doesn't operate on streams.
+ // Moreover, ZipInputStream would be inefficient for large files on
+ // pipes.
+ snapshotFile = File.createTempFile("com.android.documentsui.snapshot{",
+ "}.zip", context.getCacheDir());
+
+ try (
+ final FileOutputStream outputStream =
+ new ParcelFileDescriptor.AutoCloseOutputStream(
+ ParcelFileDescriptor.open(
+ snapshotFile, ParcelFileDescriptor.MODE_WRITE_ONLY));
+ final ParcelFileDescriptor.AutoCloseInputStream inputStream =
+ new ParcelFileDescriptor.AutoCloseInputStream(descriptor);
+ ) {
+ final byte[] buffer = new byte[32 * 1024];
+ int bytes;
+ while ((bytes = inputStream.read(buffer)) != -1) {
+ outputStream.write(buffer, 0, bytes);
+ }
+ outputStream.flush();
+ return new Archive(context, snapshotFile, archiveUri,
+ notificationUri);
+ }
+ } finally {
+ // On UNIX the file will be still available for processes which opened it, even
+ // after deleting it. Remove it ASAP, as it won't be used by anyone else.
+ if (snapshotFile != null) {
+ snapshotFile.delete();
+ }
+ }
+ }
+
+ /**
+ * 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);
+ }
+
+ final List<ZipEntry> parentList = mTree.get(parsedParentId.mPath);
+ if (parentList == null) {
+ throw new FileNotFoundException();
+ }
+ for (final ZipEntry 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.");
+
+ final ZipEntry 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.");
+
+ final ZipEntry entry = mEntries.get(parsedId.mPath);
+ if (entry == null) {
+ return false;
+ }
+
+ final ZipEntry parentEntry = mEntries.get(parsedParentId.mPath);
+ if (parentEntry == null || !parentEntry.isDirectory()) {
+ return false;
+ }
+
+ final String parentPath = entry.getName();
+
+ // Add a trailing slash even if it's not a directory, so it's easy to check if the
+ // entry is a descendant.
+ final String pathWithSlash = entry.isDirectory() ? entry.getName() : entry.getName() + "/";
+ return pathWithSlash.startsWith(parentPath) && !parentPath.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.");
+
+ final ZipEntry 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;
+ }
+
+ /**
+ * 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 {
+ MorePreconditions.checkArgumentEquals("r", mode,
+ "Invalid mode. Only reading \"r\" supported, but got: \"%s\".");
+ final ArchiveId parsedId = ArchiveId.fromDocumentId(documentId);
+ MorePreconditions.checkArgumentEquals(mArchiveUri, parsedId.mArchiveUri,
+ "Mismatching archive Uri. Expected: %s, actual: %s.");
+
+ final ZipEntry entry = mEntries.get(parsedId.mPath);
+ if (entry == null) {
+ throw new FileNotFoundException();
+ }
+
+ ParcelFileDescriptor[] pipe;
+ InputStream inputStream = null;
+ try {
+ pipe = ParcelFileDescriptor.createReliablePipe();
+ inputStream = mZipFile.getInputStream(entry);
+ } catch (IOException e) {
+ if (inputStream != null) {
+ IoUtils.closeQuietly(inputStream);
+ }
+ // Ideally we'd simply throw IOException to the caller, but for consistency
+ // with DocumentsProvider::openDocument, converting it to IllegalStateException.
+ throw new IllegalStateException("Failed to open the document.", e);
+ }
+ final ParcelFileDescriptor outputPipe = pipe[1];
+ final InputStream finalInputStream = inputStream;
+ mExecutor.execute(
+ new Runnable() {
+ @Override
+ public void run() {
+ try (final ParcelFileDescriptor.AutoCloseOutputStream outputStream =
+ new ParcelFileDescriptor.AutoCloseOutputStream(outputPipe)) {
+ try {
+ final byte buffer[] = new byte[32 * 1024];
+ int bytes;
+ while ((bytes = finalInputStream.read(buffer)) != -1) {
+ if (Thread.interrupted()) {
+ throw new InterruptedException();
+ }
+ if (signal != null) {
+ signal.throwIfCanceled();
+ }
+ outputStream.write(buffer, 0, bytes);
+ }
+ } catch (IOException | InterruptedException e) {
+ // Catch the exception before the outer try-with-resource closes the
+ // pipe with close() instead of closeWithError().
+ try {
+ outputPipe.closeWithError(e.getMessage());
+ } catch (IOException e2) {
+ Log.e(TAG, "Failed to close the pipe after an error.", e2);
+ }
+ }
+ } catch (OperationCanceledException e) {
+ // Cancelled gracefully.
+ } catch (IOException e) {
+ Log.e(TAG, "Failed to close the output stream gracefully.", e);
+ } finally {
+ IoUtils.closeQuietly(finalInputStream);
+ }
+ }
+ });
+
+ return pipe[0];
+ }
+
+ /**
+ * 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 {
+ final ArchiveId parsedId = ArchiveId.fromDocumentId(documentId);
+ MorePreconditions.checkArgumentEquals(mArchiveUri, parsedId.mArchiveUri,
+ "Mismatching archive Uri. Expected: %s, actual: %s.");
+ Preconditions.checkArgument(getDocumentType(documentId).startsWith("image/"),
+ "Thumbnails only supported for image/* MIME type.");
+
+ final ZipEntry entry = mEntries.get(parsedId.mPath);
+ if (entry == null) {
+ throw new FileNotFoundException();
+ }
+
+ InputStream inputStream = null;
+ try {
+ inputStream = mZipFile.getInputStream(entry);
+ final ExifInterface exif = new ExifInterface(inputStream);
+ if (exif.hasThumbnail()) {
+ Bundle extras = null;
+ switch (exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, -1)) {
+ case ExifInterface.ORIENTATION_ROTATE_90:
+ extras = new Bundle(1);
+ extras.putInt(DocumentsContract.EXTRA_ORIENTATION, 90);
+ break;
+ case ExifInterface.ORIENTATION_ROTATE_180:
+ extras = new Bundle(1);
+ extras.putInt(DocumentsContract.EXTRA_ORIENTATION, 180);
+ break;
+ case ExifInterface.ORIENTATION_ROTATE_270:
+ extras = new Bundle(1);
+ extras.putInt(DocumentsContract.EXTRA_ORIENTATION, 270);
+ break;
+ }
+ final long[] range = exif.getThumbnailRange();
+ return new AssetFileDescriptor(
+ openDocument(documentId, "r", signal), range[0], range[1], extras);
+ }
+ } catch (IOException e) {
+ // Ignore the exception, as reading the EXIF may legally fail.
+ Log.e(TAG, "Failed to obtain thumbnail from EXIF.", e);
+ } finally {
+ IoUtils.closeQuietly(inputStream);
+ }
+
+ return new AssetFileDescriptor(
+ openDocument(documentId, "r", signal), 0, entry.getSize(), null);
+ }
+
+ /**
+ * Schedules a gracefully close of the archive after any opened files are closed.
+ *
+ * <p>This method does not block until shutdown. Once called, other methods should not be
+ * called.
+ */
+ @Override
+ public void close() {
+ mExecutor.execute(new Runnable() {
+ @Override
+ public void run() {
+ IoUtils.closeQuietly(mZipFile);
+ }
+ });
+ mExecutor.shutdown();
+ }
+
+ private void addCursorRow(MatrixCursor cursor, ZipEntry entry) {
+ final MatrixCursor.RowBuilder row = cursor.newRow();
+ final ArchiveId parsedId = new ArchiveId(mArchiveUri, entry.getName());
+ 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);
+
+ final int flags = mimeType.startsWith("image/") ? Document.FLAG_SUPPORTS_THUMBNAIL : 0;
+ row.add(Document.COLUMN_FLAGS, flags);
+ }
+
+ private String getMimeTypeForEntry(ZipEntry 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.
+ private 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);
+ }
+ }
+};
diff --git a/src/com/android/documentsui/archives/ArchiveId.java b/src/com/android/documentsui/archives/ArchiveId.java
new file mode 100644
index 0000000..d136de1
--- /dev/null
+++ b/src/com/android/documentsui/archives/ArchiveId.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright (C) 2016 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.net.Uri;
+
+class ArchiveId {
+ private final static char DELIMITER = '#';
+
+ public final Uri mArchiveUri;
+ public final String mPath;
+
+ public ArchiveId(Uri archiveUri, String path) {
+ mArchiveUri = archiveUri;
+ mPath = path;
+ assert(!mPath.isEmpty());
+ }
+
+ static public ArchiveId fromDocumentId(String documentId) {
+ final int delimiterPosition = documentId.indexOf(DELIMITER);
+ assert(delimiterPosition != -1);
+ return new ArchiveId(Uri.parse(documentId.substring(0, delimiterPosition)),
+ documentId.substring((delimiterPosition + 1)));
+ }
+
+ public String toDocumentId() {
+ return mArchiveUri.toString() + DELIMITER + mPath;
+ }
+};
diff --git a/src/com/android/documentsui/archives/ArchivesProvider.java b/src/com/android/documentsui/archives/ArchivesProvider.java
new file mode 100644
index 0000000..1ebe427
--- /dev/null
+++ b/src/com/android/documentsui/archives/ArchivesProvider.java
@@ -0,0 +1,315 @@
+/*
+ * 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.content.res.Configuration;
+import android.database.ContentObserver;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.database.MatrixCursor.RowBuilder;
+import android.graphics.Point;
+import android.net.Uri;
+import android.os.CancellationSignal;
+import android.os.ParcelFileDescriptor;
+import android.provider.DocumentsContract.Document;
+import android.provider.DocumentsContract;
+import android.provider.DocumentsProvider;
+import android.support.annotation.Nullable;
+import android.util.Log;
+import android.util.LruCache;
+
+import com.android.internal.annotations.GuardedBy;
+import com.android.internal.util.Preconditions;
+
+import java.io.Closeable;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.Callable;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReadWriteLock;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+
+/**
+ * Provides basic implementation for creating, extracting and accessing
+ * files within archives exposed by a document provider.
+ *
+ * <p>This class is thread safe. All methods can be called on any thread without
+ * synchronization.
+ */
+public class ArchivesProvider extends DocumentsProvider implements Closeable {
+ public static final String AUTHORITY = "com.android.documentsui.archives";
+
+ private static final String TAG = "ArchivesProvider";
+ private static final int OPENED_ARCHIVES_CACHE_SIZE = 4;
+ private static final String[] ZIP_MIME_TYPES = {
+ "application/zip", "application/x-zip", "application/x-zip-compressed"
+ };
+
+ @GuardedBy("mArchives")
+ private final LruCache<Uri, Loader> mArchives =
+ new LruCache<Uri, Loader>(OPENED_ARCHIVES_CACHE_SIZE) {
+ @Override
+ public void entryRemoved(boolean evicted, Uri key,
+ Loader oldValue, Loader newValue) {
+ oldValue.getWriteLock().lock();
+ try {
+ oldValue.get().close();
+ } catch (FileNotFoundException e) {
+ Log.e(TAG, "Failed to close an archive as it no longer exists.");
+ } finally {
+ oldValue.getWriteLock().unlock();
+ }
+ }
+ };
+
+ @Override
+ public boolean onCreate() {
+ return true;
+ }
+
+ @Override
+ public Cursor queryRoots(String[] projection) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Cursor queryChildDocuments(String documentId, @Nullable String[] projection,
+ @Nullable String sortOrder)
+ throws FileNotFoundException {
+ Loader loader = null;
+ try {
+ loader = obtainInstance(documentId);
+ return loader.get().queryChildDocuments(documentId, projection, sortOrder);
+ } finally {
+ releaseInstance(loader);
+ }
+ }
+
+ @Override
+ public String getDocumentType(String documentId) throws FileNotFoundException {
+ final ArchiveId archiveId = ArchiveId.fromDocumentId(documentId);
+ if (archiveId.mPath.equals("/")) {
+ return Document.MIME_TYPE_DIR;
+ }
+
+ Loader loader = null;
+ try {
+ loader = obtainInstance(documentId);
+ return loader.get().getDocumentType(documentId);
+ } finally {
+ releaseInstance(loader);
+ }
+ }
+
+ @Override
+ public boolean isChildDocument(String parentDocumentId, String documentId) {
+ Loader loader = null;
+ try {
+ loader = obtainInstance(documentId);
+ return loader.get().isChildDocument(parentDocumentId, documentId);
+ } catch (FileNotFoundException e) {
+ throw new IllegalStateException(e);
+ } finally {
+ releaseInstance(loader);
+ }
+ }
+
+ @Override
+ public Cursor queryDocument(String documentId, @Nullable String[] projection)
+ throws FileNotFoundException {
+ final ArchiveId archiveId = ArchiveId.fromDocumentId(documentId);
+ if (archiveId.mPath.equals("/")) {
+ // For the archive's root directory return hard-coded cursor, so clients know that
+ // it's actually a directory and queryChildDocuments() can be called on it.
+ //
+ // TODO: Move this code to the Archive class, once opening archives is moved to
+ // background.
+ final MatrixCursor cursor = new MatrixCursor(
+ projection != null ? projection : Archive.DEFAULT_PROJECTION);
+ final RowBuilder row = cursor.newRow();
+ row.add(Document.COLUMN_DOCUMENT_ID, documentId);
+ row.add(Document.COLUMN_DISPLAY_NAME, "Archive"); // TODO: Fix.
+ row.add(Document.COLUMN_SIZE, 0);
+ row.add(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR);
+ return cursor;
+ }
+
+ Loader loader = null;
+ try {
+ loader = obtainInstance(documentId);
+ return loader.get().queryDocument(documentId, projection);
+ } finally {
+ releaseInstance(loader);
+ }
+ }
+
+ @Override
+ public ParcelFileDescriptor openDocument(
+ String documentId, String mode, final CancellationSignal signal)
+ throws FileNotFoundException {
+ Loader loader = null;
+ try {
+ loader = obtainInstance(documentId);
+ return loader.get().openDocument(documentId, mode, signal);
+ } finally {
+ releaseInstance(loader);
+ }
+ }
+
+ @Override
+ public AssetFileDescriptor openDocumentThumbnail(
+ String documentId, Point sizeHint, final CancellationSignal signal)
+ throws FileNotFoundException {
+ Loader loader = null;
+ try {
+ loader = obtainInstance(documentId);
+ return loader.get().openDocumentThumbnail(documentId, sizeHint, signal);
+ } finally {
+ releaseInstance(loader);
+ }
+ }
+
+ /**
+ * Returns true if the passed mime type is supported by the helper.
+ */
+ public static boolean isSupportedArchiveType(String mimeType) {
+ for (final String zipMimeType : ZIP_MIME_TYPES) {
+ if (zipMimeType.equals(mimeType)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public static Uri buildUriForArchive(Uri archiveUri) {
+ return DocumentsContract.buildDocumentUri(
+ AUTHORITY, new ArchiveId(archiveUri, "/").toDocumentId());
+ }
+
+ /**
+ * Closes the helper and disposes all existing archives. It will block until all ongoing
+ * operations on each opened archive are finished.
+ */
+ @Override
+ // TODO: Wire close() to call().
+ public void close() {
+ synchronized (mArchives) {
+ mArchives.evictAll();
+ }
+ }
+
+ private Loader obtainInstance(String documentId) throws FileNotFoundException {
+ Loader loader;
+ synchronized (mArchives) {
+ loader = getInstanceUncheckedLocked(documentId);
+ loader.getReadLock().lock();
+ }
+ return loader;
+ }
+
+ private void releaseInstance(@Nullable Loader loader) {
+ if (loader != null) {
+ loader.getReadLock().unlock();
+ }
+ }
+
+ private Loader getInstanceUncheckedLocked(String documentId)
+ throws FileNotFoundException {
+ final ArchiveId id = ArchiveId.fromDocumentId(documentId);
+ if (mArchives.get(id.mArchiveUri) != null) {
+ return mArchives.get(id.mArchiveUri);
+ }
+
+ final Cursor cursor = getContext().getContentResolver().query(
+ id.mArchiveUri, new String[] { Document.COLUMN_MIME_TYPE }, null, null, null);
+ cursor.moveToFirst();
+ final String mimeType = cursor.getString(cursor.getColumnIndex(
+ Document.COLUMN_MIME_TYPE));
+ Preconditions.checkArgument(isSupportedArchiveType(mimeType));
+ final Uri notificationUri = cursor.getNotificationUri();
+ final Loader loader = new Loader(getContext(), id.mArchiveUri, notificationUri);
+
+ // Remove the instance from mArchives collection once the archive file changes.
+ if (notificationUri != null) {
+ final LruCache<Uri, Loader> finalArchives = mArchives;
+ getContext().getContentResolver().registerContentObserver(notificationUri,
+ false,
+ new ContentObserver(null) {
+ @Override
+ public void onChange(boolean selfChange, Uri uri) {
+ synchronized (mArchives) {
+ final Loader currentLoader = mArchives.get(id.mArchiveUri);
+ if (currentLoader == loader) {
+ mArchives.remove(id.mArchiveUri);
+ }
+ }
+ }
+ });
+ }
+
+ mArchives.put(id.mArchiveUri, loader);
+ return loader;
+ }
+
+ /**
+ * Loads an instance of Archive lazily.
+ */
+ private static final class Loader {
+ private final Context mContext;
+ private final Uri mArchiveUri;
+ private final Uri mNotificationUri;
+ private final ReentrantReadWriteLock mLock = new ReentrantReadWriteLock();
+ private Archive mArchive = null;
+
+ Loader(Context context, Uri archiveUri, Uri notificationUri) {
+ this.mContext = context;
+ this.mArchiveUri = archiveUri;
+ this.mNotificationUri = notificationUri;
+ }
+
+ synchronized Archive get() throws FileNotFoundException {
+ if (mArchive != null) {
+ return mArchive;
+ }
+
+ try {
+ mArchive = Archive.createForParcelFileDescriptor(
+ mContext,
+ mContext.getContentResolver().openFileDescriptor(
+ mArchiveUri, "r", null /* signal */),
+ mArchiveUri, mNotificationUri);
+ } catch (IOException e) {
+ throw new IllegalStateException(e);
+ }
+
+ return mArchive;
+ }
+
+ Lock getReadLock() {
+ return mLock.readLock();
+ }
+
+ Lock getWriteLock() {
+ return mLock.writeLock();
+ }
+ }
+}
diff --git a/src/com/android/documentsui/base/DocumentInfo.java b/src/com/android/documentsui/base/DocumentInfo.java
index e7932c4..f9e9b5c 100644
--- a/src/com/android/documentsui/base/DocumentInfo.java
+++ b/src/com/android/documentsui/base/DocumentInfo.java
@@ -28,6 +28,7 @@
import android.support.annotation.VisibleForTesting;
import com.android.documentsui.DocumentsApplication;
+import com.android.documentsui.archives.ArchivesProvider;
import com.android.documentsui.roots.RootCursorWrapper;
import java.io.DataInputStream;
@@ -256,7 +257,7 @@
}
public boolean isArchive() {
- return (flags & Document.FLAG_ARCHIVE) != 0;
+ return ArchivesProvider.isSupportedArchiveType(mimeType);
}
public boolean isPartial() {
diff --git a/src/com/android/documentsui/dirlist/GridDocumentHolder.java b/src/com/android/documentsui/dirlist/GridDocumentHolder.java
index f9a78d8..051114d 100644
--- a/src/com/android/documentsui/dirlist/GridDocumentHolder.java
+++ b/src/com/android/documentsui/dirlist/GridDocumentHolder.java
@@ -44,7 +44,7 @@
final TextView mTitle;
final TextView mDate;
- final TextView mSize;
+ final TextView mDetails;
final ImageView mIconMimeLg;
final ImageView mIconMimeSm;
final ImageView mIconThumb;
@@ -60,7 +60,7 @@
mTitle = (TextView) itemView.findViewById(android.R.id.title);
mDate = (TextView) itemView.findViewById(R.id.date);
- mSize = (TextView) itemView.findViewById(R.id.size);
+ mDetails = (TextView) itemView.findViewById(R.id.details);
mIconMimeLg = (ImageView) itemView.findViewById(R.id.icon_mime_lg);
mIconMimeSm = (ImageView) itemView.findViewById(R.id.icon_mime_sm);
mIconThumb = (ImageView) itemView.findViewById(R.id.icon_thumb);
@@ -135,7 +135,6 @@
final long docLastModified = getCursorLong(cursor, Document.COLUMN_LAST_MODIFIED);
final int docIcon = getCursorInt(cursor, Document.COLUMN_ICON);
final int docFlags = getCursorInt(cursor, Document.COLUMN_FLAGS);
- final long docSize = getCursorLong(cursor, Document.COLUMN_SIZE);
mIconHelper.stopLoading(mIconThumb);
@@ -155,17 +154,27 @@
mTitle.setVisibility(View.VISIBLE);
}
- if (docLastModified == -1) {
+ // If file is partial, we want to show summary field as that's more relevant than fileSize
+ // and date
+ if ((docFlags & Document.FLAG_PARTIAL) != 0) {
+ final String docSummary = getCursorString(cursor, Document.COLUMN_SUMMARY);
+ mDetails.setVisibility(View.VISIBLE);
mDate.setText(null);
+ mDetails.setText(docSummary);
} else {
- mDate.setText(Shared.formatTime(mContext, docLastModified));
- }
+ if (docLastModified == -1) {
+ mDate.setText(null);
+ } else {
+ mDate.setText(Shared.formatTime(mContext, docLastModified));
+ }
- if (Document.MIME_TYPE_DIR.equals(docMimeType) || docSize == -1) {
- mSize.setVisibility(View.GONE);
- } else {
- mSize.setVisibility(View.VISIBLE);
- mSize.setText(Formatter.formatFileSize(mContext, docSize));
+ final long docSize = getCursorLong(cursor, Document.COLUMN_SIZE);
+ if (Document.MIME_TYPE_DIR.equals(docMimeType) || docSize == -1) {
+ mDetails.setVisibility(View.GONE);
+ } else {
+ mDetails.setVisibility(View.VISIBLE);
+ mDetails.setText(Formatter.formatFileSize(mContext, docSize));
+ }
}
}
}
diff --git a/src/com/android/documentsui/picker/SaveFragment.java b/src/com/android/documentsui/picker/SaveFragment.java
index 3ce88be..7de983d 100644
--- a/src/com/android/documentsui/picker/SaveFragment.java
+++ b/src/com/android/documentsui/picker/SaveFragment.java
@@ -40,7 +40,7 @@
/**
* Display document title editor and save button.
*/
-class SaveFragment extends Fragment {
+public class SaveFragment extends Fragment {
public static final String TAG = "SaveFragment";
private DocumentInfo mReplaceTarget;
diff --git a/tests/common/com/android/documentsui/testing/TestDocumentsAccess.java b/tests/common/com/android/documentsui/testing/TestDocumentsAccess.java
index 40f799e..d5c317f 100644
--- a/tests/common/com/android/documentsui/testing/TestDocumentsAccess.java
+++ b/tests/common/com/android/documentsui/testing/TestDocumentsAccess.java
@@ -42,4 +42,9 @@
public DocumentInfo getDocument(Uri uri) {
return nextDocument;
}
+
+ @Override
+ public DocumentInfo getArchiveDocument(Uri uri) {
+ return nextDocument;
+ }
}
diff --git a/tests/unit/com/android/documentsui/files/ActionHandlerTest.java b/tests/unit/com/android/documentsui/files/ActionHandlerTest.java
index 7de09b1..bd58263 100644
--- a/tests/unit/com/android/documentsui/files/ActionHandlerTest.java
+++ b/tests/unit/com/android/documentsui/files/ActionHandlerTest.java
@@ -220,6 +220,7 @@
@Test
public void testDocumentPicked_OpensArchives() throws Exception {
mActivity.currentRoot = TestRootsAccess.HOME;
+ mEnv.docs.nextDocument = TestEnv.FILE_ARCHIVE;
mHandler.onDocumentPicked(TestEnv.FILE_ARCHIVE);
assertEquals(TestEnv.FILE_ARCHIVE, mEnv.state.stack.peek());