1 /* 2 * Copyright (C) 2019 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.providers.media.util; 18 19 import static android.os.ParcelFileDescriptor.MODE_APPEND; 20 import static android.os.ParcelFileDescriptor.MODE_CREATE; 21 import static android.os.ParcelFileDescriptor.MODE_READ_ONLY; 22 import static android.os.ParcelFileDescriptor.MODE_READ_WRITE; 23 import static android.os.ParcelFileDescriptor.MODE_TRUNCATE; 24 import static android.os.ParcelFileDescriptor.MODE_WRITE_ONLY; 25 import static android.system.OsConstants.F_OK; 26 import static android.system.OsConstants.O_ACCMODE; 27 import static android.system.OsConstants.O_APPEND; 28 import static android.system.OsConstants.O_CLOEXEC; 29 import static android.system.OsConstants.O_CREAT; 30 import static android.system.OsConstants.O_NOFOLLOW; 31 import static android.system.OsConstants.O_RDONLY; 32 import static android.system.OsConstants.O_RDWR; 33 import static android.system.OsConstants.O_TRUNC; 34 import static android.system.OsConstants.O_WRONLY; 35 import static android.system.OsConstants.R_OK; 36 import static android.system.OsConstants.S_IRWXG; 37 import static android.system.OsConstants.S_IRWXU; 38 import static android.system.OsConstants.W_OK; 39 40 import static com.android.providers.media.util.DatabaseUtils.getAsBoolean; 41 import static com.android.providers.media.util.DatabaseUtils.getAsLong; 42 import static com.android.providers.media.util.DatabaseUtils.parseBoolean; 43 import static com.android.providers.media.util.Logging.TAG; 44 45 import android.content.ClipDescription; 46 import android.content.ContentValues; 47 import android.content.Context; 48 import android.content.pm.PackageManager; 49 import android.net.Uri; 50 import android.os.Environment; 51 import android.os.ParcelFileDescriptor; 52 import android.os.UserHandle; 53 import android.os.SystemProperties; 54 import android.os.storage.StorageManager; 55 import android.os.storage.StorageVolume; 56 import android.provider.MediaStore; 57 import android.provider.MediaStore.MediaColumns; 58 import android.system.ErrnoException; 59 import android.system.Os; 60 import android.system.OsConstants; 61 import android.text.TextUtils; 62 import android.text.format.DateUtils; 63 import android.util.Log; 64 import android.webkit.MimeTypeMap; 65 66 import androidx.annotation.NonNull; 67 import androidx.annotation.Nullable; 68 import androidx.annotation.VisibleForTesting; 69 70 import com.android.modules.utils.build.SdkLevel; 71 72 import java.io.File; 73 import java.io.FileDescriptor; 74 import java.io.FileNotFoundException; 75 import java.io.IOException; 76 import java.io.InputStream; 77 import java.io.OutputStream; 78 import java.nio.charset.StandardCharsets; 79 import java.nio.file.FileVisitResult; 80 import java.nio.file.FileVisitor; 81 import java.nio.file.Files; 82 import java.nio.file.NoSuchFileException; 83 import java.nio.file.Path; 84 import java.nio.file.attribute.BasicFileAttributes; 85 import java.util.ArrayList; 86 import java.util.Arrays; 87 import java.util.Collection; 88 import java.util.Comparator; 89 import java.util.Iterator; 90 import java.util.Locale; 91 import java.util.Objects; 92 import java.util.Optional; 93 import java.util.function.Consumer; 94 import java.util.regex.Matcher; 95 import java.util.regex.Pattern; 96 97 public class FileUtils { 98 // Even though vfat allows 255 UCS-2 chars, we might eventually write to 99 // ext4 through a FUSE layer, so use that limit. 100 @VisibleForTesting 101 static final int MAX_FILENAME_BYTES = 255; 102 103 /** 104 * Drop-in replacement for {@link ParcelFileDescriptor#open(File, int)} 105 * which adds security features like {@link OsConstants#O_CLOEXEC} and 106 * {@link OsConstants#O_NOFOLLOW}. 107 */ openSafely(@onNull File file, int pfdFlags)108 public static @NonNull ParcelFileDescriptor openSafely(@NonNull File file, int pfdFlags) 109 throws FileNotFoundException { 110 final int posixFlags = translateModePfdToPosix(pfdFlags) | O_CLOEXEC | O_NOFOLLOW; 111 try { 112 final FileDescriptor fd = Os.open(file.getAbsolutePath(), posixFlags, 113 S_IRWXU | S_IRWXG); 114 try { 115 return ParcelFileDescriptor.dup(fd); 116 } finally { 117 closeQuietly(fd); 118 } 119 } catch (IOException | ErrnoException e) { 120 throw new FileNotFoundException(e.getMessage()); 121 } 122 } 123 closeQuietly(@ullable AutoCloseable closeable)124 public static void closeQuietly(@Nullable AutoCloseable closeable) { 125 android.os.FileUtils.closeQuietly(closeable); 126 } 127 closeQuietly(@ullable FileDescriptor fd)128 public static void closeQuietly(@Nullable FileDescriptor fd) { 129 if (fd == null) return; 130 try { 131 Os.close(fd); 132 } catch (ErrnoException ignored) { 133 } 134 } 135 copy(@onNull InputStream in, @NonNull OutputStream out)136 public static long copy(@NonNull InputStream in, @NonNull OutputStream out) throws IOException { 137 return android.os.FileUtils.copy(in, out); 138 } 139 buildPath(File base, String... segments)140 public static File buildPath(File base, String... segments) { 141 File cur = base; 142 for (String segment : segments) { 143 if (cur == null) { 144 cur = new File(segment); 145 } else { 146 cur = new File(cur, segment); 147 } 148 } 149 return cur; 150 } 151 152 /** 153 * Delete older files in a directory until only those matching the given 154 * constraints remain. 155 * 156 * @param minCount Always keep at least this many files. 157 * @param minAgeMs Always keep files younger than this age, in milliseconds. 158 * @return if any files were deleted. 159 */ deleteOlderFiles(File dir, int minCount, long minAgeMs)160 public static boolean deleteOlderFiles(File dir, int minCount, long minAgeMs) { 161 if (minCount < 0 || minAgeMs < 0) { 162 throw new IllegalArgumentException("Constraints must be positive or 0"); 163 } 164 165 final File[] files = dir.listFiles(); 166 if (files == null) return false; 167 168 // Sort with newest files first 169 Arrays.sort(files, new Comparator<File>() { 170 @Override 171 public int compare(File lhs, File rhs) { 172 return Long.compare(rhs.lastModified(), lhs.lastModified()); 173 } 174 }); 175 176 // Keep at least minCount files 177 boolean deleted = false; 178 for (int i = minCount; i < files.length; i++) { 179 final File file = files[i]; 180 181 // Keep files newer than minAgeMs 182 final long age = System.currentTimeMillis() - file.lastModified(); 183 if (age > minAgeMs) { 184 if (file.delete()) { 185 Log.d(TAG, "Deleted old file " + file); 186 deleted = true; 187 } 188 } 189 } 190 return deleted; 191 } 192 193 /** 194 * Shamelessly borrowed from {@code android.os.FileUtils}. 195 */ translateModeStringToPosix(String mode)196 public static int translateModeStringToPosix(String mode) { 197 // Sanity check for invalid chars 198 for (int i = 0; i < mode.length(); i++) { 199 switch (mode.charAt(i)) { 200 case 'r': 201 case 'w': 202 case 't': 203 case 'a': 204 break; 205 default: 206 throw new IllegalArgumentException("Bad mode: " + mode); 207 } 208 } 209 210 int res = 0; 211 if (mode.startsWith("rw")) { 212 res = O_RDWR | O_CREAT; 213 } else if (mode.startsWith("w")) { 214 res = O_WRONLY | O_CREAT; 215 } else if (mode.startsWith("r")) { 216 res = O_RDONLY; 217 } else { 218 throw new IllegalArgumentException("Bad mode: " + mode); 219 } 220 if (mode.indexOf('t') != -1) { 221 res |= O_TRUNC; 222 } 223 if (mode.indexOf('a') != -1) { 224 res |= O_APPEND; 225 } 226 return res; 227 } 228 229 /** 230 * Shamelessly borrowed from {@code android.os.FileUtils}. 231 */ translateModePosixToString(int mode)232 public static String translateModePosixToString(int mode) { 233 String res = ""; 234 if ((mode & O_ACCMODE) == O_RDWR) { 235 res = "rw"; 236 } else if ((mode & O_ACCMODE) == O_WRONLY) { 237 res = "w"; 238 } else if ((mode & O_ACCMODE) == O_RDONLY) { 239 res = "r"; 240 } else { 241 throw new IllegalArgumentException("Bad mode: " + mode); 242 } 243 if ((mode & O_TRUNC) == O_TRUNC) { 244 res += "t"; 245 } 246 if ((mode & O_APPEND) == O_APPEND) { 247 res += "a"; 248 } 249 return res; 250 } 251 252 /** 253 * Shamelessly borrowed from {@code android.os.FileUtils}. 254 */ translateModePosixToPfd(int mode)255 public static int translateModePosixToPfd(int mode) { 256 int res = 0; 257 if ((mode & O_ACCMODE) == O_RDWR) { 258 res = MODE_READ_WRITE; 259 } else if ((mode & O_ACCMODE) == O_WRONLY) { 260 res = MODE_WRITE_ONLY; 261 } else if ((mode & O_ACCMODE) == O_RDONLY) { 262 res = MODE_READ_ONLY; 263 } else { 264 throw new IllegalArgumentException("Bad mode: " + mode); 265 } 266 if ((mode & O_CREAT) == O_CREAT) { 267 res |= MODE_CREATE; 268 } 269 if ((mode & O_TRUNC) == O_TRUNC) { 270 res |= MODE_TRUNCATE; 271 } 272 if ((mode & O_APPEND) == O_APPEND) { 273 res |= MODE_APPEND; 274 } 275 return res; 276 } 277 278 /** 279 * Shamelessly borrowed from {@code android.os.FileUtils}. 280 */ translateModePfdToPosix(int mode)281 public static int translateModePfdToPosix(int mode) { 282 int res = 0; 283 if ((mode & MODE_READ_WRITE) == MODE_READ_WRITE) { 284 res = O_RDWR; 285 } else if ((mode & MODE_WRITE_ONLY) == MODE_WRITE_ONLY) { 286 res = O_WRONLY; 287 } else if ((mode & MODE_READ_ONLY) == MODE_READ_ONLY) { 288 res = O_RDONLY; 289 } else { 290 throw new IllegalArgumentException("Bad mode: " + mode); 291 } 292 if ((mode & MODE_CREATE) == MODE_CREATE) { 293 res |= O_CREAT; 294 } 295 if ((mode & MODE_TRUNCATE) == MODE_TRUNCATE) { 296 res |= O_TRUNC; 297 } 298 if ((mode & MODE_APPEND) == MODE_APPEND) { 299 res |= O_APPEND; 300 } 301 return res; 302 } 303 304 /** 305 * Shamelessly borrowed from {@code android.os.FileUtils}. 306 */ translateModeAccessToPosix(int mode)307 public static int translateModeAccessToPosix(int mode) { 308 if (mode == F_OK) { 309 // There's not an exact mapping, so we attempt a read-only open to 310 // determine if a file exists 311 return O_RDONLY; 312 } else if ((mode & (R_OK | W_OK)) == (R_OK | W_OK)) { 313 return O_RDWR; 314 } else if ((mode & R_OK) == R_OK) { 315 return O_RDONLY; 316 } else if ((mode & W_OK) == W_OK) { 317 return O_WRONLY; 318 } else { 319 throw new IllegalArgumentException("Bad mode: " + mode); 320 } 321 } 322 323 /** 324 * Test if a file lives under the given directory, either as a direct child 325 * or a distant grandchild. 326 * <p> 327 * Both files <em>must</em> have been resolved using 328 * {@link File#getCanonicalFile()} to avoid symlink or path traversal 329 * attacks. 330 * 331 * @hide 332 */ contains(File[] dirs, File file)333 public static boolean contains(File[] dirs, File file) { 334 for (File dir : dirs) { 335 if (contains(dir, file)) { 336 return true; 337 } 338 } 339 return false; 340 } 341 342 /** {@hide} */ contains(Collection<File> dirs, File file)343 public static boolean contains(Collection<File> dirs, File file) { 344 for (File dir : dirs) { 345 if (contains(dir, file)) { 346 return true; 347 } 348 } 349 return false; 350 } 351 352 /** 353 * Test if a file lives under the given directory, either as a direct child 354 * or a distant grandchild. 355 * <p> 356 * Both files <em>must</em> have been resolved using 357 * {@link File#getCanonicalFile()} to avoid symlink or path traversal 358 * attacks. 359 * 360 * @hide 361 */ contains(File dir, File file)362 public static boolean contains(File dir, File file) { 363 if (dir == null || file == null) return false; 364 return contains(dir.getAbsolutePath(), file.getAbsolutePath()); 365 } 366 367 /** 368 * Test if a file lives under the given directory, either as a direct child 369 * or a distant grandchild. 370 * <p> 371 * Both files <em>must</em> have been resolved using 372 * {@link File#getCanonicalFile()} to avoid symlink or path traversal 373 * attacks. 374 * 375 * @hide 376 */ contains(String dirPath, String filePath)377 public static boolean contains(String dirPath, String filePath) { 378 if (dirPath.equals(filePath)) { 379 return true; 380 } 381 if (!dirPath.endsWith("/")) { 382 dirPath += "/"; 383 } 384 return filePath.startsWith(dirPath); 385 } 386 387 /** 388 * Write {@link String} to the given {@link File}. Deletes any existing file 389 * when the argument is {@link Optional#empty()}. 390 */ writeString(@onNull File file, @NonNull Optional<String> value)391 public static void writeString(@NonNull File file, @NonNull Optional<String> value) 392 throws IOException { 393 if (value.isPresent()) { 394 Files.write(file.toPath(), value.get().getBytes(StandardCharsets.UTF_8)); 395 } else { 396 file.delete(); 397 } 398 } 399 400 private static final int MAX_READ_STRING_SIZE = 4096; 401 402 /** 403 * Read given {@link File} as a single {@link String}. Returns 404 * {@link Optional#empty()} when 405 * <ul> 406 * <li> the file doesn't exist or 407 * <li> the size of the file exceeds {@code MAX_READ_STRING_SIZE} 408 * </ul> 409 */ readString(@onNull File file)410 public static @NonNull Optional<String> readString(@NonNull File file) throws IOException { 411 try { 412 if (file.length() <= MAX_READ_STRING_SIZE) { 413 final String value = new String(Files.readAllBytes(file.toPath()), 414 StandardCharsets.UTF_8); 415 return Optional.of(value); 416 } 417 // When file size exceeds MAX_READ_STRING_SIZE, file is either 418 // corrupted or doesn't the contain expected data. Hence we return 419 // Optional.empty() which will be interpreted as empty file. 420 Logging.logPersistent(String.format("Ignored reading %s, file size exceeds %d", file, 421 MAX_READ_STRING_SIZE)); 422 } catch (NoSuchFileException ignored) { 423 } 424 return Optional.empty(); 425 } 426 427 /** 428 * Recursively walk the contents of the given {@link Path}, invoking the 429 * given {@link Consumer} for every file and directory encountered. This is 430 * typically used for recursively deleting a directory tree. 431 * <p> 432 * Gracefully attempts to process as much as possible in the face of any 433 * failures. 434 */ walkFileTreeContents(@onNull Path path, @NonNull Consumer<Path> operation)435 public static void walkFileTreeContents(@NonNull Path path, @NonNull Consumer<Path> operation) { 436 try { 437 Files.walkFileTree(path, new FileVisitor<Path>() { 438 @Override 439 public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) { 440 return FileVisitResult.CONTINUE; 441 } 442 443 @Override 444 public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { 445 if (!Objects.equals(path, file)) { 446 operation.accept(file); 447 } 448 return FileVisitResult.CONTINUE; 449 } 450 451 @Override 452 public FileVisitResult visitFileFailed(Path file, IOException e) { 453 Log.w(TAG, "Failed to visit " + file, e); 454 return FileVisitResult.CONTINUE; 455 } 456 457 @Override 458 public FileVisitResult postVisitDirectory(Path dir, IOException e) { 459 if (!Objects.equals(path, dir)) { 460 operation.accept(dir); 461 } 462 return FileVisitResult.CONTINUE; 463 } 464 }); 465 } catch (IOException e) { 466 Log.w(TAG, "Failed to walk " + path, e); 467 } 468 } 469 470 /** 471 * Recursively delete all contents inside the given directory. Gracefully 472 * attempts to delete as much as possible in the face of any failures. 473 * 474 * @deprecated if you're calling this from inside {@code MediaProvider}, you 475 * likely want to call {@link #forEach} with a separate 476 * invocation to invalidate FUSE entries. 477 */ 478 @Deprecated deleteContents(@onNull File dir)479 public static void deleteContents(@NonNull File dir) { 480 walkFileTreeContents(dir.toPath(), (path) -> { 481 path.toFile().delete(); 482 }); 483 } 484 isValidFatFilenameChar(char c)485 private static boolean isValidFatFilenameChar(char c) { 486 if ((0x00 <= c && c <= 0x1f)) { 487 return false; 488 } 489 switch (c) { 490 case '"': 491 case '*': 492 case '/': 493 case ':': 494 case '<': 495 case '>': 496 case '?': 497 case '\\': 498 case '|': 499 case 0x7F: 500 return false; 501 default: 502 return true; 503 } 504 } 505 506 /** 507 * Check if given filename is valid for a FAT filesystem. 508 * 509 * @hide 510 */ isValidFatFilename(String name)511 public static boolean isValidFatFilename(String name) { 512 return (name != null) && name.equals(buildValidFatFilename(name)); 513 } 514 515 /** 516 * Mutate the given filename to make it valid for a FAT filesystem, 517 * replacing any invalid characters with "_". 518 * 519 * @hide 520 */ buildValidFatFilename(String name)521 public static String buildValidFatFilename(String name) { 522 if (TextUtils.isEmpty(name) || ".".equals(name) || "..".equals(name)) { 523 return "(invalid)"; 524 } 525 final StringBuilder res = new StringBuilder(name.length()); 526 for (int i = 0; i < name.length(); i++) { 527 final char c = name.charAt(i); 528 if (isValidFatFilenameChar(c)) { 529 res.append(c); 530 } else { 531 res.append('_'); 532 } 533 } 534 535 trimFilename(res, MAX_FILENAME_BYTES); 536 return res.toString(); 537 } 538 539 /** {@hide} */ 540 // @VisibleForTesting trimFilename(String str, int maxBytes)541 public static String trimFilename(String str, int maxBytes) { 542 final StringBuilder res = new StringBuilder(str); 543 trimFilename(res, maxBytes); 544 return res.toString(); 545 } 546 547 /** {@hide} */ trimFilename(StringBuilder res, int maxBytes)548 private static void trimFilename(StringBuilder res, int maxBytes) { 549 byte[] raw = res.toString().getBytes(StandardCharsets.UTF_8); 550 if (raw.length > maxBytes) { 551 maxBytes -= 3; 552 while (raw.length > maxBytes) { 553 res.deleteCharAt(res.length() / 2); 554 raw = res.toString().getBytes(StandardCharsets.UTF_8); 555 } 556 res.insert(res.length() / 2, "..."); 557 } 558 } 559 560 /** {@hide} */ buildUniqueFileWithExtension(File parent, String name, String ext)561 private static File buildUniqueFileWithExtension(File parent, String name, String ext) 562 throws FileNotFoundException { 563 final Iterator<String> names = buildUniqueNameIterator(parent, name); 564 while (names.hasNext()) { 565 File file = buildFile(parent, names.next(), ext); 566 if (!file.exists()) { 567 return file; 568 } 569 } 570 throw new FileNotFoundException("Failed to create unique file"); 571 } 572 573 private static final Pattern PATTERN_DCF_STRICT = Pattern 574 .compile("([A-Z0-9_]{4})([0-9]{4})"); 575 private static final Pattern PATTERN_DCF_RELAXED = Pattern 576 .compile("((?:IMG|MVIMG|VID)_[0-9]{8}_[0-9]{6})(?:~([0-9]+))?"); 577 isDcim(@onNull File dir)578 private static boolean isDcim(@NonNull File dir) { 579 while (dir != null) { 580 if (Objects.equals("DCIM", dir.getName())) { 581 return true; 582 } 583 dir = dir.getParentFile(); 584 } 585 return false; 586 } 587 buildUniqueNameIterator(@onNull File parent, @NonNull String name)588 private static @NonNull Iterator<String> buildUniqueNameIterator(@NonNull File parent, 589 @NonNull String name) { 590 if (isDcim(parent)) { 591 final Matcher dcfStrict = PATTERN_DCF_STRICT.matcher(name); 592 if (dcfStrict.matches()) { 593 // Generate names like "IMG_1001" 594 final String prefix = dcfStrict.group(1); 595 return new Iterator<String>() { 596 int i = Integer.parseInt(dcfStrict.group(2)); 597 @Override 598 public String next() { 599 final String res = String.format(Locale.US, "%s%04d", prefix, i); 600 i++; 601 return res; 602 } 603 @Override 604 public boolean hasNext() { 605 return i <= 9999; 606 } 607 }; 608 } 609 610 final Matcher dcfRelaxed = PATTERN_DCF_RELAXED.matcher(name); 611 if (dcfRelaxed.matches()) { 612 // Generate names like "IMG_20190102_030405~2" 613 final String prefix = dcfRelaxed.group(1); 614 return new Iterator<String>() { 615 int i = TextUtils.isEmpty(dcfRelaxed.group(2)) 616 ? 1 617 : Integer.parseInt(dcfRelaxed.group(2)); 618 @Override 619 public String next() { 620 final String res = (i == 1) 621 ? prefix 622 : String.format(Locale.US, "%s~%d", prefix, i); 623 i++; 624 return res; 625 } 626 @Override 627 public boolean hasNext() { 628 return i <= 99; 629 } 630 }; 631 } 632 } 633 634 // Generate names like "foo (2)" 635 return new Iterator<String>() { 636 int i = 0; 637 @Override 638 public String next() { 639 final String res = (i == 0) ? name : name + " (" + i + ")"; 640 i++; 641 return res; 642 } 643 @Override 644 public boolean hasNext() { 645 return i < 32; 646 } 647 }; 648 } 649 650 /** 651 * Generates a unique file name under the given parent directory. If the display name doesn't 652 * have an extension that matches the requested MIME type, the default extension for that MIME 653 * type is appended. If a file already exists, the name is appended with a numerical value to 654 * make it unique. 655 * 656 * For example, the display name 'example' with 'text/plain' MIME might produce 657 * 'example.txt' or 'example (1).txt', etc. 658 * 659 * @throws FileNotFoundException 660 * @hide 661 */ 662 public static File buildUniqueFile(File parent, String mimeType, String displayName) 663 throws FileNotFoundException { 664 final String[] parts = splitFileName(mimeType, displayName); 665 return buildUniqueFileWithExtension(parent, parts[0], parts[1]); 666 } 667 668 /** {@hide} */ 669 public static File buildNonUniqueFile(File parent, String mimeType, String displayName) { 670 final String[] parts = splitFileName(mimeType, displayName); 671 return buildFile(parent, parts[0], parts[1]); 672 } 673 674 /** 675 * Generates a unique file name under the given parent directory, keeping 676 * any extension intact. 677 * 678 * @hide 679 */ 680 public static File buildUniqueFile(File parent, String displayName) 681 throws FileNotFoundException { 682 final String name; 683 final String ext; 684 685 // Extract requested extension from display name 686 final int lastDot = displayName.lastIndexOf('.'); 687 if (lastDot >= 0) { 688 name = displayName.substring(0, lastDot); 689 ext = displayName.substring(lastDot + 1); 690 } else { 691 name = displayName; 692 ext = null; 693 } 694 695 return buildUniqueFileWithExtension(parent, name, ext); 696 } 697 698 /** 699 * Splits file name into base name and extension. 700 * If the display name doesn't have an extension that matches the requested MIME type, the 701 * extension is regarded as a part of filename and default extension for that MIME type is 702 * appended. 703 * 704 * @hide 705 */ 706 public static String[] splitFileName(String mimeType, String displayName) { 707 String name; 708 String ext; 709 710 { 711 String mimeTypeFromExt; 712 713 // Extract requested extension from display name 714 final int lastDot = displayName.lastIndexOf('.'); 715 if (lastDot > 0) { 716 name = displayName.substring(0, lastDot); 717 ext = displayName.substring(lastDot + 1); 718 mimeTypeFromExt = MimeTypeMap.getSingleton().getMimeTypeFromExtension( 719 ext.toLowerCase(Locale.ROOT)); 720 } else { 721 name = displayName; 722 ext = null; 723 mimeTypeFromExt = null; 724 } 725 726 if (mimeTypeFromExt == null) { 727 mimeTypeFromExt = ClipDescription.MIMETYPE_UNKNOWN; 728 } 729 730 final String extFromMimeType; 731 if (ClipDescription.MIMETYPE_UNKNOWN.equalsIgnoreCase(mimeType)) { 732 extFromMimeType = null; 733 } else { 734 extFromMimeType = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType); 735 } 736 737 if (MimeUtils.equalIgnoreCase(mimeType, mimeTypeFromExt) 738 || MimeUtils.equalIgnoreCase(ext, extFromMimeType)) { 739 // Extension maps back to requested MIME type; allow it 740 } else { 741 // No match; insist that create file matches requested MIME 742 name = displayName; 743 ext = extFromMimeType; 744 } 745 } 746 747 if (ext == null) { 748 ext = ""; 749 } 750 751 return new String[] { name, ext }; 752 } 753 754 /** {@hide} */ 755 private static File buildFile(File parent, String name, String ext) { 756 if (TextUtils.isEmpty(ext)) { 757 return new File(parent, name); 758 } else { 759 return new File(parent, name + "." + ext); 760 } 761 } 762 763 public static @Nullable String extractDisplayName(@Nullable String data) { 764 if (data == null) return null; 765 if (data.indexOf('/') == -1) { 766 return data; 767 } 768 if (data.endsWith("/")) { 769 data = data.substring(0, data.length() - 1); 770 } 771 return data.substring(data.lastIndexOf('/') + 1); 772 } 773 774 public static @Nullable String extractFileName(@Nullable String data) { 775 if (data == null) return null; 776 data = extractDisplayName(data); 777 778 final int lastDot = data.lastIndexOf('.'); 779 if (lastDot == -1) { 780 return data; 781 } else { 782 return data.substring(0, lastDot); 783 } 784 } 785 786 public static @Nullable String extractFileExtension(@Nullable String data) { 787 if (data == null) return null; 788 data = extractDisplayName(data); 789 790 final int lastDot = data.lastIndexOf('.'); 791 if (lastDot == -1) { 792 return null; 793 } else { 794 return data.substring(lastDot + 1); 795 } 796 } 797 798 /** 799 * Return list of paths that should be scanned with 800 * {@link com.android.providers.media.scan.MediaScanner} for the given 801 * volume name. 802 */ 803 public static @NonNull Collection<File> getVolumeScanPaths(@NonNull Context context, 804 @NonNull String volumeName) throws FileNotFoundException { 805 final ArrayList<File> res = new ArrayList<>(); 806 switch (volumeName) { 807 case MediaStore.VOLUME_INTERNAL: { 808 res.addAll(Environment.getInternalMediaDirectories()); 809 break; 810 } 811 case MediaStore.VOLUME_EXTERNAL: { 812 for (String resolvedVolumeName : MediaStore.getExternalVolumeNames(context)) { 813 res.add(getVolumePath(context, resolvedVolumeName)); 814 } 815 break; 816 } 817 default: { 818 res.add(getVolumePath(context, volumeName)); 819 } 820 } 821 return res; 822 } 823 824 /** 825 * Return path where the given volume name is mounted. 826 */ 827 public static @NonNull File getVolumePath(@NonNull Context context, 828 @NonNull String volumeName) throws FileNotFoundException { 829 switch (volumeName) { 830 case MediaStore.VOLUME_INTERNAL: 831 case MediaStore.VOLUME_EXTERNAL: 832 throw new FileNotFoundException(volumeName + " has no associated path"); 833 } 834 835 final Uri uri = MediaStore.Files.getContentUri(volumeName); 836 File path = null; 837 838 try { 839 path = context.getSystemService(StorageManager.class).getStorageVolume(uri) 840 .getDirectory(); 841 } catch (IllegalStateException e) { 842 Log.w("Ignoring volume not found exception", e); 843 } 844 845 if (path != null) { 846 return path; 847 } else { 848 throw new FileNotFoundException(volumeName + " has no associated path"); 849 } 850 } 851 852 /** 853 * Returns the content URI for the volume that contains the given path. 854 * 855 * <p>{@link MediaStore.Files#getContentUriForPath(String)} can't detect public volumes and can 856 * only return the URI for the primary external storage, that's why this utility should be used 857 * instead. 858 */ 859 public static @NonNull Uri getContentUriForPath(@NonNull String path) { 860 Objects.requireNonNull(path); 861 return MediaStore.Files.getContentUri(extractVolumeName(path)); 862 } 863 864 /** 865 * Return StorageVolume corresponding to the file on Path 866 */ 867 public static @NonNull StorageVolume getStorageVolume(@NonNull Context context, 868 @NonNull File path) throws FileNotFoundException { 869 int userId = extractUserId(path.getPath()); 870 Context userContext = context; 871 if (userId >= 0 && (context.getUser().getIdentifier() != userId)) { 872 // This volume is for a different user than our context, create a context 873 // for that user to retrieve the correct volume. 874 try { 875 userContext = context.createPackageContextAsUser("system", 0, 876 UserHandle.of(userId)); 877 } catch (PackageManager.NameNotFoundException e) { 878 throw new FileNotFoundException("Can't get package context for user " + userId); 879 } 880 } 881 882 StorageVolume volume = userContext.getSystemService(StorageManager.class) 883 .getStorageVolume(path); 884 if (volume == null) { 885 throw new FileNotFoundException("Can't find volume for " + path.getPath()); 886 } 887 888 return volume; 889 } 890 891 /** 892 * Return volume name which hosts the given path. 893 */ 894 public static @NonNull String getVolumeName(@NonNull Context context, @NonNull File path) 895 throws FileNotFoundException { 896 if (contains(Environment.getStorageDirectory(), path)) { 897 StorageVolume volume = getStorageVolume(context, path); 898 return volume.getMediaStoreVolumeName(); 899 } else { 900 return MediaStore.VOLUME_INTERNAL; 901 } 902 } 903 904 public static final Pattern PATTERN_DOWNLOADS_FILE = Pattern.compile( 905 "(?i)^/storage/[^/]+/(?:[0-9]+/)?Download/.+"); 906 public static final Pattern PATTERN_DOWNLOADS_DIRECTORY = Pattern.compile( 907 "(?i)^/storage/[^/]+/(?:[0-9]+/)?Download/?"); 908 public static final Pattern PATTERN_EXPIRES_FILE = Pattern.compile( 909 "(?i)^\\.(pending|trashed)-(\\d+)-([^/]+)$"); 910 public static final Pattern PATTERN_PENDING_FILEPATH_FOR_SQL = Pattern.compile( 911 ".*/\\.pending-(\\d+)-([^/]+)$"); 912 913 /** 914 * File prefix indicating that the file {@link MediaColumns#IS_PENDING}. 915 */ 916 public static final String PREFIX_PENDING = "pending"; 917 918 /** 919 * File prefix indicating that the file {@link MediaColumns#IS_TRASHED}. 920 */ 921 public static final String PREFIX_TRASHED = "trashed"; 922 923 /** 924 * Default duration that {@link MediaColumns#IS_PENDING} items should be 925 * preserved for until automatically cleaned by {@link #runIdleMaintenance}. 926 */ 927 public static final long DEFAULT_DURATION_PENDING = 7 * DateUtils.DAY_IN_MILLIS; 928 929 /** 930 * Default duration that {@link MediaColumns#IS_TRASHED} items should be 931 * preserved for until automatically cleaned by {@link #runIdleMaintenance}. 932 */ 933 public static final long DEFAULT_DURATION_TRASHED = 30 * DateUtils.DAY_IN_MILLIS; 934 935 /** 936 * Default duration that expired items should be extended in 937 * {@link #runIdleMaintenance}. 938 */ 939 public static final long DEFAULT_DURATION_EXTENDED = 7 * DateUtils.DAY_IN_MILLIS; 940 941 public static boolean isDownload(@NonNull String path) { 942 return PATTERN_DOWNLOADS_FILE.matcher(path).matches(); 943 } 944 945 public static boolean isDownloadDir(@NonNull String path) { 946 return PATTERN_DOWNLOADS_DIRECTORY.matcher(path).matches(); 947 } 948 949 private static final boolean PROP_CROSS_USER_ALLOWED = 950 SystemProperties.getBoolean("external_storage.cross_user.enabled", false); 951 952 private static final String PROP_CROSS_USER_ROOT = isCrossUserEnabled() 953 ? SystemProperties.get("external_storage.cross_user.root", "") : ""; 954 955 private static final String PROP_CROSS_USER_ROOT_PATTERN = ((PROP_CROSS_USER_ROOT.isEmpty()) 956 ? "" : "(?:" + PROP_CROSS_USER_ROOT + "/)?"); 957 958 /** 959 * Regex that matches paths in all well-known package-specific directories, 960 * and which captures the package name as the first group. 961 */ 962 public static final Pattern PATTERN_OWNED_PATH = Pattern.compile( 963 "(?i)^/storage/[^/]+/(?:[0-9]+/)?" 964 + PROP_CROSS_USER_ROOT_PATTERN 965 + "Android/(?:data|media|obb)/([^/]+)(/?.*)?"); 966 967 /** 968 * Regex that matches paths in all well-known package-specific relative directory 969 * path (as defined in {@link MediaColumns#RELATIVE_PATH}) 970 * and which captures the package name as the first group. 971 */ 972 private static final Pattern PATTERN_OWNED_RELATIVE_PATH = Pattern.compile( 973 "(?i)^Android/(?:data|media|obb)/([^/]+)(/?.*)?"); 974 975 /** 976 * Regex that matches Android/obb or Android/data path. 977 */ 978 private static final Pattern PATTERN_DATA_OR_OBB_PATH = Pattern.compile( 979 "(?i)^/storage/[^/]+/(?:[0-9]+/)?" 980 + PROP_CROSS_USER_ROOT_PATTERN 981 + "Android/(?:data|obb)(?:/.*)?$"); 982 983 /** 984 * Regex that matches Android/obb or Android/data relative path (as defined in 985 * {@link MediaColumns#RELATIVE_PATH}) 986 */ 987 private static final Pattern PATTERN_DATA_OR_OBB_RELATIVE_PATH = Pattern.compile( 988 "(?i)^Android/(?:data|obb)(?:/.*)?$"); 989 990 /** 991 * Regex that matches Android/obb {@link MediaColumns#RELATIVE_PATH}. 992 */ 993 private static final Pattern PATTERN_OBB_OR_CHILD_RELATIVE_PATH = Pattern.compile( 994 "(?i)^Android/obb(?:/.*)?$"); 995 996 /** 997 * The recordings directory. This is used for R OS. For S OS or later, 998 * we use {@link Environment#DIRECTORY_RECORDINGS} directly. 999 */ 1000 public static final String DIRECTORY_RECORDINGS = "Recordings"; 1001 1002 @VisibleForTesting 1003 public static final String[] DEFAULT_FOLDER_NAMES; 1004 static { 1005 if (SdkLevel.isAtLeastS()) { 1006 DEFAULT_FOLDER_NAMES = new String[]{ 1007 Environment.DIRECTORY_MUSIC, 1008 Environment.DIRECTORY_PODCASTS, 1009 Environment.DIRECTORY_RINGTONES, 1010 Environment.DIRECTORY_ALARMS, 1011 Environment.DIRECTORY_NOTIFICATIONS, 1012 Environment.DIRECTORY_PICTURES, 1013 Environment.DIRECTORY_MOVIES, 1014 Environment.DIRECTORY_DOWNLOADS, 1015 Environment.DIRECTORY_DCIM, 1016 Environment.DIRECTORY_DOCUMENTS, 1017 Environment.DIRECTORY_AUDIOBOOKS, 1018 Environment.DIRECTORY_RECORDINGS, 1019 }; 1020 } else { 1021 DEFAULT_FOLDER_NAMES = new String[]{ 1022 Environment.DIRECTORY_MUSIC, 1023 Environment.DIRECTORY_PODCASTS, 1024 Environment.DIRECTORY_RINGTONES, 1025 Environment.DIRECTORY_ALARMS, 1026 Environment.DIRECTORY_NOTIFICATIONS, 1027 Environment.DIRECTORY_PICTURES, 1028 Environment.DIRECTORY_MOVIES, 1029 Environment.DIRECTORY_DOWNLOADS, 1030 Environment.DIRECTORY_DCIM, 1031 Environment.DIRECTORY_DOCUMENTS, 1032 Environment.DIRECTORY_AUDIOBOOKS, 1033 DIRECTORY_RECORDINGS, 1034 }; 1035 } 1036 } 1037 1038 /** 1039 * Regex that matches paths for {@link MediaColumns#RELATIVE_PATH} 1040 */ 1041 private static final Pattern PATTERN_RELATIVE_PATH = Pattern.compile( 1042 "(?i)^/storage/(?:emulated/[0-9]+/|[^/]+/)"); 1043 1044 /** 1045 * Regex that matches paths under well-known storage paths. 1046 */ 1047 private static final Pattern PATTERN_VOLUME_NAME = Pattern.compile( 1048 "(?i)^/storage/([^/]+)"); 1049 1050 /** 1051 * Regex that matches user-ids under well-known storage paths. 1052 */ 1053 private static final Pattern PATTERN_USER_ID = Pattern.compile( 1054 "(?i)^/storage/emulated/([0-9]+)"); 1055 1056 private static final String CAMERA_RELATIVE_PATH = 1057 String.format("%s/%s/", Environment.DIRECTORY_DCIM, "Camera"); 1058 1059 public static boolean isCrossUserEnabled() { 1060 return PROP_CROSS_USER_ALLOWED || SdkLevel.isAtLeastS(); 1061 } 1062 1063 private static @Nullable String normalizeUuid(@Nullable String fsUuid) { 1064 return fsUuid != null ? fsUuid.toLowerCase(Locale.ROOT) : null; 1065 } 1066 1067 public static int extractUserId(@Nullable String data) { 1068 if (data == null) return -1; 1069 final Matcher matcher = PATTERN_USER_ID.matcher(data); 1070 if (matcher.find()) { 1071 return Integer.parseInt(matcher.group(1)); 1072 } 1073 1074 return -1; 1075 } 1076 1077 public static @Nullable String extractVolumePath(@Nullable String data) { 1078 if (data == null) return null; 1079 final Matcher matcher = PATTERN_RELATIVE_PATH.matcher(data); 1080 if (matcher.find()) { 1081 return data.substring(0, matcher.end()); 1082 } else { 1083 return null; 1084 } 1085 } 1086 1087 public static @Nullable String extractVolumeName(@Nullable String data) { 1088 if (data == null) return null; 1089 final Matcher matcher = PATTERN_VOLUME_NAME.matcher(data); 1090 if (matcher.find()) { 1091 final String volumeName = matcher.group(1); 1092 if (volumeName.equals("emulated")) { 1093 return MediaStore.VOLUME_EXTERNAL_PRIMARY; 1094 } else { 1095 return normalizeUuid(volumeName); 1096 } 1097 } else { 1098 return MediaStore.VOLUME_INTERNAL; 1099 } 1100 } 1101 1102 public static @Nullable String extractRelativePath(@Nullable String data) { 1103 if (data == null) return null; 1104 final Matcher matcher = PATTERN_RELATIVE_PATH.matcher(data); 1105 if (matcher.find()) { 1106 final int lastSlash = data.lastIndexOf('/'); 1107 if (lastSlash == -1 || lastSlash < matcher.end()) { 1108 // This is a file in the top-level directory, so relative path is "/" 1109 // which is different than null, which means unknown path 1110 return "/"; 1111 } else { 1112 return data.substring(matcher.end(), lastSlash + 1); 1113 } 1114 } else { 1115 return null; 1116 } 1117 } 1118 1119 /** 1120 * Returns relative path with display name. 1121 */ 1122 @VisibleForTesting 1123 public static @Nullable String extractRelativePathWithDisplayName(@Nullable String path) { 1124 if (path == null) return null; 1125 1126 if (path.equals("/storage/emulated") || path.equals("/storage/emulated/")) { 1127 // This path is not reachable for MediaProvider. 1128 return null; 1129 } 1130 1131 // We are extracting relative path for the directory itself, we add "/" so that we can use 1132 // same PATTERN_RELATIVE_PATH to match relative path for directory. For example, relative 1133 // path of '/storage/<volume_name>' is null where as relative path for directory is "/", for 1134 // PATTERN_RELATIVE_PATH to match '/storage/<volume_name>', it should end with "/". 1135 if (!path.endsWith("/")) { 1136 // Relative path for directory should end with "/". 1137 path += "/"; 1138 } 1139 1140 final Matcher matcher = PATTERN_RELATIVE_PATH.matcher(path); 1141 if (matcher.find()) { 1142 if (matcher.end() == path.length()) { 1143 // This is the top-level directory, so relative path is "/" 1144 return "/"; 1145 } 1146 return path.substring(matcher.end()); 1147 } 1148 return null; 1149 } 1150 1151 public static @Nullable String extractPathOwnerPackageName(@Nullable String path) { 1152 if (path == null) return null; 1153 final Matcher m = PATTERN_OWNED_PATH.matcher(path); 1154 if (m.matches()) { 1155 return m.group(1); 1156 } 1157 return null; 1158 } 1159 1160 public static @Nullable String extractOwnerPackageNameFromRelativePath(@Nullable String path) { 1161 if (path == null) return null; 1162 final Matcher m = PATTERN_OWNED_RELATIVE_PATH.matcher(path); 1163 if (m.matches()) { 1164 return m.group(1); 1165 } 1166 return null; 1167 } 1168 1169 public static boolean isExternalMediaDirectory(@NonNull String path) { 1170 return isExternalMediaDirectory(path, PROP_CROSS_USER_ROOT); 1171 } 1172 1173 @VisibleForTesting 1174 static boolean isExternalMediaDirectory(@NonNull String path, String crossUserRoot) { 1175 final String relativePath = extractRelativePath(path); 1176 if (relativePath != null) { 1177 final String externalMediaDir = (crossUserRoot == null || crossUserRoot.isEmpty()) 1178 ? "Android/media" : crossUserRoot + "/Android/media"; 1179 return relativePath.startsWith(externalMediaDir); 1180 } 1181 return false; 1182 } 1183 1184 /** 1185 * Returns true if path is Android/data or Android/obb path. 1186 */ 1187 public static boolean isDataOrObbPath(@Nullable String path) { 1188 if (path == null) return false; 1189 final Matcher m = PATTERN_DATA_OR_OBB_PATH.matcher(path); 1190 return m.matches(); 1191 } 1192 1193 /** 1194 * Returns true if relative path is Android/data or Android/obb path. 1195 */ 1196 public static boolean isDataOrObbRelativePath(@Nullable String path) { 1197 if (path == null) return false; 1198 final Matcher m = PATTERN_DATA_OR_OBB_RELATIVE_PATH.matcher(path); 1199 return m.matches(); 1200 } 1201 1202 /** 1203 * Returns true if relative path is Android/obb path. 1204 */ 1205 public static boolean isObbOrChildRelativePath(@Nullable String path) { 1206 if (path == null) return false; 1207 final Matcher m = PATTERN_OBB_OR_CHILD_RELATIVE_PATH.matcher(path); 1208 return m.matches(); 1209 } 1210 1211 /** 1212 * Returns the name of the top level directory, or null if the path doesn't go through the 1213 * external storage directory. 1214 */ 1215 @Nullable 1216 public static String extractTopLevelDir(String path) { 1217 final String relativePath = extractRelativePath(path); 1218 if (relativePath == null) { 1219 return null; 1220 } 1221 1222 return extractTopLevelDir(relativePath.split("/")); 1223 } 1224 1225 @Nullable 1226 public static String extractTopLevelDir(String[] relativePathSegments) { 1227 return extractTopLevelDir(relativePathSegments, PROP_CROSS_USER_ROOT); 1228 } 1229 1230 @VisibleForTesting 1231 @Nullable 1232 static String extractTopLevelDir(String[] relativePathSegments, String crossUserRoot) { 1233 if (relativePathSegments == null) return null; 1234 1235 final String topLevelDir = relativePathSegments.length > 0 ? relativePathSegments[0] : null; 1236 if (crossUserRoot != null && crossUserRoot.equals(topLevelDir)) { 1237 return relativePathSegments.length > 1 ? relativePathSegments[1] : null; 1238 } 1239 1240 return topLevelDir; 1241 } 1242 1243 public static boolean isDefaultDirectoryName(@Nullable String dirName) { 1244 for (String defaultDirName : DEFAULT_FOLDER_NAMES) { 1245 if (defaultDirName.equalsIgnoreCase(dirName)) { 1246 return true; 1247 } 1248 } 1249 return false; 1250 } 1251 1252 /** 1253 * Compute the value of {@link MediaColumns#DATE_EXPIRES} based on other 1254 * columns being modified by this operation. 1255 */ 1256 public static void computeDateExpires(@NonNull ContentValues values) { 1257 // External apps have no ability to change this field 1258 values.remove(MediaColumns.DATE_EXPIRES); 1259 1260 // Only define the field when this modification is actually adjusting 1261 // one of the flags that should influence the expiration 1262 final Object pending = values.get(MediaColumns.IS_PENDING); 1263 if (pending != null) { 1264 if (parseBoolean(pending, false)) { 1265 values.put(MediaColumns.DATE_EXPIRES, 1266 (System.currentTimeMillis() + DEFAULT_DURATION_PENDING) / 1000); 1267 } else { 1268 values.putNull(MediaColumns.DATE_EXPIRES); 1269 } 1270 } 1271 final Object trashed = values.get(MediaColumns.IS_TRASHED); 1272 if (trashed != null) { 1273 if (parseBoolean(trashed, false)) { 1274 values.put(MediaColumns.DATE_EXPIRES, 1275 (System.currentTimeMillis() + DEFAULT_DURATION_TRASHED) / 1000); 1276 } else { 1277 values.putNull(MediaColumns.DATE_EXPIRES); 1278 } 1279 } 1280 } 1281 1282 /** 1283 * Compute several scattered {@link MediaColumns} values from 1284 * {@link MediaColumns#DATA}. This method performs no enforcement of 1285 * argument validity. 1286 */ 1287 public static void computeValuesFromData(@NonNull ContentValues values, boolean isForFuse) { 1288 // Worst case we have to assume no bucket details 1289 values.remove(MediaColumns.VOLUME_NAME); 1290 values.remove(MediaColumns.RELATIVE_PATH); 1291 values.remove(MediaColumns.IS_TRASHED); 1292 values.remove(MediaColumns.DATE_EXPIRES); 1293 values.remove(MediaColumns.DISPLAY_NAME); 1294 values.remove(MediaColumns.BUCKET_ID); 1295 values.remove(MediaColumns.BUCKET_DISPLAY_NAME); 1296 1297 final String data = values.getAsString(MediaColumns.DATA); 1298 if (TextUtils.isEmpty(data)) return; 1299 1300 final File file = new File(data); 1301 final File fileLower = new File(data.toLowerCase(Locale.ROOT)); 1302 1303 values.put(MediaColumns.VOLUME_NAME, extractVolumeName(data)); 1304 values.put(MediaColumns.RELATIVE_PATH, extractRelativePath(data)); 1305 final String displayName = extractDisplayName(data); 1306 final Matcher matcher = FileUtils.PATTERN_EXPIRES_FILE.matcher(displayName); 1307 if (matcher.matches()) { 1308 values.put(MediaColumns.IS_PENDING, 1309 matcher.group(1).equals(FileUtils.PREFIX_PENDING) ? 1 : 0); 1310 values.put(MediaColumns.IS_TRASHED, 1311 matcher.group(1).equals(FileUtils.PREFIX_TRASHED) ? 1 : 0); 1312 values.put(MediaColumns.DATE_EXPIRES, Long.parseLong(matcher.group(2))); 1313 values.put(MediaColumns.DISPLAY_NAME, matcher.group(3)); 1314 } else { 1315 if (isForFuse) { 1316 // Allow Fuse thread to set IS_PENDING when using DATA column. 1317 // TODO(b/156867379) Unset IS_PENDING when Fuse thread doesn't explicitly specify 1318 // IS_PENDING. It can't be done now because we scan after create. Scan doesn't 1319 // explicitly specify the value of IS_PENDING. 1320 } else { 1321 values.put(MediaColumns.IS_PENDING, 0); 1322 } 1323 values.put(MediaColumns.IS_TRASHED, 0); 1324 values.putNull(MediaColumns.DATE_EXPIRES); 1325 values.put(MediaColumns.DISPLAY_NAME, displayName); 1326 } 1327 1328 // Buckets are the parent directory 1329 final String parent = fileLower.getParent(); 1330 if (parent != null) { 1331 values.put(MediaColumns.BUCKET_ID, parent.hashCode()); 1332 // The relative path for files in the top directory is "/" 1333 if (!"/".equals(values.getAsString(MediaColumns.RELATIVE_PATH))) { 1334 values.put(MediaColumns.BUCKET_DISPLAY_NAME, file.getParentFile().getName()); 1335 } 1336 } 1337 } 1338 1339 /** 1340 * Compute {@link MediaColumns#DATA} from several scattered 1341 * {@link MediaColumns} values. This method performs no enforcement of 1342 * argument validity. 1343 */ 1344 public static void computeDataFromValues(@NonNull ContentValues values, 1345 @NonNull File volumePath, boolean isForFuse) { 1346 values.remove(MediaColumns.DATA); 1347 1348 final String displayName = values.getAsString(MediaColumns.DISPLAY_NAME); 1349 final String resolvedDisplayName; 1350 // Pending file path shouldn't be rewritten for files inserted via filepath. 1351 if (!isForFuse && getAsBoolean(values, MediaColumns.IS_PENDING, false)) { 1352 final long dateExpires = getAsLong(values, MediaColumns.DATE_EXPIRES, 1353 (System.currentTimeMillis() + DEFAULT_DURATION_PENDING) / 1000); 1354 final String combinedString = String.format( 1355 Locale.US, ".%s-%d-%s", FileUtils.PREFIX_PENDING, dateExpires, displayName); 1356 // trim the file name to avoid ENAMETOOLONG error 1357 // after trim the file, if the user unpending the file, 1358 // the file name is not the original one 1359 resolvedDisplayName = trimFilename(combinedString, MAX_FILENAME_BYTES); 1360 } else if (getAsBoolean(values, MediaColumns.IS_TRASHED, false)) { 1361 final long dateExpires = getAsLong(values, MediaColumns.DATE_EXPIRES, 1362 (System.currentTimeMillis() + DEFAULT_DURATION_TRASHED) / 1000); 1363 final String combinedString = String.format( 1364 Locale.US, ".%s-%d-%s", FileUtils.PREFIX_TRASHED, dateExpires, displayName); 1365 // trim the file name to avoid ENAMETOOLONG error 1366 // after trim the file, if the user untrashes the file, 1367 // the file name is not the original one 1368 resolvedDisplayName = trimFilename(combinedString, MAX_FILENAME_BYTES); 1369 } else { 1370 resolvedDisplayName = displayName; 1371 } 1372 1373 final File filePath = buildPath(volumePath, 1374 values.getAsString(MediaColumns.RELATIVE_PATH), resolvedDisplayName); 1375 values.put(MediaColumns.DATA, filePath.getAbsolutePath()); 1376 } 1377 1378 public static void sanitizeValues(@NonNull ContentValues values, 1379 boolean rewriteHiddenFileName) { 1380 final String[] relativePath = values.getAsString(MediaColumns.RELATIVE_PATH).split("/"); 1381 for (int i = 0; i < relativePath.length; i++) { 1382 relativePath[i] = sanitizeDisplayName(relativePath[i], rewriteHiddenFileName); 1383 } 1384 values.put(MediaColumns.RELATIVE_PATH, 1385 String.join("/", relativePath) + "/"); 1386 1387 final String displayName = values.getAsString(MediaColumns.DISPLAY_NAME); 1388 values.put(MediaColumns.DISPLAY_NAME, 1389 sanitizeDisplayName(displayName, rewriteHiddenFileName)); 1390 } 1391 1392 /** {@hide} **/ 1393 @Nullable 1394 public static String getAbsoluteSanitizedPath(String path) { 1395 final String[] pathSegments = sanitizePath(path); 1396 if (pathSegments.length == 0) { 1397 return null; 1398 } 1399 return path = "/" + String.join("/", 1400 Arrays.copyOfRange(pathSegments, 1, pathSegments.length)); 1401 } 1402 1403 /** {@hide} */ 1404 public static @NonNull String[] sanitizePath(@Nullable String path) { 1405 if (path == null) { 1406 return new String[0]; 1407 } else { 1408 final String[] segments = path.split("/"); 1409 // If the path corresponds to the top level directory, then we return an empty path 1410 // which denotes the top level directory 1411 if (segments.length == 0) { 1412 return new String[] { "" }; 1413 } 1414 for (int i = 0; i < segments.length; i++) { 1415 segments[i] = sanitizeDisplayName(segments[i]); 1416 } 1417 return segments; 1418 } 1419 } 1420 1421 /** 1422 * Sanitizes given name by mutating the file name to make it valid for a FAT filesystem. 1423 * @hide 1424 */ 1425 public static @Nullable String sanitizeDisplayName(@Nullable String name) { 1426 return sanitizeDisplayName(name, /*rewriteHiddenFileName*/ false); 1427 } 1428 1429 /** 1430 * Sanitizes given name by appending '_' to make it non-hidden and mutating the file name to 1431 * make it valid for a FAT filesystem. 1432 * @hide 1433 */ 1434 public static @Nullable String sanitizeDisplayName(@Nullable String name, 1435 boolean rewriteHiddenFileName) { 1436 if (name == null) { 1437 return null; 1438 } else if (rewriteHiddenFileName && name.startsWith(".")) { 1439 // The resulting file must not be hidden. 1440 return "_" + name; 1441 } else { 1442 return buildValidFatFilename(name); 1443 } 1444 } 1445 1446 /** 1447 * Test if this given directory should be considered hidden. 1448 */ 1449 @VisibleForTesting 1450 public static boolean isDirectoryHidden(@NonNull File dir) { 1451 final String name = dir.getName(); 1452 if (name.startsWith(".")) { 1453 return true; 1454 } 1455 1456 final File nomedia = new File(dir, ".nomedia"); 1457 1458 // check for .nomedia presence 1459 if (!nomedia.exists()) { 1460 return false; 1461 } 1462 1463 // Handle top-level default directories. These directories should always be visible, 1464 // regardless of .nomedia presence. 1465 final String[] relativePath = sanitizePath(extractRelativePath(dir.getAbsolutePath())); 1466 final boolean isTopLevelDir = 1467 relativePath.length == 1 && TextUtils.isEmpty(relativePath[0]); 1468 if (isTopLevelDir && isDefaultDirectoryName(name)) { 1469 nomedia.delete(); 1470 return false; 1471 } 1472 1473 // DCIM/Camera should always be visible regardless of .nomedia presence. 1474 if (CAMERA_RELATIVE_PATH.equalsIgnoreCase( 1475 extractRelativePathWithDisplayName(dir.getAbsolutePath()))) { 1476 nomedia.delete(); 1477 return false; 1478 } 1479 1480 if (isScreenshotsDirNonHidden(relativePath, name)) { 1481 nomedia.delete(); 1482 return false; 1483 } 1484 1485 // .nomedia is present which makes this directory as hidden directory 1486 Logging.logPersistent("Observed non-standard " + nomedia); 1487 return true; 1488 } 1489 1490 /** 1491 * Consider Screenshots directory in root directory or inside well-known directory as always 1492 * non-hidden. Nomedia file in these directories will not be able to hide these directories. 1493 * i.e., some examples of directories that will be considered non-hidden are 1494 * <ul> 1495 * <li> /storage/emulated/0/Screenshots or 1496 * <li> /storage/emulated/0/DCIM/Screenshots or 1497 * <li> /storage/emulated/0/Pictures/Screenshots ... 1498 * </ul> 1499 * Some examples of directories that can be considered as hidden with nomedia are 1500 * <ul> 1501 * <li> /storage/emulated/0/foo/Screenshots or 1502 * <li> /storage/emulated/0/DCIM/Foo/Screenshots or 1503 * <li> /storage/emulated/0/Pictures/foo/bar/Screenshots ... 1504 * </ul> 1505 */ 1506 private static boolean isScreenshotsDirNonHidden(@NonNull String[] relativePath, 1507 @NonNull String name) { 1508 if (name.equalsIgnoreCase(Environment.DIRECTORY_SCREENSHOTS)) { 1509 return (relativePath.length == 1 && 1510 (TextUtils.isEmpty(relativePath[0]) || isDefaultDirectoryName(relativePath[0]))); 1511 } 1512 return false; 1513 } 1514 1515 /** 1516 * Test if this given file should be considered hidden. 1517 */ 1518 @VisibleForTesting 1519 public static boolean isFileHidden(@NonNull File file) { 1520 final String name = file.getName(); 1521 1522 // Handle well-known file names that are pending or trashed; they 1523 // normally appear hidden, but we give them special treatment 1524 if (PATTERN_EXPIRES_FILE.matcher(name).matches()) { 1525 return false; 1526 } 1527 1528 // Otherwise fall back to file name 1529 if (name.startsWith(".")) { 1530 return true; 1531 } 1532 return false; 1533 } 1534 1535 /** 1536 * Clears all app's external cache directories, i.e. for each app we delete 1537 * /sdcard/Android/data/app/cache/* but we keep the directory itself. 1538 * 1539 * @return 0 in case of success, or {@link OsConstants#EIO} if any error occurs. 1540 * 1541 * <p>This method doesn't perform any checks, so make sure that the calling package is allowed 1542 * to clear cache directories first. 1543 * 1544 * <p>If this method returned {@link OsConstants#EIO}, then we can't guarantee whether all, none 1545 * or part of the directories were cleared. 1546 */ 1547 public static int clearAppCacheDirectories() { 1548 int status = 0; 1549 Log.i(TAG, "Clearing cache for all apps"); 1550 final File rootDataDir = buildPath(Environment.getExternalStorageDirectory(), 1551 "Android", "data"); 1552 for (File appDataDir : rootDataDir.listFiles()) { 1553 try { 1554 final File appCacheDir = new File(appDataDir, "cache"); 1555 if (appCacheDir.isDirectory()) { 1556 FileUtils.deleteContents(appCacheDir); 1557 } 1558 } catch (Exception e) { 1559 // We want to avoid crashing MediaProvider at all costs, so we handle all "generic" 1560 // exceptions here, and just report to the caller that an IO exception has occurred. 1561 // We still try to clear the rest of the directories. 1562 Log.e(TAG, "Couldn't delete all app cache dirs!", e); 1563 status = OsConstants.EIO; 1564 } 1565 } 1566 return status; 1567 } 1568 1569 /** 1570 * @return {@code true} if {@code dir} is dirty and should be scanned, {@code false} otherwise. 1571 */ 1572 public static boolean isDirectoryDirty(File dir) { 1573 File nomedia = new File(dir, ".nomedia"); 1574 if (nomedia.exists()) { 1575 try { 1576 Optional<String> expectedPath = readString(nomedia); 1577 // Returns true If .nomedia file is empty or content doesn't match |dir| 1578 // Returns false otherwise 1579 return !expectedPath.isPresent() 1580 || !expectedPath.get().equals(dir.getPath()); 1581 } catch (IOException e) { 1582 Log.w(TAG, "Failed to read directory dirty" + dir); 1583 } 1584 } 1585 return true; 1586 } 1587 1588 /** 1589 * {@code isDirty} == {@code true} will force {@code dir} scanning even if it's hidden 1590 * {@code isDirty} == {@code false} will skip {@code dir} scanning on next scan. 1591 */ 1592 public static void setDirectoryDirty(File dir, boolean isDirty) { 1593 File nomedia = new File(dir, ".nomedia"); 1594 if (nomedia.exists()) { 1595 try { 1596 writeString(nomedia, isDirty ? Optional.of("") : Optional.of(dir.getPath())); 1597 } catch (IOException e) { 1598 Log.w(TAG, "Failed to change directory dirty: " + dir + ". isDirty: " + isDirty); 1599 } 1600 } 1601 } 1602 1603 /** 1604 * @return the folder containing the top-most .nomedia in {@code file} hierarchy. 1605 * E.g input as /sdcard/foo/bar/ will return /sdcard/foo 1606 * even if foo and bar contain .nomedia files. 1607 * 1608 * Returns {@code null} if there's no .nomedia in hierarchy 1609 */ 1610 public static File getTopLevelNoMedia(@NonNull File file) { 1611 File topNoMediaDir = null; 1612 1613 File parent = file; 1614 while (parent != null) { 1615 File nomedia = new File(parent, ".nomedia"); 1616 if (nomedia.exists()) { 1617 topNoMediaDir = parent; 1618 } 1619 parent = parent.getParentFile(); 1620 } 1621 1622 return topNoMediaDir; 1623 } 1624 1625 /** 1626 * Generate the extended absolute path from the expired file path 1627 * E.g. the input expiredFilePath is /storage/emulated/0/DCIM/.trashed-1621147340-test.jpg 1628 * The returned result is /storage/emulated/0/DCIM/.trashed-1888888888-test.jpg 1629 * 1630 * @hide 1631 */ 1632 @Nullable 1633 public static String getAbsoluteExtendedPath(@NonNull String expiredFilePath, 1634 long extendedTime) { 1635 final String displayName = extractDisplayName(expiredFilePath); 1636 1637 final Matcher matcher = PATTERN_EXPIRES_FILE.matcher(displayName); 1638 if (matcher.matches()) { 1639 final String newDisplayName = String.format(Locale.US, ".%s-%d-%s", matcher.group(1), 1640 extendedTime, matcher.group(3)); 1641 final int lastSlash = expiredFilePath.lastIndexOf('/'); 1642 final String newPath = expiredFilePath.substring(0, lastSlash + 1).concat( 1643 newDisplayName); 1644 return newPath; 1645 } 1646 1647 return null; 1648 } 1649 } 1650