1 /* 2 * Copyright (C) 2013 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 com.android.externalstorage; 18 19 import android.content.ContentResolver; 20 import android.content.Context; 21 import android.content.Intent; 22 import android.content.res.AssetFileDescriptor; 23 import android.database.Cursor; 24 import android.database.MatrixCursor; 25 import android.database.MatrixCursor.RowBuilder; 26 import android.graphics.Point; 27 import android.net.Uri; 28 import android.os.CancellationSignal; 29 import android.os.Environment; 30 import android.os.FileObserver; 31 import android.os.FileUtils; 32 import android.os.Handler; 33 import android.os.ParcelFileDescriptor; 34 import android.os.ParcelFileDescriptor.OnCloseListener; 35 import android.os.storage.StorageManager; 36 import android.os.storage.StorageVolume; 37 import android.provider.DocumentsContract; 38 import android.provider.DocumentsContract.Document; 39 import android.provider.DocumentsContract.Root; 40 import android.provider.DocumentsProvider; 41 import android.text.TextUtils; 42 import android.util.Log; 43 import android.webkit.MimeTypeMap; 44 45 import com.android.internal.annotations.GuardedBy; 46 import com.google.android.collect.Lists; 47 import com.google.android.collect.Maps; 48 49 import java.io.File; 50 import java.io.FileNotFoundException; 51 import java.io.IOException; 52 import java.util.ArrayList; 53 import java.util.HashMap; 54 import java.util.LinkedList; 55 import java.util.Map; 56 57 public class ExternalStorageProvider extends DocumentsProvider { 58 private static final String TAG = "ExternalStorage"; 59 60 private static final boolean LOG_INOTIFY = false; 61 62 public static final String AUTHORITY = "com.android.externalstorage.documents"; 63 64 // docId format: root:path/to/file 65 66 private static final String[] DEFAULT_ROOT_PROJECTION = new String[] { 67 Root.COLUMN_ROOT_ID, Root.COLUMN_FLAGS, Root.COLUMN_ICON, Root.COLUMN_TITLE, 68 Root.COLUMN_DOCUMENT_ID, Root.COLUMN_AVAILABLE_BYTES, 69 }; 70 71 private static final String[] DEFAULT_DOCUMENT_PROJECTION = new String[] { 72 Document.COLUMN_DOCUMENT_ID, Document.COLUMN_MIME_TYPE, Document.COLUMN_DISPLAY_NAME, 73 Document.COLUMN_LAST_MODIFIED, Document.COLUMN_FLAGS, Document.COLUMN_SIZE, 74 }; 75 76 private static class RootInfo { 77 public String rootId; 78 public int flags; 79 public String title; 80 public String docId; 81 } 82 83 private static final String ROOT_ID_PRIMARY_EMULATED = "primary"; 84 85 private StorageManager mStorageManager; 86 private Handler mHandler; 87 88 private final Object mRootsLock = new Object(); 89 90 @GuardedBy("mRootsLock") 91 private ArrayList<RootInfo> mRoots; 92 @GuardedBy("mRootsLock") 93 private HashMap<String, RootInfo> mIdToRoot; 94 @GuardedBy("mRootsLock") 95 private HashMap<String, File> mIdToPath; 96 97 @GuardedBy("mObservers") 98 private Map<File, DirectoryObserver> mObservers = Maps.newHashMap(); 99 100 @Override 101 public boolean onCreate() { 102 mStorageManager = (StorageManager) getContext().getSystemService(Context.STORAGE_SERVICE); 103 mHandler = new Handler(); 104 105 mRoots = Lists.newArrayList(); 106 mIdToRoot = Maps.newHashMap(); 107 mIdToPath = Maps.newHashMap(); 108 109 updateVolumes(); 110 111 return true; 112 } 113 114 public void updateVolumes() { 115 synchronized (mRootsLock) { 116 updateVolumesLocked(); 117 } 118 } 119 120 private void updateVolumesLocked() { 121 mRoots.clear(); 122 mIdToPath.clear(); 123 mIdToRoot.clear(); 124 125 final StorageVolume[] volumes = mStorageManager.getVolumeList(); 126 for (StorageVolume volume : volumes) { 127 final boolean mounted = Environment.MEDIA_MOUNTED.equals(volume.getState()) 128 || Environment.MEDIA_MOUNTED_READ_ONLY.equals(volume.getState()); 129 if (!mounted) continue; 130 131 final String rootId; 132 if (volume.isPrimary() && volume.isEmulated()) { 133 rootId = ROOT_ID_PRIMARY_EMULATED; 134 } else if (volume.getUuid() != null) { 135 rootId = volume.getUuid(); 136 } else { 137 Log.d(TAG, "Missing UUID for " + volume.getPath() + "; skipping"); 138 continue; 139 } 140 141 if (mIdToPath.containsKey(rootId)) { 142 Log.w(TAG, "Duplicate UUID " + rootId + "; skipping"); 143 continue; 144 } 145 146 try { 147 final File path = volume.getPathFile(); 148 mIdToPath.put(rootId, path); 149 150 final RootInfo root = new RootInfo(); 151 root.rootId = rootId; 152 root.flags = Root.FLAG_SUPPORTS_CREATE | Root.FLAG_LOCAL_ONLY | Root.FLAG_ADVANCED 153 | Root.FLAG_SUPPORTS_SEARCH | Root.FLAG_SUPPORTS_IS_CHILD; 154 if (ROOT_ID_PRIMARY_EMULATED.equals(rootId)) { 155 root.title = getContext().getString(R.string.root_internal_storage); 156 } else { 157 root.title = volume.getUserLabel(); 158 } 159 root.docId = getDocIdForFile(path); 160 mRoots.add(root); 161 mIdToRoot.put(rootId, root); 162 } catch (FileNotFoundException e) { 163 throw new IllegalStateException(e); 164 } 165 } 166 167 Log.d(TAG, "After updating volumes, found " + mRoots.size() + " active roots"); 168 169 getContext().getContentResolver() 170 .notifyChange(DocumentsContract.buildRootsUri(AUTHORITY), null, false); 171 } 172 173 private static String[] resolveRootProjection(String[] projection) { 174 return projection != null ? projection : DEFAULT_ROOT_PROJECTION; 175 } 176 177 private static String[] resolveDocumentProjection(String[] projection) { 178 return projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION; 179 } 180 181 private String getDocIdForFile(File file) throws FileNotFoundException { 182 String path = file.getAbsolutePath(); 183 184 // Find the most-specific root path 185 Map.Entry<String, File> mostSpecific = null; 186 synchronized (mRootsLock) { 187 for (Map.Entry<String, File> root : mIdToPath.entrySet()) { 188 final String rootPath = root.getValue().getPath(); 189 if (path.startsWith(rootPath) && (mostSpecific == null 190 || rootPath.length() > mostSpecific.getValue().getPath().length())) { 191 mostSpecific = root; 192 } 193 } 194 } 195 196 if (mostSpecific == null) { 197 throw new FileNotFoundException("Failed to find root that contains " + path); 198 } 199 200 // Start at first char of path under root 201 final String rootPath = mostSpecific.getValue().getPath(); 202 if (rootPath.equals(path)) { 203 path = ""; 204 } else if (rootPath.endsWith("/")) { 205 path = path.substring(rootPath.length()); 206 } else { 207 path = path.substring(rootPath.length() + 1); 208 } 209 210 return mostSpecific.getKey() + ':' + path; 211 } 212 213 private File getFileForDocId(String docId) throws FileNotFoundException { 214 final int splitIndex = docId.indexOf(':', 1); 215 final String tag = docId.substring(0, splitIndex); 216 final String path = docId.substring(splitIndex + 1); 217 218 File target; 219 synchronized (mRootsLock) { 220 target = mIdToPath.get(tag); 221 } 222 if (target == null) { 223 throw new FileNotFoundException("No root for " + tag); 224 } 225 if (!target.exists()) { 226 target.mkdirs(); 227 } 228 target = new File(target, path); 229 if (!target.exists()) { 230 throw new FileNotFoundException("Missing file for " + docId + " at " + target); 231 } 232 return target; 233 } 234 235 private void includeFile(MatrixCursor result, String docId, File file) 236 throws FileNotFoundException { 237 if (docId == null) { 238 docId = getDocIdForFile(file); 239 } else { 240 file = getFileForDocId(docId); 241 } 242 243 int flags = 0; 244 245 if (file.canWrite()) { 246 if (file.isDirectory()) { 247 flags |= Document.FLAG_DIR_SUPPORTS_CREATE; 248 flags |= Document.FLAG_SUPPORTS_DELETE; 249 flags |= Document.FLAG_SUPPORTS_RENAME; 250 } else { 251 flags |= Document.FLAG_SUPPORTS_WRITE; 252 flags |= Document.FLAG_SUPPORTS_DELETE; 253 flags |= Document.FLAG_SUPPORTS_RENAME; 254 } 255 } 256 257 final String displayName = file.getName(); 258 final String mimeType = getTypeForFile(file); 259 if (mimeType.startsWith("image/")) { 260 flags |= Document.FLAG_SUPPORTS_THUMBNAIL; 261 } 262 263 final RowBuilder row = result.newRow(); 264 row.add(Document.COLUMN_DOCUMENT_ID, docId); 265 row.add(Document.COLUMN_DISPLAY_NAME, displayName); 266 row.add(Document.COLUMN_SIZE, file.length()); 267 row.add(Document.COLUMN_MIME_TYPE, mimeType); 268 row.add(Document.COLUMN_FLAGS, flags); 269 270 // Only publish dates reasonably after epoch 271 long lastModified = file.lastModified(); 272 if (lastModified > 31536000000L) { 273 row.add(Document.COLUMN_LAST_MODIFIED, lastModified); 274 } 275 } 276 277 @Override 278 public Cursor queryRoots(String[] projection) throws FileNotFoundException { 279 final MatrixCursor result = new MatrixCursor(resolveRootProjection(projection)); 280 synchronized (mRootsLock) { 281 for (String rootId : mIdToPath.keySet()) { 282 final RootInfo root = mIdToRoot.get(rootId); 283 final File path = mIdToPath.get(rootId); 284 285 final RowBuilder row = result.newRow(); 286 row.add(Root.COLUMN_ROOT_ID, root.rootId); 287 row.add(Root.COLUMN_FLAGS, root.flags); 288 row.add(Root.COLUMN_TITLE, root.title); 289 row.add(Root.COLUMN_DOCUMENT_ID, root.docId); 290 row.add(Root.COLUMN_AVAILABLE_BYTES, path.getFreeSpace()); 291 } 292 } 293 return result; 294 } 295 296 @Override 297 public boolean isChildDocument(String parentDocId, String docId) { 298 try { 299 final File parent = getFileForDocId(parentDocId).getCanonicalFile(); 300 final File doc = getFileForDocId(docId).getCanonicalFile(); 301 return FileUtils.contains(parent, doc); 302 } catch (IOException e) { 303 throw new IllegalArgumentException( 304 "Failed to determine if " + docId + " is child of " + parentDocId + ": " + e); 305 } 306 } 307 308 @Override 309 public String createDocument(String docId, String mimeType, String displayName) 310 throws FileNotFoundException { 311 final File parent = getFileForDocId(docId); 312 if (!parent.isDirectory()) { 313 throw new IllegalArgumentException("Parent document isn't a directory"); 314 } 315 316 File file; 317 if (Document.MIME_TYPE_DIR.equals(mimeType)) { 318 file = new File(parent, displayName); 319 if (!file.mkdir()) { 320 throw new IllegalStateException("Failed to mkdir " + file); 321 } 322 } else { 323 displayName = removeExtension(mimeType, displayName); 324 file = new File(parent, addExtension(mimeType, displayName)); 325 326 // If conflicting file, try adding counter suffix 327 int n = 0; 328 while (file.exists() && n++ < 32) { 329 file = new File(parent, addExtension(mimeType, displayName + " (" + n + ")")); 330 } 331 332 try { 333 if (!file.createNewFile()) { 334 throw new IllegalStateException("Failed to touch " + file); 335 } 336 } catch (IOException e) { 337 throw new IllegalStateException("Failed to touch " + file + ": " + e); 338 } 339 } 340 return getDocIdForFile(file); 341 } 342 343 @Override 344 public String renameDocument(String docId, String displayName) throws FileNotFoundException { 345 final File before = getFileForDocId(docId); 346 final File after = new File(before.getParentFile(), displayName); 347 if (after.exists()) { 348 throw new IllegalStateException("Already exists " + after); 349 } 350 if (!before.renameTo(after)) { 351 throw new IllegalStateException("Failed to rename to " + after); 352 } 353 final String afterDocId = getDocIdForFile(after); 354 if (!TextUtils.equals(docId, afterDocId)) { 355 return afterDocId; 356 } else { 357 return null; 358 } 359 } 360 361 @Override 362 public void deleteDocument(String docId) throws FileNotFoundException { 363 final File file = getFileForDocId(docId); 364 if (file.isDirectory()) { 365 FileUtils.deleteContents(file); 366 } 367 if (!file.delete()) { 368 throw new IllegalStateException("Failed to delete " + file); 369 } 370 } 371 372 @Override 373 public Cursor queryDocument(String documentId, String[] projection) 374 throws FileNotFoundException { 375 final MatrixCursor result = new MatrixCursor(resolveDocumentProjection(projection)); 376 includeFile(result, documentId, null); 377 return result; 378 } 379 380 @Override 381 public Cursor queryChildDocuments( 382 String parentDocumentId, String[] projection, String sortOrder) 383 throws FileNotFoundException { 384 final File parent = getFileForDocId(parentDocumentId); 385 final MatrixCursor result = new DirectoryCursor( 386 resolveDocumentProjection(projection), parentDocumentId, parent); 387 for (File file : parent.listFiles()) { 388 includeFile(result, null, file); 389 } 390 return result; 391 } 392 393 @Override 394 public Cursor querySearchDocuments(String rootId, String query, String[] projection) 395 throws FileNotFoundException { 396 final MatrixCursor result = new MatrixCursor(resolveDocumentProjection(projection)); 397 398 final File parent; 399 synchronized (mRootsLock) { 400 parent = mIdToPath.get(rootId); 401 } 402 403 final LinkedList<File> pending = new LinkedList<File>(); 404 pending.add(parent); 405 while (!pending.isEmpty() && result.getCount() < 24) { 406 final File file = pending.removeFirst(); 407 if (file.isDirectory()) { 408 for (File child : file.listFiles()) { 409 pending.add(child); 410 } 411 } 412 if (file.getName().toLowerCase().contains(query)) { 413 includeFile(result, null, file); 414 } 415 } 416 return result; 417 } 418 419 @Override 420 public String getDocumentType(String documentId) throws FileNotFoundException { 421 final File file = getFileForDocId(documentId); 422 return getTypeForFile(file); 423 } 424 425 @Override 426 public ParcelFileDescriptor openDocument( 427 String documentId, String mode, CancellationSignal signal) 428 throws FileNotFoundException { 429 final File file = getFileForDocId(documentId); 430 final int pfdMode = ParcelFileDescriptor.parseMode(mode); 431 if (pfdMode == ParcelFileDescriptor.MODE_READ_ONLY) { 432 return ParcelFileDescriptor.open(file, pfdMode); 433 } else { 434 try { 435 // When finished writing, kick off media scanner 436 return ParcelFileDescriptor.open(file, pfdMode, mHandler, new OnCloseListener() { 437 @Override 438 public void onClose(IOException e) { 439 final Intent intent = new Intent( 440 Intent.ACTION_MEDIA_SCANNER_SCAN_FILE); 441 intent.setData(Uri.fromFile(file)); 442 getContext().sendBroadcast(intent); 443 } 444 }); 445 } catch (IOException e) { 446 throw new FileNotFoundException("Failed to open for writing: " + e); 447 } 448 } 449 } 450 451 @Override 452 public AssetFileDescriptor openDocumentThumbnail( 453 String documentId, Point sizeHint, CancellationSignal signal) 454 throws FileNotFoundException { 455 final File file = getFileForDocId(documentId); 456 return DocumentsContract.openImageThumbnail(file); 457 } 458 459 private static String getTypeForFile(File file) { 460 if (file.isDirectory()) { 461 return Document.MIME_TYPE_DIR; 462 } else { 463 return getTypeForName(file.getName()); 464 } 465 } 466 467 private static String getTypeForName(String name) { 468 final int lastDot = name.lastIndexOf('.'); 469 if (lastDot >= 0) { 470 final String extension = name.substring(lastDot + 1).toLowerCase(); 471 final String mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); 472 if (mime != null) { 473 return mime; 474 } 475 } 476 477 return "application/octet-stream"; 478 } 479 480 /** 481 * Remove file extension from name, but only if exact MIME type mapping 482 * exists. This means we can reapply the extension later. 483 */ 484 private static String removeExtension(String mimeType, String name) { 485 final int lastDot = name.lastIndexOf('.'); 486 if (lastDot >= 0) { 487 final String extension = name.substring(lastDot + 1).toLowerCase(); 488 final String nameMime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); 489 if (mimeType.equals(nameMime)) { 490 return name.substring(0, lastDot); 491 } 492 } 493 return name; 494 } 495 496 /** 497 * Add file extension to name, but only if exact MIME type mapping exists. 498 */ 499 private static String addExtension(String mimeType, String name) { 500 final String extension = MimeTypeMap.getSingleton() 501 .getExtensionFromMimeType(mimeType); 502 if (extension != null) { 503 return name + "." + extension; 504 } 505 return name; 506 } 507 508 private void startObserving(File file, Uri notifyUri) { 509 synchronized (mObservers) { 510 DirectoryObserver observer = mObservers.get(file); 511 if (observer == null) { 512 observer = new DirectoryObserver( 513 file, getContext().getContentResolver(), notifyUri); 514 observer.startWatching(); 515 mObservers.put(file, observer); 516 } 517 observer.mRefCount++; 518 519 if (LOG_INOTIFY) Log.d(TAG, "after start: " + observer); 520 } 521 } 522 523 private void stopObserving(File file) { 524 synchronized (mObservers) { 525 DirectoryObserver observer = mObservers.get(file); 526 if (observer == null) return; 527 528 observer.mRefCount--; 529 if (observer.mRefCount == 0) { 530 mObservers.remove(file); 531 observer.stopWatching(); 532 } 533 534 if (LOG_INOTIFY) Log.d(TAG, "after stop: " + observer); 535 } 536 } 537 538 private static class DirectoryObserver extends FileObserver { 539 private static final int NOTIFY_EVENTS = ATTRIB | CLOSE_WRITE | MOVED_FROM | MOVED_TO 540 | CREATE | DELETE | DELETE_SELF | MOVE_SELF; 541 542 private final File mFile; 543 private final ContentResolver mResolver; 544 private final Uri mNotifyUri; 545 546 private int mRefCount = 0; 547 548 public DirectoryObserver(File file, ContentResolver resolver, Uri notifyUri) { 549 super(file.getAbsolutePath(), NOTIFY_EVENTS); 550 mFile = file; 551 mResolver = resolver; 552 mNotifyUri = notifyUri; 553 } 554 555 @Override 556 public void onEvent(int event, String path) { 557 if ((event & NOTIFY_EVENTS) != 0) { 558 if (LOG_INOTIFY) Log.d(TAG, "onEvent() " + event + " at " + path); 559 mResolver.notifyChange(mNotifyUri, null, false); 560 } 561 } 562 563 @Override 564 public String toString() { 565 return "DirectoryObserver{file=" + mFile.getAbsolutePath() + ", ref=" + mRefCount + "}"; 566 } 567 } 568 569 private class DirectoryCursor extends MatrixCursor { 570 private final File mFile; 571 572 public DirectoryCursor(String[] columnNames, String docId, File file) { 573 super(columnNames); 574 575 final Uri notifyUri = DocumentsContract.buildChildDocumentsUri( 576 AUTHORITY, docId); 577 setNotificationUri(getContext().getContentResolver(), notifyUri); 578 579 mFile = file; 580 startObserving(mFile, notifyUri); 581 } 582 583 @Override 584 public void close() { 585 super.close(); 586 stopObserving(mFile); 587 } 588 } 589 } 590