Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit d683f975 authored by Tomasz Mikolajewski's avatar Tomasz Mikolajewski
Browse files

Refactor archives to support creating archives.

Archive class used StrictJarFile, which only supports reading.
This CL makes Archive a base class which is going to have two
subclasses: ReadableArchive (current Archive) and WriteableArchive.

ReadableArchive will be used to open archives with StrictJarFile.
WriteableArchive will be used to create archives with ZipOutputStream.

Test: Unit tests.
Bug: 20822019
Change-Id: I40174c8d970bc3098929854231622e3006f6263e
parent cb53bbbf
Loading
Loading
Loading
Loading
+3 −1
Original line number Diff line number Diff line
@@ -22,6 +22,7 @@ import android.content.ContentResolver;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.os.RemoteException;
import android.provider.DocumentsContract;
import android.provider.DocumentsContract.Path;
@@ -107,7 +108,8 @@ public interface DocumentsAccess {

        @Override
        public DocumentInfo getArchiveDocument(Uri uri) {
            return getDocument(ArchivesProvider.buildUriForArchive(uri));
            return getDocument(ArchivesProvider.buildUriForArchive(uri,
                    ParcelFileDescriptor.MODE_READ_ONLY));
        }

        @Override
+25 −285
Original line number Diff line number Diff line
@@ -21,44 +21,26 @@ 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.support.annotation.Nullable;
import android.support.annotation.VisibleForTesting;
import android.system.ErrnoException;
import android.system.Os;
import android.system.OsConstants;
import android.text.TextUtils;
import android.util.Log;
import android.util.jar.StrictJarFile;
import android.webkit.MimeTypeMap;

import com.android.internal.util.Preconditions;

import libcore.io.IoUtils;

import java.io.Closeable;
import java.io.File;
import java.io.FileDescriptor;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
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.Executors;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
@@ -70,7 +52,7 @@ import java.util.zip.ZipEntry;
 *
 * <p>This class is thread safe.
 */
public class Archive implements Closeable {
public abstract class Archive implements Closeable {
    private static final String TAG = "Archive";

    public static final String[] DEFAULT_PROJECTION = new String[] {
@@ -81,27 +63,23 @@ public class Archive implements Closeable {
            Document.COLUMN_FLAGS
    };

    private final Context mContext;
    private final Uri mArchiveUri;
    private final Uri mNotificationUri;
    private final StrictJarFile mZipFile;
    private final ThreadPoolExecutor mExecutor;
    private final Map<String, ZipEntry> mEntries;
    private final Map<String, List<ZipEntry>> mTree;
    final Context mContext;
    final Uri mArchiveUri;
    final int mArchiveMode;
    final Uri mNotificationUri;
    final ThreadPoolExecutor mExecutor;
    final Map<String, ZipEntry> mEntries;
    final Map<String, List<ZipEntry>> mTree;

    private Archive(
    Archive(
            Context context,
            @Nullable File file,
            @Nullable FileDescriptor fd,
            Uri archiveUri,
            @Nullable Uri notificationUri)
            throws IOException {
            int archiveMode,
            @Nullable Uri notificationUri) {
        mContext = context;
        mArchiveUri = archiveUri;
        mArchiveMode = archiveMode;
        mNotificationUri = notificationUri;
        mZipFile = file != null ?
                new StrictJarFile(file.getPath(), false /* verify */, false /* signatures */) :
                new StrictJarFile(fd, false /* verify */, false /* signatures */);

        // At most 8 active threads. All threads idling for more than a minute will
        // be closed.
@@ -109,68 +87,8 @@ public class Archive implements Closeable {
                new LinkedBlockingQueue<Runnable>());
        mExecutor.allowCoreThreadTimeOut(true);

        // Build the tree structure in memory.
        mTree = new HashMap<>();

        mEntries = new HashMap<>();
        ZipEntry entry;
        String entryPath;
        final Iterator<ZipEntry> it = mZipFile.iterator();
        final Stack<ZipEntry> stack = new Stack<>();
        while (it.hasNext()) {
            entry = it.next();
            if (entry.isDirectory() != entry.getName().endsWith("/")) {
                throw new IOException(
                        "Directories must have a trailing slash, and files must not.");
            }
            entryPath = getEntryPath(entry);
            if (mEntries.containsKey(entryPath)) {
                throw new IOException("Multiple entries with the same name are not supported.");
            }
            mEntries.put(entryPath, entry);
            if (entry.isDirectory()) {
                mTree.put(entryPath, new ArrayList<ZipEntry>());
            }
            if (!"/".equals(entryPath)) { // Skip root, as it doesn't have a parent.
                stack.push(entry);
            }
        }

        int delimiterIndex;
        String parentPath;
        ZipEntry parentEntry;
        List<ZipEntry> parentList;

        // Go through all directories recursively and build a tree structure.
        while (stack.size() > 0) {
            entry = stack.pop();

            entryPath = getEntryPath(entry);
            delimiterIndex = entryPath.lastIndexOf('/', entry.isDirectory()
                    ? entryPath.length() - 2 : entryPath.length() - 1);
            parentPath = entryPath.substring(0, delimiterIndex) + "/";

            parentList = mTree.get(parentPath);

            if (parentList == 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);

                if (!"/".equals(parentPath)) {
                    stack.push(parentEntry);
                }

                parentList = new ArrayList<>();
                mTree.put(parentPath, parentList);
            }

            parentList.add(entry);
        }
    }

    /**
@@ -190,7 +108,6 @@ public class Archive implements Closeable {
     * Returns true if the file descriptor is seekable.
     * @param descriptor File descriptor to check.
     */
    @VisibleForTesting
    public static boolean canSeek(ParcelFileDescriptor descriptor) {
        try {
            return Os.lseek(descriptor.getFileDescriptor(), 0,
@@ -200,75 +117,6 @@ public class Archive implements Closeable {
        }
    }

    /**
     * Creates a DocumentsArchive instance for opening, browsing and accessing
     * documents within the archive passed as a file descriptor.
     *
     * If the file descriptor is not seekable, then a snapshot will be created.
     *
     * This method takes ownership for the passed descriptor. The caller must
     * not close it.
     *
     * @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 {
        FileDescriptor fd = null;
        try {
            if (canSeek(descriptor)) {
                fd = new FileDescriptor();
                fd.setInt$(descriptor.detachFd());
                return new Archive(context, null, fd, archiveUri,
                        notificationUri);
            }

            // Fallback for non-seekable file descriptors.
            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, null, 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();
                }
            }
        } catch (Exception e) {
            // Since the method takes ownership of the passed descriptor, close it
            // on exception.
            IoUtils.closeQuietly(descriptor);
            IoUtils.closeQuietly(fd);
            throw e;
        }
    }

    /**
     * Lists child documents of an archive or a directory within an
     * archive. Must be called only for archives with supported mime type,
@@ -376,127 +224,24 @@ public class Archive implements Closeable {
     *
     * @see DocumentsProvider.openDocument(String, String, CancellationSignal))
     */
    public ParcelFileDescriptor openDocument(
    abstract 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];
    }
            throws FileNotFoundException;

    /**
     * Opens a thumbnail of a file within an archive.
     *
     * @see DocumentsProvider.openDocumentThumbnail(String, Point, CancellationSignal))
     */
    public AssetFileDescriptor openDocumentThumbnail(
    abstract 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();
        }
            throws 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);
    /**
     * Creates an archive id for the passed path.
     */
    public ArchiveId createArchiveId(String path) {
        return new ArchiveId(mArchiveUri, mArchiveMode, path);
    }

    /**
@@ -508,16 +253,11 @@ public class Archive implements Closeable {
    @Override
    public void close() {
        mExecutor.shutdownNow();
        try {
            mZipFile.close();
        } catch (IOException e) {
            // Silent close.
        }
    }

    private void addCursorRow(MatrixCursor cursor, ZipEntry entry) {
    void addCursorRow(MatrixCursor cursor, ZipEntry entry) {
        final MatrixCursor.RowBuilder row = cursor.newRow();
        final ArchiveId parsedId = new ArchiveId(mArchiveUri, getEntryPath(entry));
        final ArchiveId parsedId = createArchiveId(getEntryPath(entry));
        row.add(Document.COLUMN_DOCUMENT_ID, parsedId.toDocumentId());

        final File file = new File(entry.getName());
@@ -531,7 +271,7 @@ public class Archive implements Closeable {
        row.add(Document.COLUMN_FLAGS, flags);
    }

    private String getMimeTypeForEntry(ZipEntry entry) {
    static String getMimeTypeForEntry(ZipEntry entry) {
        if (entry.isDirectory()) {
            return Document.MIME_TYPE_DIR;
        }
@@ -549,7 +289,7 @@ public class Archive implements Closeable {
    }

    // TODO: Upstream to the Preconditions class.
    private static class MorePreconditions {
    static class MorePreconditions {
        static void checkArgumentEquals(String expected, @Nullable String actual,
                String message) {
            if (!TextUtils.equals(expected, actual)) {
+19 −5
Original line number Diff line number Diff line
@@ -22,22 +22,36 @@ public class ArchiveId {
    private final static char DELIMITER = '#';

    public final Uri mArchiveUri;
    public final int mAccessMode;
    public final String mPath;

    public ArchiveId(Uri archiveUri, String path) {
    public ArchiveId(Uri archiveUri, int accessMode, String path) {
        assert(archiveUri.toString().indexOf(DELIMITER) == -1);
        assert(!path.isEmpty());

        mArchiveUri = archiveUri;
        mAccessMode = accessMode;
        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)));

        final int secondDelimiterPosition = documentId.indexOf(DELIMITER, delimiterPosition + 1);
        assert(secondDelimiterPosition != -1);

        final String archiveUriPart = documentId.substring(0, delimiterPosition);
        final String accessModePart = documentId.substring(delimiterPosition + 1,
                secondDelimiterPosition);

        final String pathPart = documentId.substring(secondDelimiterPosition + 1);

        return new ArchiveId(Uri.parse(archiveUriPart), Integer.parseInt(accessModePart),
                pathPart);
    }

    public String toDocumentId() {
        return mArchiveUri.toString() + DELIMITER + mPath;
        return mArchiveUri.toString() + DELIMITER + mAccessMode + DELIMITER + mPath;
    }
};
+13 −7
Original line number Diff line number Diff line
@@ -121,7 +121,7 @@ public class ArchivesProvider extends DocumentsProvider implements Closeable {

            cursor.setExtras(bundle);
            cursor.setNotificationUri(getContext().getContentResolver(),
                    buildUriForArchive(archiveId.mArchiveUri));
                    buildUriForArchive(archiveId.mArchiveUri, archiveId.mAccessMode));
            return cursor;
        } finally {
            releaseInstance(loader);
@@ -231,9 +231,15 @@ public class ArchivesProvider extends DocumentsProvider implements Closeable {
        return false;
    }

    public static Uri buildUriForArchive(Uri archiveUri) {
        return DocumentsContract.buildDocumentUri(
                AUTHORITY, new ArchiveId(archiveUri, "/").toDocumentId());
    /**
     * Creates a Uri for accessing an archive with the specified access mode.
     *
     * @see ParcelFileDescriptor#MODE_READ
     * @see ParcelFileDescriptor#MODE_WRITE
     */
    public static Uri buildUriForArchive(Uri archiveUri, int accessMode) {
        return DocumentsContract.buildDocumentUri(AUTHORITY,
                new ArchiveId(archiveUri, accessMode, "/").toDocumentId());
    }

    /**
@@ -263,8 +269,7 @@ public class ArchivesProvider extends DocumentsProvider implements Closeable {
        }
    }

    private Loader getInstanceUncheckedLocked(String documentId)
            throws FileNotFoundException {
    private Loader getInstanceUncheckedLocked(String documentId) throws FileNotFoundException {
        final ArchiveId id = ArchiveId.fromDocumentId(documentId);
        if (mArchives.get(id.mArchiveUri) != null) {
            return mArchives.get(id.mArchiveUri);
@@ -281,7 +286,8 @@ public class ArchivesProvider extends DocumentsProvider implements Closeable {
                Document.COLUMN_MIME_TYPE));
        Preconditions.checkArgument(isSupportedArchiveType(mimeType));
        final Uri notificationUri = cursor.getNotificationUri();
        final Loader loader = new Loader(getContext(), id.mArchiveUri, notificationUri);
        final Loader loader = new Loader(getContext(), id.mArchiveUri, id.mAccessMode,
                notificationUri);

        // Remove the instance from mArchives collection once the archive file changes.
        if (notificationUri != null) {
+15 −7
Original line number Diff line number Diff line
@@ -42,6 +42,7 @@ public class Loader {

    private final Context mContext;
    private final Uri mArchiveUri;
    private final int mAccessMode;
    private final Uri mNotificationUri;
    private final ReentrantReadWriteLock mLock = new ReentrantReadWriteLock();
    private final ExecutorService mExecutor = Executors.newSingleThreadExecutor();
@@ -50,9 +51,10 @@ public class Loader {
    private int mStatus = STATUS_OPENING;
    private Archive mArchive = null;

    Loader(Context context, Uri archiveUri, Uri notificationUri) {
    Loader(Context context, Uri archiveUri, int accessMode, Uri notificationUri) {
        this.mContext = context;
        this.mArchiveUri = archiveUri;
        this.mAccessMode = accessMode;
        this.mNotificationUri = notificationUri;

        // Start loading the archive immediately in the background.
@@ -77,11 +79,17 @@ public class Loader {
        }

        try {
            mArchive = Archive.createForParcelFileDescriptor(
            if (ReadableArchive.supportsAccessMode(mAccessMode)) {
                mArchive = ReadableArchive.createForParcelFileDescriptor(
                        mContext,
                        mContext.getContentResolver().openFileDescriptor(
                                mArchiveUri, "r", null /* signal */),
                    mArchiveUri, mNotificationUri);
                        mArchiveUri, mAccessMode, mNotificationUri);
            // TODO:
            // } else if (WriteableArchive.supportsAccessMode(mAccessMode)) {
            } else {
                throw new IllegalStateException("Access mode not supported.");
            }
            synchronized (mStatusLock) {
                mStatus = STATUS_OPENED;
            }
@@ -95,7 +103,7 @@ public class Loader {
            // Notify observers that the root directory is loaded (or failed)
            // so clients reload it.
            mContext.getContentResolver().notifyChange(
                    ArchivesProvider.buildUriForArchive(mArchiveUri),
                    ArchivesProvider.buildUriForArchive(mArchiveUri, mAccessMode),
                    null /* observer */, false /* syncToNetwork */);
        }

Loading