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