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 18 package com.example.android.storageprovider; 19 20 import android.content.Context; 21 import android.content.SharedPreferences; 22 import android.content.res.AssetFileDescriptor; 23 import android.content.res.TypedArray; 24 import android.database.Cursor; 25 import android.database.MatrixCursor; 26 import android.graphics.Point; 27 import android.os.CancellationSignal; 28 import android.os.Handler; 29 import android.os.ParcelFileDescriptor; 30 import android.provider.DocumentsContract.Document; 31 import android.provider.DocumentsContract.Root; 32 import android.provider.DocumentsProvider; 33 import android.webkit.MimeTypeMap; 34 35 import com.example.android.common.logger.Log; 36 37 import java.io.ByteArrayOutputStream; 38 import java.io.File; 39 import java.io.FileNotFoundException; 40 import java.io.FileOutputStream; 41 import java.io.IOException; 42 import java.io.InputStream; 43 import java.util.Collections; 44 import java.util.Comparator; 45 import java.util.HashSet; 46 import java.util.LinkedList; 47 import java.util.PriorityQueue; 48 import java.util.Set; 49 50 /** 51 * Manages documents and exposes them to the Android system for sharing. 52 */ 53 public class MyCloudProvider extends DocumentsProvider { 54 private static final String TAG = MyCloudProvider.class.getSimpleName(); 55 56 // Use these as the default columns to return information about a root if no specific 57 // columns are requested in a query. 58 private static final String[] DEFAULT_ROOT_PROJECTION = new String[]{ 59 Root.COLUMN_ROOT_ID, 60 Root.COLUMN_MIME_TYPES, 61 Root.COLUMN_FLAGS, 62 Root.COLUMN_ICON, 63 Root.COLUMN_TITLE, 64 Root.COLUMN_SUMMARY, 65 Root.COLUMN_DOCUMENT_ID, 66 Root.COLUMN_AVAILABLE_BYTES 67 }; 68 69 // Use these as the default columns to return information about a document if no specific 70 // columns are requested in a query. 71 private static final String[] DEFAULT_DOCUMENT_PROJECTION = new String[]{ 72 Document.COLUMN_DOCUMENT_ID, 73 Document.COLUMN_MIME_TYPE, 74 Document.COLUMN_DISPLAY_NAME, 75 Document.COLUMN_LAST_MODIFIED, 76 Document.COLUMN_FLAGS, 77 Document.COLUMN_SIZE 78 }; 79 80 // No official policy on how many to return, but make sure you do limit the number of recent 81 // and search results. 82 private static final int MAX_SEARCH_RESULTS = 20; 83 private static final int MAX_LAST_MODIFIED = 5; 84 85 private static final String ROOT = "root"; 86 87 // A file object at the root of the file hierarchy. Depending on your implementation, the root 88 // does not need to be an existing file system directory. For example, a tag-based document 89 // provider might return a directory containing all tags, represented as child directories. 90 private File mBaseDir; 91 92 @Override 93 public boolean onCreate() { 94 Log.v(TAG, "onCreate"); 95 96 mBaseDir = getContext().getFilesDir(); 97 98 writeDummyFilesToStorage(); 99 100 return true; 101 } 102 104 @Override 105 public Cursor queryRoots(String[] projection) throws FileNotFoundException { 106 Log.v(TAG, "queryRoots"); 107 108 // Create a cursor with either the requested fields, or the default projection. This 109 // cursor is returned to the Android system picker UI and used to display all roots from 110 // this provider. 111 final MatrixCursor result = new MatrixCursor(resolveRootProjection(projection)); 112 113 // If user is not logged in, return an empty root cursor. This removes our provider from 114 // the list entirely. 115 if (!isUserLoggedIn()) { 116 return result; 117 } 118 119 // It's possible to have multiple roots (e.g. for multiple accounts in the same app) - 120 // just add multiple cursor rows. 121 // Construct one row for a root called "MyCloud". 122 final MatrixCursor.RowBuilder row = result.newRow(); 123 124 row.add(Root.COLUMN_ROOT_ID, ROOT); 125 row.add(Root.COLUMN_SUMMARY, getContext().getString(R.string.root_summary)); 126 127 // FLAG_SUPPORTS_CREATE means at least one directory under the root supports creating 128 // documents. FLAG_SUPPORTS_RECENTS means your application's most recently used 129 // documents will show up in the "Recents" category. FLAG_SUPPORTS_SEARCH allows users 130 // to search all documents the application shares. 131 row.add(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_CREATE | 132 Root.FLAG_SUPPORTS_RECENTS | 133 Root.FLAG_SUPPORTS_SEARCH); 134 135 // COLUMN_TITLE is the root title (e.g. what will be displayed to identify your provider). 136 row.add(Root.COLUMN_TITLE, getContext().getString(R.string.app_name)); 137 138 // This document id must be unique within this provider and consistent across time. The 139 // system picker UI may save it and refer to it later. 140 row.add(Root.COLUMN_DOCUMENT_ID, getDocIdForFile(mBaseDir)); 141 142 // The child MIME types are used to filter the roots and only present to the user roots 143 // that contain the desired type somewhere in their file hierarchy. 144 row.add(Root.COLUMN_MIME_TYPES, getChildMimeTypes(mBaseDir)); 145 row.add(Root.COLUMN_AVAILABLE_BYTES, mBaseDir.getFreeSpace()); 146 row.add(Root.COLUMN_ICON, R.drawable.ic_launcher); 147 148 return result; 149 } 151 153 @Override 154 public Cursor queryRecentDocuments(String rootId, String[] projection) 155 throws FileNotFoundException { 156 Log.v(TAG, "queryRecentDocuments"); 157 158 // This example implementation walks a local file structure to find the most recently 159 // modified files. Other implementations might include making a network call to query a 160 // server. 161 162 // Create a cursor with the requested projection, or the default projection. 163 final MatrixCursor result = new MatrixCursor(resolveDocumentProjection(projection)); 164 165 final File parent = getFileForDocId(rootId); 166 167 // Create a queue to store the most recent documents, which orders by last modified. 168 PriorityQueue<File> lastModifiedFiles = new PriorityQueue<File>(5, new Comparator<File>() { 169 public int compare(File i, File j) { 170 return Long.compare(i.lastModified(), j.lastModified()); 171 } 172 }); 173 174 // Iterate through all files and directories in the file structure under the root. If 175 // the file is more recent than the least recently modified, add it to the queue, 176 // limiting the number of results. 177 final LinkedList<File> pending = new LinkedList<File>(); 178 179 // Start by adding the parent to the list of files to be processed 180 pending.add(parent); 181 182 // Do while we still have unexamined files 183 while (!pending.isEmpty()) { 184 // Take a file from the list of unprocessed files 185 final File file = pending.removeFirst(); 186 if (file.isDirectory()) { 187 // If it's a directory, add all its children to the unprocessed list 188 Collections.addAll(pending, file.listFiles()); 189 } else { 190 // If it's a file, add it to the ordered queue. 191 lastModifiedFiles.add(file); 192 } 193 } 194 195 // Add the most recent files to the cursor, not exceeding the max number of results. 196 for (int i = 0; i < Math.min(MAX_LAST_MODIFIED + 1, lastModifiedFiles.size()); i++) { 197 final File file = lastModifiedFiles.remove(); 198 includeFile(result, null, file); 199 } 200 return result; 201 } 203 205 @Override 206 public Cursor querySearchDocuments(String rootId, String query, String[] projection) 207 throws FileNotFoundException { 208 Log.v(TAG, "querySearchDocuments"); 209 210 // Create a cursor with the requested projection, or the default projection. 211 final MatrixCursor result = new MatrixCursor(resolveDocumentProjection(projection)); 212 final File parent = getFileForDocId(rootId); 213 214 // This example implementation searches file names for the query and doesn't rank search 215 // results, so we can stop as soon as we find a sufficient number of matches. Other 216 // implementations might use other data about files, rather than the file name, to 217 // produce a match; it might also require a network call to query a remote server. 218 219 // Iterate through all files in the file structure under the root until we reach the 220 // desired number of matches. 221 final LinkedList<File> pending = new LinkedList<File>(); 222 223 // Start by adding the parent to the list of files to be processed 224 pending.add(parent); 225 226 // Do while we still have unexamined files, and fewer than the max search results 227 while (!pending.isEmpty() && result.getCount() < MAX_SEARCH_RESULTS) { 228 // Take a file from the list of unprocessed files 229 final File file = pending.removeFirst(); 230 if (file.isDirectory()) { 231 // If it's a directory, add all its children to the unprocessed list 232 Collections.addAll(pending, file.listFiles()); 233 } else { 234 // If it's a file and it matches, add it to the result cursor. 235 if (file.getName().toLowerCase().contains(query)) { 236 includeFile(result, null, file); 237 } 238 } 239 } 240 return result; 241 } 243 245 @Override 246 public AssetFileDescriptor openDocumentThumbnail(String documentId, Point sizeHint, 247 CancellationSignal signal) 248 throws FileNotFoundException { 249 Log.v(TAG, "openDocumentThumbnail"); 250 251 final File file = getFileForDocId(documentId); 252 final ParcelFileDescriptor pfd = 253 ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY); 254 return new AssetFileDescriptor(pfd, 0, AssetFileDescriptor.UNKNOWN_LENGTH); 255 } 257 259 @Override 260 public Cursor queryDocument(String documentId, String[] projection) 261 throws FileNotFoundException { 262 Log.v(TAG, "queryDocument"); 263 264 // Create a cursor with the requested projection, or the default projection. 265 final MatrixCursor result = new MatrixCursor(resolveDocumentProjection(projection)); 266 includeFile(result, documentId, null); 267 return result; 268 } 270 272 @Override 273 public Cursor queryChildDocuments(String parentDocumentId, String[] projection, 274 String sortOrder) throws FileNotFoundException { 275 Log.v(TAG, "queryChildDocuments, parentDocumentId: " + 276 parentDocumentId + 277 " sortOrder: " + 278 sortOrder); 279 280 final MatrixCursor result = new MatrixCursor(resolveDocumentProjection(projection)); 281 final File parent = getFileForDocId(parentDocumentId); 282 for (File file : parent.listFiles()) { 283 includeFile(result, null, file); 284 } 285 return result; 286 } 288 289 291 @Override 292 public ParcelFileDescriptor openDocument(final String documentId, final String mode, 293 CancellationSignal signal) 294 throws FileNotFoundException { 295 Log.v(TAG, "openDocument, mode: " + mode); 296 // It's OK to do network operations in this method to download the document, as long as you 297 // periodically check the CancellationSignal. If you have an extremely large file to 298 // transfer from the network, a better solution may be pipes or sockets 299 // (see ParcelFileDescriptor for helper methods). 300 301 final File file = getFileForDocId(documentId); 302 final int accessMode = ParcelFileDescriptor.parseMode(mode); 303 304 final boolean isWrite = (mode.indexOf('w') != -1); 305 if (isWrite) { 306 // Attach a close listener if the document is opened in write mode. 307 try { 308 Handler handler = new Handler(getContext().getMainLooper()); 309 return ParcelFileDescriptor.open(file, accessMode, handler, 310 new ParcelFileDescriptor.OnCloseListener() { 311 @Override 312 public void onClose(IOException e) { 313 314 // Update the file with the cloud server. The client is done writing. 315 Log.i(TAG, "A file with id " + documentId + " has been closed! Time to " + 316 "update the server."); 317 } 318 319 }); 320 } catch (IOException e) { 321 throw new FileNotFoundException("Failed to open document with id " + documentId + 322 " and mode " + mode); 323 } 324 } else { 325 return ParcelFileDescriptor.open(file, accessMode); 326 } 327 } 329 330 332 @Override 333 public String createDocument(String documentId, String mimeType, String displayName) 334 throws FileNotFoundException { 335 Log.v(TAG, "createDocument"); 336 337 File parent = getFileForDocId(documentId); 338 File file = new File(parent.getPath(), displayName); 339 try { 340 file.createNewFile(); 341 file.setWritable(true); 342 file.setReadable(true); 343 } catch (IOException e) { 344 throw new FileNotFoundException("Failed to create document with name " + 345 displayName +" and documentId " + documentId); 346 } 347 return getDocIdForFile(file); 348 } 350 352 @Override 353 public void deleteDocument(String documentId) throws FileNotFoundException { 354 Log.v(TAG, "deleteDocument"); 355 File file = getFileForDocId(documentId); 356 if (file.delete()) { 357 Log.i(TAG, "Deleted file with id " + documentId); 358 } else { 359 throw new FileNotFoundException("Failed to delete document with id " + documentId); 360 } 361 } 363 364 365 @Override 366 public String getDocumentType(String documentId) throws FileNotFoundException { 367 File file = getFileForDocId(documentId); 368 return getTypeForFile(file); 369 } 370 371 /** 372 * @param projection the requested root column projection 373 * @return either the requested root column projection, or the default projection if the 374 * requested projection is null. 375 */ 376 private static String[] resolveRootProjection(String[] projection) { 377 return projection != null ? projection : DEFAULT_ROOT_PROJECTION; 378 } 379 380 private static String[] resolveDocumentProjection(String[] projection) { 381 return projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION; 382 } 383 384 /** 385 * Get a file's MIME type 386 * 387 * @param file the File object whose type we want 388 * @return the MIME type of the file 389 */ 390 private static String getTypeForFile(File file) { 391 if (file.isDirectory()) { 392 return Document.MIME_TYPE_DIR; 393 } else { 394 return getTypeForName(file.getName()); 395 } 396 } 397 398 /** 399 * Get the MIME data type of a document, given its filename. 400 * 401 * @param name the filename of the document 402 * @return the MIME data type of a document 403 */ 404 private static String getTypeForName(String name) { 405 final int lastDot = name.lastIndexOf('.'); 406 if (lastDot >= 0) { 407 final String extension = name.substring(lastDot + 1); 408 final String mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); 409 if (mime != null) { 410 return mime; 411 } 412 } 413 return "application/octet-stream"; 414 } 415 416 /** 417 * Gets a string of unique MIME data types a directory supports, separated by newlines. This 418 * should not change. 419 * 420 * @param parent the File for the parent directory 421 * @return a string of the unique MIME data types the parent directory supports 422 */ 423 private String getChildMimeTypes(File parent) { 424 Set<String> mimeTypes = new HashSet<String>(); 425 mimeTypes.add("image/*"); 426 mimeTypes.add("text/*"); 427 mimeTypes.add("application/vnd.openxmlformats-officedocument.wordprocessingml.document"); 428 429 // Flatten the list into a string and insert newlines between the MIME type strings. 430 StringBuilder mimeTypesString = new StringBuilder(); 431 for (String mimeType : mimeTypes) { 432 mimeTypesString.append(mimeType).append("\n"); 433 } 434 435 return mimeTypesString.toString(); 436 } 437 438 /** 439 * Get the document ID given a File. The document id must be consistent across time. Other 440 * applications may save the ID and use it to reference documents later. 441 * <p/> 442 * This implementation is specific to this demo. It assumes only one root and is built 443 * directly from the file structure. However, it is possible for a document to be a child of 444 * multiple directories (for example "android" and "images"), in which case the file must have 445 * the same consistent, unique document ID in both cases. 446 * 447 * @param file the File whose document ID you want 448 * @return the corresponding document ID 449 */ 450 private String getDocIdForFile(File file) { 451 String path = file.getAbsolutePath(); 452 453 // Start at first char of path under root 454 final String rootPath = mBaseDir.getPath(); 455 if (rootPath.equals(path)) { 456 path = ""; 457 } else if (rootPath.endsWith("/")) { 458 path = path.substring(rootPath.length()); 459 } else { 460 path = path.substring(rootPath.length() + 1); 461 } 462 463 return "root" + ':' + path; 464 } 465 466 /** 467 * Add a representation of a file to a cursor. 468 * 469 * @param result the cursor to modify 470 * @param docId the document ID representing the desired file (may be null if given file) 471 * @param file the File object representing the desired file (may be null if given docID) 472 * @throws java.io.FileNotFoundException 473 */ 474 private void includeFile(MatrixCursor result, String docId, File file) 475 throws FileNotFoundException { 476 if (docId == null) { 477 docId = getDocIdForFile(file); 478 } else { 479 file = getFileForDocId(docId); 480 } 481 482 int flags = 0; 483 484 if (file.isDirectory()) { 485 // Request the folder to lay out as a grid rather than a list. This also allows a larger 486 // thumbnail to be displayed for each image. 487 // flags |= Document.FLAG_DIR_PREFERS_GRID; 488 489 // Add FLAG_DIR_SUPPORTS_CREATE if the file is a writable directory. 490 if (file.isDirectory() && file.canWrite()) { 491 flags |= Document.FLAG_DIR_SUPPORTS_CREATE; 492 } 493 } else if (file.canWrite()) { 494 // If the file is writable set FLAG_SUPPORTS_WRITE and 495 // FLAG_SUPPORTS_DELETE 496 flags |= Document.FLAG_SUPPORTS_WRITE; 497 flags |= Document.FLAG_SUPPORTS_DELETE; 498 } 499 500 final String displayName = file.getName(); 501 final String mimeType = getTypeForFile(file); 502 503 if (mimeType.startsWith("image/")) { 504 // Allow the image to be represented by a thumbnail rather than an icon 505 flags |= Document.FLAG_SUPPORTS_THUMBNAIL; 506 } 507 508 final MatrixCursor.RowBuilder row = result.newRow(); 509 row.add(Document.COLUMN_DOCUMENT_ID, docId); 510 row.add(Document.COLUMN_DISPLAY_NAME, displayName); 511 row.add(Document.COLUMN_SIZE, file.length()); 512 row.add(Document.COLUMN_MIME_TYPE, mimeType); 513 row.add(Document.COLUMN_LAST_MODIFIED, file.lastModified()); 514 row.add(Document.COLUMN_FLAGS, flags); 515 516 // Add a custom icon 517 row.add(Document.COLUMN_ICON, R.drawable.ic_launcher); 518 } 519 520 /** 521 * Translate your custom URI scheme into a File object. 522 * 523 * @param docId the document ID representing the desired file 524 * @return a File represented by the given document ID 525 * @throws java.io.FileNotFoundException 526 */ 527 private File getFileForDocId(String docId) throws FileNotFoundException { 528 File target = mBaseDir; 529 if (docId.equals(ROOT)) { 530 return target; 531 } 532 final int splitIndex = docId.indexOf(':', 1); 533 if (splitIndex < 0) { 534 throw new FileNotFoundException("Missing root for " + docId); 535 } else { 536 final String path = docId.substring(splitIndex + 1); 537 target = new File(target, path); 538 if (!target.exists()) { 539 throw new FileNotFoundException("Missing file for " + docId + " at " + target); 540 } 541 return target; 542 } 543 } 544 545 546 /** 547 * Preload sample files packaged in the apk into the internal storage directory. This is a 548 * dummy function specific to this demo. The MyCloud mock cloud service doesn't actually 549 * have a backend, so it simulates by reading content from the device's internal storage. 550 */ 551 private void writeDummyFilesToStorage() { 552 if (mBaseDir.list().length > 0) { 553 return; 554 } 555 556 int[] imageResIds = getResourceIdArray(R.array.image_res_ids); 557 for (int resId : imageResIds) { 558 writeFileToInternalStorage(resId, ".jpeg"); 559 } 560 561 int[] textResIds = getResourceIdArray(R.array.text_res_ids); 562 for (int resId : textResIds) { 563 writeFileToInternalStorage(resId, ".txt"); 564 } 565 566 int[] docxResIds = getResourceIdArray(R.array.docx_res_ids); 567 for (int resId : docxResIds) { 568 writeFileToInternalStorage(resId, ".docx"); 569 } 570 } 571 572 /** 573 * Write a file to internal storage. Used to set up our dummy "cloud server". 574 * 575 * @param resId the resource ID of the file to write to internal storage 576 * @param extension the file extension (ex. .png, .mp3) 577 */ 578 private void writeFileToInternalStorage(int resId, String extension) { 579 InputStream ins = getContext().getResources().openRawResource(resId); 580 ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); 581 int size; 582 byte[] buffer = new byte[1024]; 583 try { 584 while ((size = ins.read(buffer, 0, 1024)) >= 0) { 585 outputStream.write(buffer, 0, size); 586 } 587 ins.close(); 588 buffer = outputStream.toByteArray(); 589 String filename = getContext().getResources().getResourceEntryName(resId) + extension; 590 FileOutputStream fos = getContext().openFileOutput(filename, Context.MODE_PRIVATE); 591 fos.write(buffer); 592 fos.close(); 593 594 } catch (IOException e) { 595 e.printStackTrace(); 596 } 597 } 598 599 private int[] getResourceIdArray(int arrayResId) { 600 TypedArray ar = getContext().getResources().obtainTypedArray(arrayResId); 601 int len = ar.length(); 602 int[] resIds = new int[len]; 603 for (int i = 0; i < len; i++) { 604 resIds[i] = ar.getResourceId(i, 0); 605 } 606 ar.recycle(); 607 return resIds; 608 } 609 610 /** 611 * Dummy function to determine whether the user is logged in. 612 */ 613 private boolean isUserLoggedIn() { 614 final SharedPreferences sharedPreferences = 615 getContext().getSharedPreferences(getContext().getString(R.string.app_name), 616 Context.MODE_PRIVATE); 617 return sharedPreferences.getBoolean(getContext().getString(R.string.key_logged_in), false); 618 } 619 620 621 }