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