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
17package com.android.documentsui.archives;
18
19import android.content.ContentProviderClient;
20import android.content.ContentResolver;
21import android.content.Context;
22import android.content.res.AssetFileDescriptor;
23import android.content.res.Configuration;
24import android.database.ContentObserver;
25import android.database.Cursor;
26import android.database.MatrixCursor.RowBuilder;
27import android.database.MatrixCursor;
28import android.graphics.Point;
29import android.net.Uri;
30import android.os.Bundle;
31import android.os.CancellationSignal;
32import android.os.ParcelFileDescriptor;
33import android.provider.DocumentsContract.Document;
34import android.provider.DocumentsContract.Root;
35import android.provider.DocumentsContract;
36import android.provider.DocumentsProvider;
37import android.support.annotation.Nullable;
38import android.util.Log;
39
40import com.android.documentsui.R;
41import com.android.internal.annotations.GuardedBy;
42import com.android.internal.util.Preconditions;
43
44import java.io.Closeable;
45import java.io.File;
46import java.io.FileNotFoundException;
47import java.io.IOException;
48import java.util.HashMap;
49import java.util.Map;
50import java.util.Objects;
51import java.util.concurrent.locks.Lock;
52
53/**
54 * Provides basic implementation for creating, extracting and accessing
55 * files within archives exposed by a document provider.
56 *
57 * <p>This class is thread safe. All methods can be called on any thread without
58 * synchronization.
59 */
60public class ArchivesProvider extends DocumentsProvider {
61    public static final String AUTHORITY = "com.android.documentsui.archives";
62
63    private static final String[] DEFAULT_ROOTS_PROJECTION = new String[] {
64            Root.COLUMN_ROOT_ID, Root.COLUMN_DOCUMENT_ID, Root.COLUMN_TITLE, Root.COLUMN_FLAGS,
65            Root.COLUMN_ICON };
66    private static final String TAG = "ArchivesProvider";
67    private static final String METHOD_ACQUIRE_ARCHIVE = "acquireArchive";
68    private static final String METHOD_RELEASE_ARCHIVE = "releaseArchive";
69    private static final String[] ZIP_MIME_TYPES = {
70            "application/zip", "application/x-zip", "application/x-zip-compressed"
71    };
72
73    @GuardedBy("mArchives")
74    private final Map<Key, Loader> mArchives = new HashMap<Key, Loader>();
75
76    @Override
77    public Bundle call(String method, String arg, Bundle extras) {
78        if (METHOD_ACQUIRE_ARCHIVE.equals(method)) {
79            acquireArchive(arg);
80            return null;
81        }
82
83        if (METHOD_RELEASE_ARCHIVE.equals(method)) {
84            releaseArchive(arg);
85            return null;
86        }
87
88        return super.call(method, arg, extras);
89    }
90
91    @Override
92    public boolean onCreate() {
93        return true;
94    }
95
96    @Override
97    public Cursor queryRoots(String[] projection) {
98        // No roots provided.
99        return new MatrixCursor(projection != null ? projection : DEFAULT_ROOTS_PROJECTION);
100    }
101
102    @Override
103    public Cursor queryChildDocuments(String documentId, @Nullable String[] projection,
104            @Nullable String sortOrder)
105            throws FileNotFoundException {
106        final ArchiveId archiveId = ArchiveId.fromDocumentId(documentId);
107        final Loader loader = getLoaderOrThrow(documentId);
108        final int status = loader.getStatus();
109        // If already loaded, then forward the request to the archive.
110        if (status == Loader.STATUS_OPENED) {
111            return loader.get().queryChildDocuments(documentId, projection, sortOrder);
112        }
113
114        final MatrixCursor cursor = new MatrixCursor(
115                projection != null ? projection : Archive.DEFAULT_PROJECTION);
116        final Bundle bundle = new Bundle();
117
118        switch (status) {
119            case Loader.STATUS_OPENING:
120                bundle.putBoolean(DocumentsContract.EXTRA_LOADING, true);
121                break;
122
123            case Loader.STATUS_FAILED:
124                // Return an empty cursor with EXTRA_LOADING, which shows spinner
125                // in DocumentsUI. Once the archive is loaded, the notification will
126                // be sent, and the directory reloaded.
127                bundle.putString(DocumentsContract.EXTRA_ERROR,
128                        getContext().getString(R.string.archive_loading_failed));
129                break;
130        }
131
132        cursor.setExtras(bundle);
133        cursor.setNotificationUri(getContext().getContentResolver(),
134                buildUriForArchive(archiveId.mArchiveUri, archiveId.mAccessMode));
135        return cursor;
136    }
137
138    @Override
139    public String getDocumentType(String documentId) throws FileNotFoundException {
140        final ArchiveId archiveId = ArchiveId.fromDocumentId(documentId);
141        if (archiveId.mPath.equals("/")) {
142            return Document.MIME_TYPE_DIR;
143        }
144
145        final Loader loader = getLoaderOrThrow(documentId);
146        return loader.get().getDocumentType(documentId);
147    }
148
149    @Override
150    public boolean isChildDocument(String parentDocumentId, String documentId) {
151        final Loader loader = getLoaderOrThrow(documentId);
152        return loader.get().isChildDocument(parentDocumentId, documentId);
153    }
154
155    @Override
156    public Cursor queryDocument(String documentId, @Nullable String[] projection)
157            throws FileNotFoundException {
158        final ArchiveId archiveId = ArchiveId.fromDocumentId(documentId);
159        if (archiveId.mPath.equals("/")) {
160            try (final Cursor archiveCursor = getContext().getContentResolver().query(
161                    archiveId.mArchiveUri,
162                    new String[] { Document.COLUMN_DISPLAY_NAME },
163                    null, null, null, null)) {
164                if (archiveCursor == null || !archiveCursor.moveToFirst()) {
165                    throw new FileNotFoundException(
166                            "Cannot resolve display name of the archive.");
167                }
168                final String displayName = archiveCursor.getString(
169                        archiveCursor.getColumnIndex(Document.COLUMN_DISPLAY_NAME));
170
171                final MatrixCursor cursor = new MatrixCursor(
172                        projection != null ? projection : Archive.DEFAULT_PROJECTION);
173                final RowBuilder row = cursor.newRow();
174                row.add(Document.COLUMN_DOCUMENT_ID, documentId);
175                row.add(Document.COLUMN_DISPLAY_NAME, displayName);
176                row.add(Document.COLUMN_SIZE, 0);
177                row.add(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR);
178                return cursor;
179            }
180        }
181
182        final Loader loader = getLoaderOrThrow(documentId);
183        return loader.get().queryDocument(documentId, projection);
184    }
185
186    @Override
187    public String createDocument(
188            String parentDocumentId, String mimeType, String displayName)
189            throws FileNotFoundException {
190        final Loader loader = getLoaderOrThrow(parentDocumentId);
191        return loader.get().createDocument(parentDocumentId, mimeType, displayName);
192    }
193
194    @Override
195    public ParcelFileDescriptor openDocument(
196            String documentId, String mode, final CancellationSignal signal)
197            throws FileNotFoundException {
198        final Loader loader = getLoaderOrThrow(documentId);
199        return loader.get().openDocument(documentId, mode, signal);
200    }
201
202    @Override
203    public AssetFileDescriptor openDocumentThumbnail(
204            String documentId, Point sizeHint, final CancellationSignal signal)
205            throws FileNotFoundException {
206        final Loader loader = getLoaderOrThrow(documentId);
207        return loader.get().openDocumentThumbnail(documentId, sizeHint, signal);
208    }
209
210    /**
211     * Returns true if the passed mime type is supported by the helper.
212     */
213    public static boolean isSupportedArchiveType(String mimeType) {
214        for (final String zipMimeType : ZIP_MIME_TYPES) {
215            if (zipMimeType.equals(mimeType)) {
216                return true;
217            }
218        }
219        return false;
220    }
221
222    /**
223     * Creates a Uri for accessing an archive with the specified access mode.
224     *
225     * @see ParcelFileDescriptor#MODE_READ
226     * @see ParcelFileDescriptor#MODE_WRITE
227     */
228    public static Uri buildUriForArchive(Uri externalUri, int accessMode) {
229        return DocumentsContract.buildDocumentUri(AUTHORITY,
230                new ArchiveId(externalUri, accessMode, "/").toDocumentId());
231    }
232
233    /**
234     * Acquires an archive.
235     */
236    public static void acquireArchive(ContentProviderClient client, Uri archiveUri) {
237        Archive.MorePreconditions.checkArgumentEquals(AUTHORITY, archiveUri.getAuthority(),
238                "Mismatching authority. Expected: %s, actual: %s.");
239        final String documentId = DocumentsContract.getDocumentId(archiveUri);
240
241        try {
242            client.call(METHOD_ACQUIRE_ARCHIVE, documentId, null);
243        } catch (Exception e) {
244            Log.w(TAG, "Failed to acquire archive.", e);
245        }
246    }
247
248    /**
249     * Releases an archive.
250     */
251    public static void releaseArchive(ContentProviderClient client, Uri archiveUri) {
252        Archive.MorePreconditions.checkArgumentEquals(AUTHORITY, archiveUri.getAuthority(),
253                "Mismatching authority. Expected: %s, actual: %s.");
254        final String documentId = DocumentsContract.getDocumentId(archiveUri);
255
256        try {
257            client.call(METHOD_RELEASE_ARCHIVE, documentId, null);
258        } catch (Exception e) {
259            Log.w(TAG, "Failed to release archive.", e);
260        }
261    }
262
263    /**
264     * The archive won't close until all clients release it.
265     */
266    private void acquireArchive(String documentId) {
267        final ArchiveId archiveId = ArchiveId.fromDocumentId(documentId);
268        synchronized (mArchives) {
269            final Key key = Key.fromArchiveId(archiveId);
270            Loader loader = mArchives.get(key);
271            if (loader == null) {
272                // TODO: Pass parent Uri so the loader can acquire the parent's notification Uri.
273                loader = new Loader(getContext(), archiveId.mArchiveUri, archiveId.mAccessMode,
274                        null);
275                mArchives.put(key, loader);
276            }
277            loader.acquire();
278            mArchives.put(key, loader);
279        }
280    }
281
282    /**
283     * If all clients release the archive, then it will be closed.
284     */
285    private void releaseArchive(String documentId) {
286        final ArchiveId archiveId = ArchiveId.fromDocumentId(documentId);
287        final Key key = Key.fromArchiveId(archiveId);
288        synchronized (mArchives) {
289            final Loader loader = mArchives.get(key);
290            loader.release();
291            final int status = loader.getStatus();
292            if (status == Loader.STATUS_CLOSED || status == Loader.STATUS_CLOSING) {
293                mArchives.remove(key);
294            }
295        }
296    }
297
298    private Loader getLoaderOrThrow(String documentId) {
299        final ArchiveId id = ArchiveId.fromDocumentId(documentId);
300        final Key key = Key.fromArchiveId(id);
301        synchronized (mArchives) {
302            final Loader loader = mArchives.get(key);
303            if (loader == null) {
304                throw new IllegalStateException("Archive not acquired.");
305            }
306            return loader;
307        }
308    }
309
310    private static class Key {
311        Uri archiveUri;
312        int accessMode;
313
314        public Key(Uri archiveUri, int accessMode) {
315            this.archiveUri = archiveUri;
316            this.accessMode = accessMode;
317        }
318
319        public static Key fromArchiveId(ArchiveId id) {
320            return new Key(id.mArchiveUri, id.mAccessMode);
321        }
322
323        @Override
324        public boolean equals(Object other) {
325            if (other == null) {
326                return false;
327            }
328            if (!(other instanceof Key)) {
329                return false;
330            }
331            return archiveUri.equals(((Key) other).archiveUri) &&
332                accessMode == ((Key) other).accessMode;
333        }
334
335        @Override
336        public int hashCode() {
337            return Objects.hash(archiveUri, accessMode);
338        }
339    }
340}
341