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