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 17 package android.support.provider; 18 19 import android.content.res.AssetFileDescriptor; 20 import android.content.res.Configuration; 21 import android.database.ContentObserver; 22 import android.database.Cursor; 23 import android.database.MatrixCursor; 24 import android.graphics.Point; 25 import android.net.Uri; 26 import android.os.CancellationSignal; 27 import android.os.ParcelFileDescriptor; 28 import android.provider.DocumentsContract.Document; 29 import android.provider.DocumentsProvider; 30 import android.support.annotation.Nullable; 31 import android.util.Log; 32 import android.util.LruCache; 33 34 import java.io.Closeable; 35 import java.io.File; 36 import java.io.FileNotFoundException; 37 import java.io.IOException; 38 import java.util.HashMap; 39 import java.util.Map; 40 import java.util.concurrent.Callable; 41 import java.util.concurrent.locks.Lock; 42 import java.util.concurrent.locks.ReadWriteLock; 43 import java.util.concurrent.locks.ReentrantReadWriteLock; 44 45 /** 46 * Provides basic implementation for creating, extracting and accessing 47 * files within archives exposed by a document provider. 48 * 49 * <p>This class is thread safe. All methods can be called on any thread without 50 * synchronization. 51 * 52 * TODO: Update the documentation. b/26047732 53 * @hide 54 */ 55 public class DocumentArchiveHelper implements Closeable { 56 /** 57 * Cursor column to be used for passing the local file path for documents. 58 * If it's not specified, then a snapshot will be created, which is slower 59 * and consumes more resources. 60 * 61 * <p>Type: STRING 62 */ 63 public static final String COLUMN_LOCAL_FILE_PATH = "local_file_path"; 64 65 private static final String TAG = "DocumentArchiveHelper"; 66 private static final int OPENED_ARCHIVES_CACHE_SIZE = 4; 67 private static final String[] ZIP_MIME_TYPES = { 68 "application/zip", "application/x-zip", "application/x-zip-compressed" 69 }; 70 71 private final DocumentsProvider mProvider; 72 private final char mIdDelimiter; 73 74 // @GuardedBy("mArchives") 75 private final LruCache<String, Loader> mArchives = 76 new LruCache<String, Loader>(OPENED_ARCHIVES_CACHE_SIZE) { 77 @Override 78 public void entryRemoved(boolean evicted, String key, 79 Loader oldValue, Loader newValue) { 80 oldValue.getWriteLock().lock(); 81 try { 82 oldValue.get().close(); 83 } catch (FileNotFoundException e) { 84 Log.e(TAG, "Failed to close an archive as it no longer exists."); 85 } finally { 86 oldValue.getWriteLock().unlock(); 87 } 88 } 89 }; 90 91 /** 92 * Creates a helper for handling archived documents. 93 * 94 * @param provider Instance of a documents provider which provides archived documents. 95 * @param idDelimiter A character used to create document IDs within archives. Can be any 96 * character which is not used in any other document ID. If your provider uses 97 * numbers as document IDs, the delimiter can be eg. a colon. However if your 98 * provider uses paths, then a delimiter can be any character not allowed in the 99 * path, which is often \0. 100 */ 101 public DocumentArchiveHelper(DocumentsProvider provider, char idDelimiter) { 102 mProvider = provider; 103 mIdDelimiter = idDelimiter; 104 } 105 106 /** 107 * Lists child documents of an archive or a directory within an 108 * archive. Must be called only for archives with supported mime type, 109 * or for documents within archives. 110 * 111 * @see DocumentsProvider.queryChildDocuments(String, String[], String) 112 */ 113 public Cursor queryChildDocuments(String documentId, @Nullable String[] projection, 114 @Nullable String sortOrder) 115 throws FileNotFoundException { 116 Loader loader = null; 117 try { 118 loader = obtainInstance(documentId); 119 return loader.get().queryChildDocuments(documentId, projection, sortOrder); 120 } finally { 121 releaseInstance(loader); 122 } 123 } 124 125 /** 126 * Returns a MIME type of a document within an archive. 127 * 128 * @see DocumentsProvider.getDocumentType(String) 129 */ 130 public String getDocumentType(String documentId) throws FileNotFoundException { 131 Loader loader = null; 132 try { 133 loader = obtainInstance(documentId); 134 return loader.get().getDocumentType(documentId); 135 } finally { 136 releaseInstance(loader); 137 } 138 } 139 140 /** 141 * Returns true if a document within an archive is a child or any descendant of the archive 142 * document or another document within the archive. 143 * 144 * @see DocumentsProvider.isChildDocument(String, String) 145 */ 146 public boolean isChildDocument(String parentDocumentId, String documentId) { 147 Loader loader = null; 148 try { 149 loader = obtainInstance(documentId); 150 return loader.get().isChildDocument(parentDocumentId, documentId); 151 } catch (FileNotFoundException e) { 152 throw new IllegalStateException(e); 153 } finally { 154 releaseInstance(loader); 155 } 156 } 157 158 /** 159 * Returns metadata of a document within an archive. 160 * 161 * @see DocumentsProvider.queryDocument(String, String[]) 162 */ 163 public Cursor queryDocument(String documentId, @Nullable String[] projection) 164 throws FileNotFoundException { 165 Loader loader = null; 166 try { 167 loader = obtainInstance(documentId); 168 return loader.get().queryDocument(documentId, projection); 169 } finally { 170 releaseInstance(loader); 171 } 172 } 173 174 /** 175 * Opens a file within an archive. 176 * 177 * @see DocumentsProvider.openDocument(String, String, CancellationSignal)) 178 */ 179 public ParcelFileDescriptor openDocument( 180 String documentId, String mode, final CancellationSignal signal) 181 throws FileNotFoundException { 182 Loader loader = null; 183 try { 184 loader = obtainInstance(documentId); 185 return loader.get().openDocument(documentId, mode, signal); 186 } finally { 187 releaseInstance(loader); 188 } 189 } 190 191 /** 192 * Opens a thumbnail of a file within an archive. 193 * 194 * @see DocumentsProvider.openDocumentThumbnail(String, Point, CancellationSignal)) 195 */ 196 public AssetFileDescriptor openDocumentThumbnail( 197 String documentId, Point sizeHint, final CancellationSignal signal) 198 throws FileNotFoundException { 199 Loader loader = null; 200 try { 201 loader = obtainInstance(documentId); 202 return loader.get().openDocumentThumbnail(documentId, sizeHint, signal); 203 } finally { 204 releaseInstance(loader); 205 } 206 } 207 208 /** 209 * Returns true if the passed document ID is for a document within an archive. 210 */ 211 public boolean isArchivedDocument(String documentId) { 212 return ParsedDocumentId.hasPath(documentId, mIdDelimiter); 213 } 214 215 /** 216 * Returns true if the passed mime type is supported by the helper. 217 */ 218 public boolean isSupportedArchiveType(String mimeType) { 219 for (final String zipMimeType : ZIP_MIME_TYPES) { 220 if (zipMimeType.equals(mimeType)) { 221 return true; 222 } 223 } 224 return false; 225 } 226 227 /** 228 * Closes the helper and disposes all existing archives. It will block until all ongoing 229 * operations on each opened archive are finished. 230 */ 231 @Override 232 public void close() { 233 synchronized (mArchives) { 234 mArchives.evictAll(); 235 } 236 } 237 238 /** 239 * Releases resources for an archive with the specified document ID. It will block until all 240 * operations on the archive are finished. If not opened, the method does nothing. 241 * 242 * <p>Calling this method is optional. The helper automatically closes the least recently used 243 * archives if too many archives are opened. 244 * 245 * @param archiveDocumentId ID of the archive file. 246 */ 247 public void closeArchive(String documentId) { 248 synchronized (mArchives) { 249 mArchives.remove(documentId); 250 } 251 } 252 253 private Loader obtainInstance(String documentId) throws FileNotFoundException { 254 Loader loader; 255 synchronized (mArchives) { 256 loader = getInstanceUncheckedLocked(documentId); 257 loader.getReadLock().lock(); 258 } 259 return loader; 260 } 261 262 private void releaseInstance(@Nullable Loader loader) { 263 if (loader != null) { 264 loader.getReadLock().unlock(); 265 } 266 } 267 268 private Loader getInstanceUncheckedLocked(String documentId) 269 throws FileNotFoundException { 270 try { 271 final ParsedDocumentId id = ParsedDocumentId.fromDocumentId(documentId, mIdDelimiter); 272 if (mArchives.get(id.mArchiveId) != null) { 273 return mArchives.get(id.mArchiveId); 274 } 275 276 final Cursor cursor = mProvider.queryDocument(id.mArchiveId, new String[] 277 { Document.COLUMN_MIME_TYPE, COLUMN_LOCAL_FILE_PATH }); 278 cursor.moveToFirst(); 279 final String mimeType = cursor.getString(cursor.getColumnIndex( 280 Document.COLUMN_MIME_TYPE)); 281 Preconditions.checkArgument(isSupportedArchiveType(mimeType), 282 "Unsupported archive type."); 283 final int columnIndex = cursor.getColumnIndex(COLUMN_LOCAL_FILE_PATH); 284 final String localFilePath = columnIndex != -1 ? cursor.getString(columnIndex) : null; 285 final File localFile = localFilePath != null ? new File(localFilePath) : null; 286 final Uri notificationUri = cursor.getNotificationUri(); 287 final Loader loader = new Loader(mProvider, localFile, id, mIdDelimiter, 288 notificationUri); 289 290 // Remove the instance from mArchives collection once the archive file changes. 291 if (notificationUri != null) { 292 final LruCache<String, Loader> finalArchives = mArchives; 293 mProvider.getContext().getContentResolver().registerContentObserver(notificationUri, 294 false, 295 new ContentObserver(null) { 296 @Override 297 public void onChange(boolean selfChange, Uri uri) { 298 synchronized (mArchives) { 299 final Loader currentLoader = mArchives.get(id.mArchiveId); 300 if (currentLoader == loader) { 301 mArchives.remove(id.mArchiveId); 302 } 303 } 304 } 305 }); 306 } 307 308 mArchives.put(id.mArchiveId, loader); 309 return loader; 310 } catch (IOException e) { 311 // DocumentsProvider doesn't use IOException. For consistency convert it to 312 // IllegalStateException. 313 throw new IllegalStateException(e); 314 } 315 } 316 317 /** 318 * Loads an instance of DocumentArchive lazily. 319 */ 320 private static final class Loader { 321 private final DocumentsProvider mProvider; 322 private final File mLocalFile; 323 private final ParsedDocumentId mId; 324 private final char mIdDelimiter; 325 private final Uri mNotificationUri; 326 private final ReentrantReadWriteLock mLock = new ReentrantReadWriteLock(); 327 private DocumentArchive mArchive = null; 328 329 Loader(DocumentsProvider provider, @Nullable File localFile, ParsedDocumentId id, 330 char idDelimiter, Uri notificationUri) { 331 this.mProvider = provider; 332 this.mLocalFile = localFile; 333 this.mId = id; 334 this.mIdDelimiter = idDelimiter; 335 this.mNotificationUri = notificationUri; 336 } 337 338 synchronized DocumentArchive get() throws FileNotFoundException { 339 if (mArchive != null) { 340 return mArchive; 341 } 342 343 try { 344 if (mLocalFile != null) { 345 mArchive = DocumentArchive.createForLocalFile( 346 mProvider.getContext(), mLocalFile, mId.mArchiveId, mIdDelimiter, 347 mNotificationUri); 348 } else { 349 mArchive = DocumentArchive.createForParcelFileDescriptor( 350 mProvider.getContext(), 351 mProvider.openDocument(mId.mArchiveId, "r", null /* signal */), 352 mId.mArchiveId, mIdDelimiter, mNotificationUri); 353 } 354 } catch (IOException e) { 355 throw new IllegalStateException(e); 356 } 357 358 return mArchive; 359 } 360 361 Lock getReadLock() { 362 return mLock.readLock(); 363 } 364 365 Lock getWriteLock() { 366 return mLock.writeLock(); 367 } 368 } 369 } 370