blob: f488340f3cfdaa0f875e182ca96066389b4b9bfa [file] [log] [blame]
Jeff Sharkey7ea24f22019-08-22 10:14:18 -06001/*
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
17package com.android.providers.media.util;
18
Jeff Sharkeyf06febd2020-04-07 13:03:30 -060019import static android.os.ParcelFileDescriptor.MODE_APPEND;
20import static android.os.ParcelFileDescriptor.MODE_CREATE;
21import static android.os.ParcelFileDescriptor.MODE_READ_ONLY;
22import static android.os.ParcelFileDescriptor.MODE_READ_WRITE;
23import static android.os.ParcelFileDescriptor.MODE_TRUNCATE;
24import static android.os.ParcelFileDescriptor.MODE_WRITE_ONLY;
25import static android.system.OsConstants.F_OK;
26import static android.system.OsConstants.O_ACCMODE;
27import static android.system.OsConstants.O_APPEND;
Jeff Sharkey9a497642020-04-23 13:15:10 -060028import static android.system.OsConstants.O_CLOEXEC;
Jeff Sharkeyf06febd2020-04-07 13:03:30 -060029import static android.system.OsConstants.O_CREAT;
Jeff Sharkey9a497642020-04-23 13:15:10 -060030import static android.system.OsConstants.O_NOFOLLOW;
Jeff Sharkeyf06febd2020-04-07 13:03:30 -060031import static android.system.OsConstants.O_RDONLY;
32import static android.system.OsConstants.O_RDWR;
33import static android.system.OsConstants.O_TRUNC;
34import static android.system.OsConstants.O_WRONLY;
35import static android.system.OsConstants.R_OK;
Jeff Sharkey9a497642020-04-23 13:15:10 -060036import static android.system.OsConstants.S_IRWXG;
37import static android.system.OsConstants.S_IRWXU;
Jeff Sharkeyf06febd2020-04-07 13:03:30 -060038import static android.system.OsConstants.W_OK;
39
Jeff Sharkey89149b62020-03-29 22:03:44 -060040import static com.android.providers.media.util.DatabaseUtils.getAsBoolean;
41import static com.android.providers.media.util.DatabaseUtils.getAsLong;
Jeff Sharkeya9473e92020-04-17 15:54:30 -060042import static com.android.providers.media.util.DatabaseUtils.parseBoolean;
Jeff Sharkeyc55994b2019-12-20 19:43:59 -070043import static com.android.providers.media.util.Logging.TAG;
Jeff Sharkey7ea24f22019-08-22 10:14:18 -060044
Jeff Sharkey7ea24f22019-08-22 10:14:18 -060045import android.content.ClipDescription;
Jeff Sharkeyc55994b2019-12-20 19:43:59 -070046import android.content.ContentValues;
Jeff Sharkeyc5c39142019-12-15 22:46:03 -070047import android.content.Context;
48import android.net.Uri;
49import android.os.Environment;
Jeff Sharkey9a497642020-04-23 13:15:10 -060050import android.os.ParcelFileDescriptor;
Jeff Sharkeyc5c39142019-12-15 22:46:03 -070051import android.os.storage.StorageManager;
Martijn Coenen2bf49fa2020-11-02 11:46:32 +010052import android.os.storage.StorageVolume;
Jeff Sharkeyc5c39142019-12-15 22:46:03 -070053import android.provider.MediaStore;
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -070054import android.provider.MediaStore.MediaColumns;
Jeff Sharkey9a497642020-04-23 13:15:10 -060055import android.system.ErrnoException;
56import android.system.Os;
57import android.system.OsConstants;
Jeff Sharkey7ea24f22019-08-22 10:14:18 -060058import android.text.TextUtils;
Jeff Sharkey89149b62020-03-29 22:03:44 -060059import android.text.format.DateUtils;
Jeff Sharkey7ea24f22019-08-22 10:14:18 -060060import android.util.Log;
61import android.webkit.MimeTypeMap;
62
63import androidx.annotation.NonNull;
64import androidx.annotation.Nullable;
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -070065import androidx.annotation.VisibleForTesting;
Jeff Sharkey7ea24f22019-08-22 10:14:18 -060066
Ivan Chiang7fce4a52021-01-25 16:53:54 +080067import com.android.modules.utils.build.SdkLevel;
68
Jeff Sharkey7ea24f22019-08-22 10:14:18 -060069import java.io.File;
Jeff Sharkey9a497642020-04-23 13:15:10 -060070import java.io.FileDescriptor;
Jeff Sharkey7ea24f22019-08-22 10:14:18 -060071import java.io.FileNotFoundException;
72import java.io.IOException;
73import java.io.InputStream;
74import java.io.OutputStream;
75import java.nio.charset.StandardCharsets;
Jeff Sharkeybb4e5e62020-02-09 17:14:08 -070076import java.nio.file.FileVisitResult;
77import java.nio.file.FileVisitor;
78import java.nio.file.Files;
79import java.nio.file.NoSuchFileException;
80import java.nio.file.Path;
Jeff Sharkeybb4e5e62020-02-09 17:14:08 -070081import java.nio.file.attribute.BasicFileAttributes;
Jeff Sharkeyc5c39142019-12-15 22:46:03 -070082import java.util.ArrayList;
Jeff Sharkey5278ead2020-01-07 16:40:18 -070083import java.util.Arrays;
Jeff Sharkey7ea24f22019-08-22 10:14:18 -060084import java.util.Collection;
Jeff Sharkey5278ead2020-01-07 16:40:18 -070085import java.util.Comparator;
Jeff Sharkey06b38ca2019-12-17 15:51:12 -070086import java.util.Iterator;
Jeff Sharkey470b97e2019-10-15 16:32:04 -060087import java.util.Locale;
Jeff Sharkey7ea24f22019-08-22 10:14:18 -060088import java.util.Objects;
Jeff Sharkeybb4e5e62020-02-09 17:14:08 -070089import java.util.Optional;
90import java.util.function.Consumer;
Jeff Sharkey06b38ca2019-12-17 15:51:12 -070091import java.util.regex.Matcher;
92import java.util.regex.Pattern;
Jeff Sharkey7ea24f22019-08-22 10:14:18 -060093
94public class FileUtils {
Ivan Chiang9aa80952020-12-04 13:11:53 +080095 // Even though vfat allows 255 UCS-2 chars, we might eventually write to
96 // ext4 through a FUSE layer, so use that limit.
97 @VisibleForTesting
98 static final int MAX_FILENAME_BYTES = 255;
99
Jeff Sharkey9a497642020-04-23 13:15:10 -0600100 /**
101 * Drop-in replacement for {@link ParcelFileDescriptor#open(File, int)}
102 * which adds security features like {@link OsConstants#O_CLOEXEC} and
103 * {@link OsConstants#O_NOFOLLOW}.
104 */
105 public static @NonNull ParcelFileDescriptor openSafely(@NonNull File file, int pfdFlags)
106 throws FileNotFoundException {
107 final int posixFlags = translateModePfdToPosix(pfdFlags) | O_CLOEXEC | O_NOFOLLOW;
108 try {
109 final FileDescriptor fd = Os.open(file.getAbsolutePath(), posixFlags,
110 S_IRWXU | S_IRWXG);
111 try {
112 return ParcelFileDescriptor.dup(fd);
113 } finally {
114 closeQuietly(fd);
115 }
116 } catch (IOException | ErrnoException e) {
117 throw new FileNotFoundException(e.getMessage());
118 }
119 }
120
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600121 public static void closeQuietly(@Nullable AutoCloseable closeable) {
122 android.os.FileUtils.closeQuietly(closeable);
123 }
124
Jeff Sharkey9a497642020-04-23 13:15:10 -0600125 public static void closeQuietly(@Nullable FileDescriptor fd) {
126 if (fd == null) return;
127 try {
128 Os.close(fd);
129 } catch (ErrnoException ignored) {
130 }
131 }
132
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600133 public static long copy(@NonNull InputStream in, @NonNull OutputStream out) throws IOException {
134 return android.os.FileUtils.copy(in, out);
135 }
136
137 public static File buildPath(File base, String... segments) {
138 File cur = base;
139 for (String segment : segments) {
140 if (cur == null) {
141 cur = new File(segment);
142 } else {
143 cur = new File(cur, segment);
144 }
145 }
146 return cur;
147 }
148
149 /**
Jeff Sharkey5278ead2020-01-07 16:40:18 -0700150 * Delete older files in a directory until only those matching the given
151 * constraints remain.
152 *
153 * @param minCount Always keep at least this many files.
154 * @param minAgeMs Always keep files younger than this age, in milliseconds.
155 * @return if any files were deleted.
156 */
157 public static boolean deleteOlderFiles(File dir, int minCount, long minAgeMs) {
158 if (minCount < 0 || minAgeMs < 0) {
159 throw new IllegalArgumentException("Constraints must be positive or 0");
160 }
161
162 final File[] files = dir.listFiles();
163 if (files == null) return false;
164
165 // Sort with newest files first
166 Arrays.sort(files, new Comparator<File>() {
167 @Override
168 public int compare(File lhs, File rhs) {
169 return Long.compare(rhs.lastModified(), lhs.lastModified());
170 }
171 });
172
173 // Keep at least minCount files
174 boolean deleted = false;
175 for (int i = minCount; i < files.length; i++) {
176 final File file = files[i];
177
178 // Keep files newer than minAgeMs
179 final long age = System.currentTimeMillis() - file.lastModified();
180 if (age > minAgeMs) {
181 if (file.delete()) {
182 Log.d(TAG, "Deleted old file " + file);
183 deleted = true;
184 }
185 }
186 }
187 return deleted;
188 }
189
190 /**
Jeff Sharkeyf06febd2020-04-07 13:03:30 -0600191 * Shamelessly borrowed from {@code android.os.FileUtils}.
192 */
193 public static int translateModeStringToPosix(String mode) {
194 // Sanity check for invalid chars
195 for (int i = 0; i < mode.length(); i++) {
196 switch (mode.charAt(i)) {
197 case 'r':
198 case 'w':
199 case 't':
200 case 'a':
201 break;
202 default:
203 throw new IllegalArgumentException("Bad mode: " + mode);
204 }
205 }
206
207 int res = 0;
208 if (mode.startsWith("rw")) {
209 res = O_RDWR | O_CREAT;
210 } else if (mode.startsWith("w")) {
211 res = O_WRONLY | O_CREAT;
212 } else if (mode.startsWith("r")) {
213 res = O_RDONLY;
214 } else {
215 throw new IllegalArgumentException("Bad mode: " + mode);
216 }
217 if (mode.indexOf('t') != -1) {
218 res |= O_TRUNC;
219 }
220 if (mode.indexOf('a') != -1) {
221 res |= O_APPEND;
222 }
223 return res;
224 }
225
226 /**
227 * Shamelessly borrowed from {@code android.os.FileUtils}.
228 */
229 public static String translateModePosixToString(int mode) {
230 String res = "";
231 if ((mode & O_ACCMODE) == O_RDWR) {
232 res = "rw";
233 } else if ((mode & O_ACCMODE) == O_WRONLY) {
234 res = "w";
235 } else if ((mode & O_ACCMODE) == O_RDONLY) {
236 res = "r";
237 } else {
238 throw new IllegalArgumentException("Bad mode: " + mode);
239 }
240 if ((mode & O_TRUNC) == O_TRUNC) {
241 res += "t";
242 }
243 if ((mode & O_APPEND) == O_APPEND) {
244 res += "a";
245 }
246 return res;
247 }
248
249 /**
250 * Shamelessly borrowed from {@code android.os.FileUtils}.
251 */
252 public static int translateModePosixToPfd(int mode) {
253 int res = 0;
254 if ((mode & O_ACCMODE) == O_RDWR) {
255 res = MODE_READ_WRITE;
256 } else if ((mode & O_ACCMODE) == O_WRONLY) {
257 res = MODE_WRITE_ONLY;
258 } else if ((mode & O_ACCMODE) == O_RDONLY) {
259 res = MODE_READ_ONLY;
260 } else {
261 throw new IllegalArgumentException("Bad mode: " + mode);
262 }
263 if ((mode & O_CREAT) == O_CREAT) {
264 res |= MODE_CREATE;
265 }
266 if ((mode & O_TRUNC) == O_TRUNC) {
267 res |= MODE_TRUNCATE;
268 }
269 if ((mode & O_APPEND) == O_APPEND) {
270 res |= MODE_APPEND;
271 }
272 return res;
273 }
274
275 /**
276 * Shamelessly borrowed from {@code android.os.FileUtils}.
277 */
278 public static int translateModePfdToPosix(int mode) {
279 int res = 0;
280 if ((mode & MODE_READ_WRITE) == MODE_READ_WRITE) {
281 res = O_RDWR;
282 } else if ((mode & MODE_WRITE_ONLY) == MODE_WRITE_ONLY) {
283 res = O_WRONLY;
284 } else if ((mode & MODE_READ_ONLY) == MODE_READ_ONLY) {
285 res = O_RDONLY;
286 } else {
287 throw new IllegalArgumentException("Bad mode: " + mode);
288 }
289 if ((mode & MODE_CREATE) == MODE_CREATE) {
290 res |= O_CREAT;
291 }
292 if ((mode & MODE_TRUNCATE) == MODE_TRUNCATE) {
293 res |= O_TRUNC;
294 }
295 if ((mode & MODE_APPEND) == MODE_APPEND) {
296 res |= O_APPEND;
297 }
298 return res;
299 }
300
301 /**
302 * Shamelessly borrowed from {@code android.os.FileUtils}.
303 */
304 public static int translateModeAccessToPosix(int mode) {
305 if (mode == F_OK) {
306 // There's not an exact mapping, so we attempt a read-only open to
307 // determine if a file exists
308 return O_RDONLY;
309 } else if ((mode & (R_OK | W_OK)) == (R_OK | W_OK)) {
310 return O_RDWR;
311 } else if ((mode & R_OK) == R_OK) {
312 return O_RDONLY;
313 } else if ((mode & W_OK) == W_OK) {
314 return O_WRONLY;
315 } else {
316 throw new IllegalArgumentException("Bad mode: " + mode);
317 }
318 }
319
320 /**
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600321 * Test if a file lives under the given directory, either as a direct child
322 * or a distant grandchild.
323 * <p>
324 * Both files <em>must</em> have been resolved using
325 * {@link File#getCanonicalFile()} to avoid symlink or path traversal
326 * attacks.
327 *
328 * @hide
329 */
330 public static boolean contains(File[] dirs, File file) {
331 for (File dir : dirs) {
332 if (contains(dir, file)) {
333 return true;
334 }
335 }
336 return false;
337 }
338
339 /** {@hide} */
340 public static boolean contains(Collection<File> dirs, File file) {
341 for (File dir : dirs) {
342 if (contains(dir, file)) {
343 return true;
344 }
345 }
346 return false;
347 }
348
349 /**
350 * Test if a file lives under the given directory, either as a direct child
351 * or a distant grandchild.
352 * <p>
353 * Both files <em>must</em> have been resolved using
354 * {@link File#getCanonicalFile()} to avoid symlink or path traversal
355 * attacks.
356 *
357 * @hide
358 */
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600359 public static boolean contains(File dir, File file) {
360 if (dir == null || file == null) return false;
361 return contains(dir.getAbsolutePath(), file.getAbsolutePath());
362 }
363
364 /**
365 * Test if a file lives under the given directory, either as a direct child
366 * or a distant grandchild.
367 * <p>
368 * Both files <em>must</em> have been resolved using
369 * {@link File#getCanonicalFile()} to avoid symlink or path traversal
370 * attacks.
371 *
372 * @hide
373 */
374 public static boolean contains(String dirPath, String filePath) {
375 if (dirPath.equals(filePath)) {
376 return true;
377 }
378 if (!dirPath.endsWith("/")) {
379 dirPath += "/";
380 }
381 return filePath.startsWith(dirPath);
382 }
383
Jeff Sharkeybb4e5e62020-02-09 17:14:08 -0700384 /**
385 * Write {@link String} to the given {@link File}. Deletes any existing file
386 * when the argument is {@link Optional#empty()}.
387 */
388 public static void writeString(@NonNull File file, @NonNull Optional<String> value)
389 throws IOException {
390 if (value.isPresent()) {
391 Files.write(file.toPath(), value.get().getBytes(StandardCharsets.UTF_8));
392 } else {
393 file.delete();
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600394 }
Jeff Sharkeybb4e5e62020-02-09 17:14:08 -0700395 }
396
Sahana Raoe64f5bb2020-12-01 12:35:49 +0000397 private static final int MAX_READ_STRING_SIZE = 4096;
398
Jeff Sharkeybb4e5e62020-02-09 17:14:08 -0700399 /**
400 * Read given {@link File} as a single {@link String}. Returns
Sahana Raoe64f5bb2020-12-01 12:35:49 +0000401 * {@link Optional#empty()} when
402 * <ul>
403 * <li> the file doesn't exist or
404 * <li> the size of the file exceeds {@code MAX_READ_STRING_SIZE}
405 * </ul>
Jeff Sharkeybb4e5e62020-02-09 17:14:08 -0700406 */
407 public static @NonNull Optional<String> readString(@NonNull File file) throws IOException {
408 try {
Sahana Raoe64f5bb2020-12-01 12:35:49 +0000409 if (file.length() <= MAX_READ_STRING_SIZE) {
410 final String value = new String(Files.readAllBytes(file.toPath()),
411 StandardCharsets.UTF_8);
412 return Optional.of(value);
413 }
414 // When file size exceeds MAX_READ_STRING_SIZE, file is either
415 // corrupted or doesn't the contain expected data. Hence we return
416 // Optional.empty() which will be interpreted as empty file.
417 Logging.logPersistent(String.format("Ignored reading %s, file size exceeds %d", file,
418 MAX_READ_STRING_SIZE));
419 } catch (NoSuchFileException ignored) {
Jeff Sharkeybb4e5e62020-02-09 17:14:08 -0700420 }
Sahana Raoe64f5bb2020-12-01 12:35:49 +0000421 return Optional.empty();
Jeff Sharkeybb4e5e62020-02-09 17:14:08 -0700422 }
423
424 /**
425 * Recursively walk the contents of the given {@link Path}, invoking the
426 * given {@link Consumer} for every file and directory encountered. This is
427 * typically used for recursively deleting a directory tree.
428 * <p>
429 * Gracefully attempts to process as much as possible in the face of any
430 * failures.
431 */
432 public static void walkFileTreeContents(@NonNull Path path, @NonNull Consumer<Path> operation) {
433 try {
434 Files.walkFileTree(path, new FileVisitor<Path>() {
435 @Override
436 public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
437 return FileVisitResult.CONTINUE;
438 }
439
440 @Override
441 public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
442 if (!Objects.equals(path, file)) {
443 operation.accept(file);
444 }
445 return FileVisitResult.CONTINUE;
446 }
447
448 @Override
449 public FileVisitResult visitFileFailed(Path file, IOException e) {
450 Log.w(TAG, "Failed to visit " + file, e);
451 return FileVisitResult.CONTINUE;
452 }
453
454 @Override
455 public FileVisitResult postVisitDirectory(Path dir, IOException e) {
456 if (!Objects.equals(path, dir)) {
457 operation.accept(dir);
458 }
459 return FileVisitResult.CONTINUE;
460 }
461 });
462 } catch (IOException e) {
463 Log.w(TAG, "Failed to walk " + path, e);
464 }
465 }
466
467 /**
468 * Recursively delete all contents inside the given directory. Gracefully
469 * attempts to delete as much as possible in the face of any failures.
470 *
Jeff Sharkey89149b62020-03-29 22:03:44 -0600471 * @deprecated if you're calling this from inside {@code MediaProvider}, you
Jeff Sharkeybb4e5e62020-02-09 17:14:08 -0700472 * likely want to call {@link #forEach} with a separate
473 * invocation to invalidate FUSE entries.
474 */
475 @Deprecated
476 public static void deleteContents(@NonNull File dir) {
477 walkFileTreeContents(dir.toPath(), (path) -> {
478 path.toFile().delete();
479 });
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600480 }
481
482 private static boolean isValidFatFilenameChar(char c) {
483 if ((0x00 <= c && c <= 0x1f)) {
484 return false;
485 }
486 switch (c) {
487 case '"':
488 case '*':
489 case '/':
490 case ':':
491 case '<':
492 case '>':
493 case '?':
494 case '\\':
495 case '|':
496 case 0x7F:
497 return false;
498 default:
499 return true;
500 }
501 }
502
503 /**
504 * Check if given filename is valid for a FAT filesystem.
505 *
506 * @hide
507 */
508 public static boolean isValidFatFilename(String name) {
509 return (name != null) && name.equals(buildValidFatFilename(name));
510 }
511
512 /**
513 * Mutate the given filename to make it valid for a FAT filesystem,
514 * replacing any invalid characters with "_".
515 *
516 * @hide
517 */
518 public static String buildValidFatFilename(String name) {
519 if (TextUtils.isEmpty(name) || ".".equals(name) || "..".equals(name)) {
520 return "(invalid)";
521 }
522 final StringBuilder res = new StringBuilder(name.length());
523 for (int i = 0; i < name.length(); i++) {
524 final char c = name.charAt(i);
525 if (isValidFatFilenameChar(c)) {
526 res.append(c);
527 } else {
528 res.append('_');
529 }
530 }
Ivan Chiang9aa80952020-12-04 13:11:53 +0800531
532 trimFilename(res, MAX_FILENAME_BYTES);
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600533 return res.toString();
534 }
535
536 /** {@hide} */
537 // @VisibleForTesting
538 public static String trimFilename(String str, int maxBytes) {
539 final StringBuilder res = new StringBuilder(str);
540 trimFilename(res, maxBytes);
541 return res.toString();
542 }
543
544 /** {@hide} */
545 private static void trimFilename(StringBuilder res, int maxBytes) {
546 byte[] raw = res.toString().getBytes(StandardCharsets.UTF_8);
547 if (raw.length > maxBytes) {
548 maxBytes -= 3;
549 while (raw.length > maxBytes) {
550 res.deleteCharAt(res.length() / 2);
551 raw = res.toString().getBytes(StandardCharsets.UTF_8);
552 }
553 res.insert(res.length() / 2, "...");
554 }
555 }
556
557 /** {@hide} */
558 private static File buildUniqueFileWithExtension(File parent, String name, String ext)
559 throws FileNotFoundException {
Jeff Sharkey06b38ca2019-12-17 15:51:12 -0700560 final Iterator<String> names = buildUniqueNameIterator(parent, name);
561 while (names.hasNext()) {
562 File file = buildFile(parent, names.next(), ext);
563 if (!file.exists()) {
564 return file;
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600565 }
Jeff Sharkey06b38ca2019-12-17 15:51:12 -0700566 }
567 throw new FileNotFoundException("Failed to create unique file");
568 }
569
570 private static final Pattern PATTERN_DCF_STRICT = Pattern
571 .compile("([A-Z0-9_]{4})([0-9]{4})");
572 private static final Pattern PATTERN_DCF_RELAXED = Pattern
573 .compile("((?:IMG|MVIMG|VID)_[0-9]{8}_[0-9]{6})(?:~([0-9]+))?");
574
575 private static boolean isDcim(@NonNull File dir) {
576 while (dir != null) {
577 if (Objects.equals("DCIM", dir.getName())) {
578 return true;
579 }
580 dir = dir.getParentFile();
581 }
582 return false;
583 }
584
585 private static @NonNull Iterator<String> buildUniqueNameIterator(@NonNull File parent,
586 @NonNull String name) {
587 if (isDcim(parent)) {
588 final Matcher dcfStrict = PATTERN_DCF_STRICT.matcher(name);
589 if (dcfStrict.matches()) {
590 // Generate names like "IMG_1001"
591 final String prefix = dcfStrict.group(1);
592 return new Iterator<String>() {
593 int i = Integer.parseInt(dcfStrict.group(2));
594 @Override
595 public String next() {
Corina54fb8ba2020-11-27 13:38:09 +0000596 final String res = String.format(Locale.US, "%s%04d", prefix, i);
Jeff Sharkey06b38ca2019-12-17 15:51:12 -0700597 i++;
598 return res;
599 }
600 @Override
601 public boolean hasNext() {
602 return i <= 9999;
603 }
604 };
605 }
606
607 final Matcher dcfRelaxed = PATTERN_DCF_RELAXED.matcher(name);
608 if (dcfRelaxed.matches()) {
609 // Generate names like "IMG_20190102_030405~2"
610 final String prefix = dcfRelaxed.group(1);
611 return new Iterator<String>() {
Corina54fb8ba2020-11-27 13:38:09 +0000612 int i = TextUtils.isEmpty(dcfRelaxed.group(2))
613 ? 1
Jeff Sharkey06b38ca2019-12-17 15:51:12 -0700614 : Integer.parseInt(dcfRelaxed.group(2));
615 @Override
616 public String next() {
Corina54fb8ba2020-11-27 13:38:09 +0000617 final String res = (i == 1)
618 ? prefix
619 : String.format(Locale.US, "%s~%d", prefix, i);
Jeff Sharkey06b38ca2019-12-17 15:51:12 -0700620 i++;
621 return res;
622 }
623 @Override
624 public boolean hasNext() {
625 return i <= 99;
626 }
627 };
628 }
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600629 }
630
Jeff Sharkey06b38ca2019-12-17 15:51:12 -0700631 // Generate names like "foo (2)"
632 return new Iterator<String>() {
633 int i = 0;
634 @Override
635 public String next() {
636 final String res = (i == 0) ? name : name + " (" + i + ")";
637 i++;
638 return res;
639 }
640 @Override
641 public boolean hasNext() {
642 return i < 32;
643 }
644 };
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600645 }
646
647 /**
648 * Generates a unique file name under the given parent directory. If the display name doesn't
649 * have an extension that matches the requested MIME type, the default extension for that MIME
650 * type is appended. If a file already exists, the name is appended with a numerical value to
651 * make it unique.
652 *
653 * For example, the display name 'example' with 'text/plain' MIME might produce
654 * 'example.txt' or 'example (1).txt', etc.
655 *
656 * @throws FileNotFoundException
657 * @hide
658 */
659 public static File buildUniqueFile(File parent, String mimeType, String displayName)
660 throws FileNotFoundException {
661 final String[] parts = splitFileName(mimeType, displayName);
662 return buildUniqueFileWithExtension(parent, parts[0], parts[1]);
663 }
664
665 /** {@hide} */
666 public static File buildNonUniqueFile(File parent, String mimeType, String displayName) {
667 final String[] parts = splitFileName(mimeType, displayName);
668 return buildFile(parent, parts[0], parts[1]);
669 }
670
671 /**
672 * Generates a unique file name under the given parent directory, keeping
673 * any extension intact.
674 *
675 * @hide
676 */
677 public static File buildUniqueFile(File parent, String displayName)
678 throws FileNotFoundException {
679 final String name;
680 final String ext;
681
682 // Extract requested extension from display name
683 final int lastDot = displayName.lastIndexOf('.');
684 if (lastDot >= 0) {
685 name = displayName.substring(0, lastDot);
686 ext = displayName.substring(lastDot + 1);
687 } else {
688 name = displayName;
689 ext = null;
690 }
691
692 return buildUniqueFileWithExtension(parent, name, ext);
693 }
694
695 /**
696 * Splits file name into base name and extension.
697 * If the display name doesn't have an extension that matches the requested MIME type, the
698 * extension is regarded as a part of filename and default extension for that MIME type is
699 * appended.
700 *
701 * @hide
702 */
703 public static String[] splitFileName(String mimeType, String displayName) {
704 String name;
705 String ext;
706
707 {
708 String mimeTypeFromExt;
709
710 // Extract requested extension from display name
711 final int lastDot = displayName.lastIndexOf('.');
Sahana Raof3c8a162020-05-15 21:58:36 +0100712 if (lastDot > 0) {
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600713 name = displayName.substring(0, lastDot);
714 ext = displayName.substring(lastDot + 1);
715 mimeTypeFromExt = MimeTypeMap.getSingleton().getMimeTypeFromExtension(
Jeff Sharkey470b97e2019-10-15 16:32:04 -0600716 ext.toLowerCase(Locale.ROOT));
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600717 } else {
718 name = displayName;
719 ext = null;
720 mimeTypeFromExt = null;
721 }
722
723 if (mimeTypeFromExt == null) {
724 mimeTypeFromExt = ClipDescription.MIMETYPE_UNKNOWN;
725 }
726
727 final String extFromMimeType;
Jeff Sharkeyc4a5f812020-05-03 21:07:14 -0600728 if (ClipDescription.MIMETYPE_UNKNOWN.equalsIgnoreCase(mimeType)) {
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600729 extFromMimeType = null;
730 } else {
731 extFromMimeType = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType);
732 }
733
Jeff Sharkeyc4a5f812020-05-03 21:07:14 -0600734 if (MimeUtils.equalIgnoreCase(mimeType, mimeTypeFromExt)
735 || MimeUtils.equalIgnoreCase(ext, extFromMimeType)) {
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600736 // Extension maps back to requested MIME type; allow it
737 } else {
738 // No match; insist that create file matches requested MIME
739 name = displayName;
740 ext = extFromMimeType;
741 }
742 }
743
744 if (ext == null) {
745 ext = "";
746 }
747
748 return new String[] { name, ext };
749 }
750
751 /** {@hide} */
752 private static File buildFile(File parent, String name, String ext) {
753 if (TextUtils.isEmpty(ext)) {
754 return new File(parent, name);
755 } else {
756 return new File(parent, name + "." + ext);
757 }
758 }
Jeff Sharkeye152d5762019-10-11 17:14:51 -0600759
760 public static @Nullable String extractDisplayName(@Nullable String data) {
761 if (data == null) return null;
Jeff Sharkeye76c4262019-12-06 14:46:00 -0700762 if (data.indexOf('/') == -1) {
763 return data;
764 }
Jeff Sharkeye152d5762019-10-11 17:14:51 -0600765 if (data.endsWith("/")) {
766 data = data.substring(0, data.length() - 1);
767 }
768 return data.substring(data.lastIndexOf('/') + 1);
769 }
770
771 public static @Nullable String extractFileName(@Nullable String data) {
772 if (data == null) return null;
773 data = extractDisplayName(data);
774
775 final int lastDot = data.lastIndexOf('.');
776 if (lastDot == -1) {
777 return data;
778 } else {
779 return data.substring(0, lastDot);
780 }
781 }
782
783 public static @Nullable String extractFileExtension(@Nullable String data) {
784 if (data == null) return null;
785 data = extractDisplayName(data);
786
787 final int lastDot = data.lastIndexOf('.');
788 if (lastDot == -1) {
789 return null;
790 } else {
791 return data.substring(lastDot + 1);
792 }
793 }
Jeff Sharkeyc5c39142019-12-15 22:46:03 -0700794
795 /**
Jeff Sharkeyc55994b2019-12-20 19:43:59 -0700796 * Return list of paths that should be scanned with
797 * {@link com.android.providers.media.scan.MediaScanner} for the given
798 * volume name.
Jeff Sharkeyc5c39142019-12-15 22:46:03 -0700799 */
800 public static @NonNull Collection<File> getVolumeScanPaths(@NonNull Context context,
801 @NonNull String volumeName) throws FileNotFoundException {
802 final ArrayList<File> res = new ArrayList<>();
803 switch (volumeName) {
804 case MediaStore.VOLUME_INTERNAL: {
805 res.addAll(Environment.getInternalMediaDirectories());
806 break;
807 }
808 case MediaStore.VOLUME_EXTERNAL: {
809 for (String resolvedVolumeName : MediaStore.getExternalVolumeNames(context)) {
810 res.add(getVolumePath(context, resolvedVolumeName));
811 }
812 break;
813 }
814 default: {
815 res.add(getVolumePath(context, volumeName));
816 }
817 }
818 return res;
819 }
820
821 /**
822 * Return path where the given volume name is mounted.
823 */
824 public static @NonNull File getVolumePath(@NonNull Context context,
825 @NonNull String volumeName) throws FileNotFoundException {
826 switch (volumeName) {
827 case MediaStore.VOLUME_INTERNAL:
828 case MediaStore.VOLUME_EXTERNAL:
829 throw new FileNotFoundException(volumeName + " has no associated path");
830 }
831
832 final Uri uri = MediaStore.Files.getContentUri(volumeName);
Zimfb3b1062020-09-10 10:29:47 +0100833 File path = null;
834
835 try {
836 path = context.getSystemService(StorageManager.class).getStorageVolume(uri)
837 .getDirectory();
838 } catch (IllegalStateException e) {
839 Log.w("Ignoring volume not found exception", e);
840 }
841
Jeff Sharkeyf95b06f2020-06-02 11:10:35 -0600842 if (path != null) {
843 return path;
844 } else {
845 throw new FileNotFoundException(volumeName + " has no associated path");
846 }
Jeff Sharkeyc5c39142019-12-15 22:46:03 -0700847 }
848
849 /**
shafik536982a2020-05-14 17:54:05 +0100850 * Returns the content URI for the volume that contains the given path.
851 *
852 * <p>{@link MediaStore.Files#getContentUriForPath(String)} can't detect public volumes and can
853 * only return the URI for the primary external storage, that's why this utility should be used
854 * instead.
855 */
856 public static @NonNull Uri getContentUriForPath(@NonNull String path) {
857 Objects.requireNonNull(path);
858 return MediaStore.Files.getContentUri(extractVolumeName(path));
859 }
860
861 /**
Jeff Sharkeyc5c39142019-12-15 22:46:03 -0700862 * Return volume name which hosts the given path.
863 */
Martijn Coenen2bf49fa2020-11-02 11:46:32 +0100864 public static @NonNull String getVolumeName(@NonNull Context context, @NonNull File path)
865 throws FileNotFoundException {
Jeff Sharkeyc5c39142019-12-15 22:46:03 -0700866 if (contains(Environment.getStorageDirectory(), path)) {
Martijn Coenen2bf49fa2020-11-02 11:46:32 +0100867 StorageVolume volume = context.getSystemService(StorageManager.class)
868 .getStorageVolume(path);
869 if (volume == null) {
870 throw new FileNotFoundException("Can't find volume for " + path.getPath());
871 }
872 return volume.getMediaStoreVolumeName();
Jeff Sharkeyc5c39142019-12-15 22:46:03 -0700873 } else {
874 return MediaStore.VOLUME_INTERNAL;
875 }
876 }
Jeff Sharkey5ea5c282019-12-18 14:06:28 -0700877
878 public static final Pattern PATTERN_DOWNLOADS_FILE = Pattern.compile(
Zim78ffeeb2020-09-22 20:15:25 +0100879 "(?i)^/storage/[^/]+/(?:[0-9]+/)?Download/.+");
Jeff Sharkey5ea5c282019-12-18 14:06:28 -0700880 public static final Pattern PATTERN_DOWNLOADS_DIRECTORY = Pattern.compile(
Zim78ffeeb2020-09-22 20:15:25 +0100881 "(?i)^/storage/[^/]+/(?:[0-9]+/)?Download/?");
Jeff Sharkey89149b62020-03-29 22:03:44 -0600882 public static final Pattern PATTERN_EXPIRES_FILE = Pattern.compile(
Sahana Raoea587fc2020-06-03 15:56:23 +0100883 "(?i)^\\.(pending|trashed)-(\\d+)-([^/]+)$");
884 public static final Pattern PATTERN_PENDING_FILEPATH_FOR_SQL = Pattern.compile(
885 ".*/\\.pending-(\\d+)-([^/]+)$");
Jeff Sharkey89149b62020-03-29 22:03:44 -0600886
887 /**
888 * File prefix indicating that the file {@link MediaColumns#IS_PENDING}.
889 */
890 public static final String PREFIX_PENDING = "pending";
891
892 /**
893 * File prefix indicating that the file {@link MediaColumns#IS_TRASHED}.
894 */
895 public static final String PREFIX_TRASHED = "trashed";
896
897 /**
898 * Default duration that {@link MediaColumns#IS_PENDING} items should be
899 * preserved for until automatically cleaned by {@link #runIdleMaintenance}.
900 */
Jeff Sharkey05c3a032020-04-09 16:57:04 -0600901 public static final long DEFAULT_DURATION_PENDING = 7 * DateUtils.DAY_IN_MILLIS;
Jeff Sharkey89149b62020-03-29 22:03:44 -0600902
903 /**
904 * Default duration that {@link MediaColumns#IS_TRASHED} items should be
905 * preserved for until automatically cleaned by {@link #runIdleMaintenance}.
906 */
Jeff Sharkey05c3a032020-04-09 16:57:04 -0600907 public static final long DEFAULT_DURATION_TRASHED = 30 * DateUtils.DAY_IN_MILLIS;
Jeff Sharkey5ea5c282019-12-18 14:06:28 -0700908
909 public static boolean isDownload(@NonNull String path) {
910 return PATTERN_DOWNLOADS_FILE.matcher(path).matches();
911 }
912
913 public static boolean isDownloadDir(@NonNull String path) {
914 return PATTERN_DOWNLOADS_DIRECTORY.matcher(path).matches();
915 }
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -0700916
917 /**
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -0700918 * Regex that matches paths in all well-known package-specific directories,
919 * and which captures the package name as the first group.
920 */
921 public static final Pattern PATTERN_OWNED_PATH = Pattern.compile(
Zim78ffeeb2020-09-22 20:15:25 +0100922 "(?i)^/storage/[^/]+/(?:[0-9]+/)?Android/(?:data|media|obb)/([^/]+)(/?.*)?");
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -0700923
924 /**
Ricky Waifeb9d9b2020-04-06 19:14:46 +0100925 * Regex that matches Android/obb or Android/data path.
926 */
927 public static final Pattern PATTERN_DATA_OR_OBB_PATH = Pattern.compile(
928 "(?i)^/storage/[^/]+/(?:[0-9]+/)?Android/(?:data|obb)/?$");
929
Abhijeet Kaurb3ac2802020-10-07 16:42:42 +0100930 /**
931 * Regex that matches Android/obb paths.
932 */
933 public static final Pattern PATTERN_OBB_OR_CHILD_PATH = Pattern.compile(
934 "(?i)^/storage/[^/]+/(?:[0-9]+/)?Android/(?:obb)(/?.*)");
935
Sahana Raoe44080f2020-09-25 11:13:55 +0100936 @VisibleForTesting
Ivan Chiang7fce4a52021-01-25 16:53:54 +0800937 public static final String[] DEFAULT_FOLDER_NAMES;
938 static {
939 if (SdkLevel.isAtLeastS()) {
940 DEFAULT_FOLDER_NAMES = new String[]{
941 Environment.DIRECTORY_MUSIC,
942 Environment.DIRECTORY_PODCASTS,
943 Environment.DIRECTORY_RINGTONES,
944 Environment.DIRECTORY_ALARMS,
945 Environment.DIRECTORY_NOTIFICATIONS,
946 Environment.DIRECTORY_PICTURES,
947 Environment.DIRECTORY_MOVIES,
948 Environment.DIRECTORY_DOWNLOADS,
949 Environment.DIRECTORY_DCIM,
950 Environment.DIRECTORY_DOCUMENTS,
951 Environment.DIRECTORY_AUDIOBOOKS,
952 Environment.DIRECTORY_RECORDINGS,
953 };
954 } else {
955 DEFAULT_FOLDER_NAMES = new String[]{
956 Environment.DIRECTORY_MUSIC,
957 Environment.DIRECTORY_PODCASTS,
958 Environment.DIRECTORY_RINGTONES,
959 Environment.DIRECTORY_ALARMS,
960 Environment.DIRECTORY_NOTIFICATIONS,
961 Environment.DIRECTORY_PICTURES,
962 Environment.DIRECTORY_MOVIES,
963 Environment.DIRECTORY_DOWNLOADS,
964 Environment.DIRECTORY_DCIM,
965 Environment.DIRECTORY_DOCUMENTS,
966 Environment.DIRECTORY_AUDIOBOOKS,
967 };
968 }
969 }
Sahana Raoe44080f2020-09-25 11:13:55 +0100970
Ricky Waifeb9d9b2020-04-06 19:14:46 +0100971 /**
Zim78ffeeb2020-09-22 20:15:25 +0100972 * Regex that matches paths for {@link MediaColumns#RELATIVE_PATH}
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -0700973 */
974 private static final Pattern PATTERN_RELATIVE_PATH = Pattern.compile(
Zim78ffeeb2020-09-22 20:15:25 +0100975 "(?i)^/storage/(?:emulated/[0-9]+/|[^/]+/)");
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -0700976
977 /**
978 * Regex that matches paths under well-known storage paths.
979 */
980 private static final Pattern PATTERN_VOLUME_NAME = Pattern.compile(
981 "(?i)^/storage/([^/]+)");
982
Saumya Pathakb60b33b2021-04-12 13:54:52 +0000983 /**
984 * Regex that matches user-ids under well-known storage paths.
985 */
986 private static final Pattern PATTERN_USER_ID = Pattern.compile(
987 "(?i)^/storage/emulated/([0-9]+)/");
988
Sahana Raoe44080f2020-09-25 11:13:55 +0100989 private static final String CAMERA_RELATIVE_PATH =
990 String.format("%s/%s/", Environment.DIRECTORY_DCIM, "Camera");
991
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -0700992 private static @Nullable String normalizeUuid(@Nullable String fsUuid) {
Jeff Sharkeyc4a5f812020-05-03 21:07:14 -0600993 return fsUuid != null ? fsUuid.toLowerCase(Locale.ROOT) : null;
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -0700994 }
995
Saumya Pathakb60b33b2021-04-12 13:54:52 +0000996 public static int extractUserId(@Nullable String data) {
997 if (data == null) return -1;
998 final Matcher matcher = PATTERN_USER_ID.matcher(data);
999 if (matcher.find()) {
1000 return Integer.parseInt(matcher.group(1));
1001 }
1002
1003 return -1;
1004 }
1005
Jeff Sharkey89149b62020-03-29 22:03:44 -06001006 public static @Nullable String extractVolumePath(@Nullable String data) {
1007 if (data == null) return null;
1008 final Matcher matcher = PATTERN_RELATIVE_PATH.matcher(data);
1009 if (matcher.find()) {
1010 return data.substring(0, matcher.end());
1011 } else {
1012 return null;
1013 }
1014 }
1015
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001016 public static @Nullable String extractVolumeName(@Nullable String data) {
1017 if (data == null) return null;
1018 final Matcher matcher = PATTERN_VOLUME_NAME.matcher(data);
1019 if (matcher.find()) {
1020 final String volumeName = matcher.group(1);
1021 if (volumeName.equals("emulated")) {
1022 return MediaStore.VOLUME_EXTERNAL_PRIMARY;
1023 } else {
1024 return normalizeUuid(volumeName);
1025 }
1026 } else {
1027 return MediaStore.VOLUME_INTERNAL;
1028 }
1029 }
1030
1031 public static @Nullable String extractRelativePath(@Nullable String data) {
1032 if (data == null) return null;
1033 final Matcher matcher = PATTERN_RELATIVE_PATH.matcher(data);
1034 if (matcher.find()) {
1035 final int lastSlash = data.lastIndexOf('/');
1036 if (lastSlash == -1 || lastSlash < matcher.end()) {
1037 // This is a file in the top-level directory, so relative path is "/"
1038 // which is different than null, which means unknown path
1039 return "/";
1040 } else {
1041 return data.substring(matcher.end(), lastSlash + 1);
1042 }
1043 } else {
1044 return null;
1045 }
1046 }
1047
1048 /**
1049 * Returns relative path for the directory.
1050 */
1051 @VisibleForTesting
1052 public static @Nullable String extractRelativePathForDirectory(@Nullable String directoryPath) {
1053 if (directoryPath == null) return null;
Sahana Rao54dafa32020-06-22 08:42:37 +01001054
1055 if (directoryPath.equals("/storage/emulated") ||
1056 directoryPath.equals("/storage/emulated/")) {
1057 // This path is not reachable for MediaProvider.
1058 return null;
1059 }
1060
1061 // We are extracting relative path for the directory itself, we add "/" so that we can use
1062 // same PATTERN_RELATIVE_PATH to match relative path for directory. For example, relative
1063 // path of '/storage/<volume_name>' is null where as relative path for directory is "/", for
1064 // PATTERN_RELATIVE_PATH to match '/storage/<volume_name>', it should end with "/".
1065 if (!directoryPath.endsWith("/")) {
1066 // Relative path for directory should end with "/".
1067 directoryPath += "/";
1068 }
1069
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001070 final Matcher matcher = PATTERN_RELATIVE_PATH.matcher(directoryPath);
1071 if (matcher.find()) {
Sahana Rao54dafa32020-06-22 08:42:37 +01001072 if (matcher.end() == directoryPath.length()) {
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001073 // This is the top-level directory, so relative path is "/"
1074 return "/";
1075 }
Sahana Rao54dafa32020-06-22 08:42:37 +01001076 return directoryPath.substring(matcher.end());
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001077 }
1078 return null;
1079 }
1080
1081 public static @Nullable String extractPathOwnerPackageName(@Nullable String path) {
1082 if (path == null) return null;
1083 final Matcher m = PATTERN_OWNED_PATH.matcher(path);
1084 if (m.matches()) {
1085 return m.group(1);
1086 } else {
1087 return null;
1088 }
1089 }
1090
1091 /**
Ricky Waifeb9d9b2020-04-06 19:14:46 +01001092 * Returns true if relative path is Android/data or Android/obb path.
1093 */
1094 public static boolean isDataOrObbPath(String path) {
1095 if (path == null) return false;
1096 final Matcher m = PATTERN_DATA_OR_OBB_PATH.matcher(path);
1097 return m.matches();
1098 }
1099
1100 /**
Abhijeet Kaurb3ac2802020-10-07 16:42:42 +01001101 * Returns true if relative path is Android/obb path.
1102 */
1103 public static boolean isObbOrChildPath(String path) {
1104 if (path == null) return false;
1105 final Matcher m = PATTERN_OBB_OR_CHILD_PATH.matcher(path);
1106 return m.matches();
1107 }
1108
1109 /**
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001110 * Returns the name of the top level directory, or null if the path doesn't go through the
1111 * external storage directory.
1112 */
1113 @Nullable
1114 public static String extractTopLevelDir(String path) {
Sahana Rao920f6662020-04-30 15:13:42 +01001115 final String relativePath = extractRelativePath(path);
1116 if (relativePath == null) {
1117 return null;
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001118 }
Sahana Rao920f6662020-04-30 15:13:42 +01001119 final String[] relativePathSegments = relativePath.split("/");
1120 return relativePathSegments.length > 0 ? relativePathSegments[0] : null;
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001121 }
Jeff Sharkeyc55994b2019-12-20 19:43:59 -07001122
Sahana Raoe44080f2020-09-25 11:13:55 +01001123 public static boolean isDefaultDirectoryName(@Nullable String dirName) {
1124 for (String defaultDirName : DEFAULT_FOLDER_NAMES) {
1125 if (defaultDirName.equalsIgnoreCase(dirName)) {
1126 return true;
1127 }
1128 }
1129 return false;
1130 }
1131
Jeff Sharkey89149b62020-03-29 22:03:44 -06001132 /**
Jeff Sharkey05c3a032020-04-09 16:57:04 -06001133 * Compute the value of {@link MediaColumns#DATE_EXPIRES} based on other
1134 * columns being modified by this operation.
1135 */
1136 public static void computeDateExpires(@NonNull ContentValues values) {
1137 // External apps have no ability to change this field
1138 values.remove(MediaColumns.DATE_EXPIRES);
1139
1140 // Only define the field when this modification is actually adjusting
1141 // one of the flags that should influence the expiration
Jeff Sharkeya9473e92020-04-17 15:54:30 -06001142 final Object pending = values.get(MediaColumns.IS_PENDING);
Jeff Sharkey05c3a032020-04-09 16:57:04 -06001143 if (pending != null) {
Jeff Sharkeya9473e92020-04-17 15:54:30 -06001144 if (parseBoolean(pending, false)) {
Jeff Sharkey05c3a032020-04-09 16:57:04 -06001145 values.put(MediaColumns.DATE_EXPIRES,
1146 (System.currentTimeMillis() + DEFAULT_DURATION_PENDING) / 1000);
1147 } else {
1148 values.putNull(MediaColumns.DATE_EXPIRES);
1149 }
1150 }
Jeff Sharkeya9473e92020-04-17 15:54:30 -06001151 final Object trashed = values.get(MediaColumns.IS_TRASHED);
Jeff Sharkey05c3a032020-04-09 16:57:04 -06001152 if (trashed != null) {
Jeff Sharkeya9473e92020-04-17 15:54:30 -06001153 if (parseBoolean(trashed, false)) {
Jeff Sharkey05c3a032020-04-09 16:57:04 -06001154 values.put(MediaColumns.DATE_EXPIRES,
1155 (System.currentTimeMillis() + DEFAULT_DURATION_TRASHED) / 1000);
1156 } else {
1157 values.putNull(MediaColumns.DATE_EXPIRES);
1158 }
1159 }
1160 }
1161
1162 /**
Jeff Sharkey89149b62020-03-29 22:03:44 -06001163 * Compute several scattered {@link MediaColumns} values from
1164 * {@link MediaColumns#DATA}. This method performs no enforcement of
1165 * argument validity.
1166 */
Sahana Raoea587fc2020-06-03 15:56:23 +01001167 public static void computeValuesFromData(@NonNull ContentValues values, boolean isForFuse) {
Jeff Sharkeyc55994b2019-12-20 19:43:59 -07001168 // Worst case we have to assume no bucket details
Jeff Sharkey89149b62020-03-29 22:03:44 -06001169 values.remove(MediaColumns.VOLUME_NAME);
1170 values.remove(MediaColumns.RELATIVE_PATH);
Jeff Sharkey89149b62020-03-29 22:03:44 -06001171 values.remove(MediaColumns.IS_TRASHED);
1172 values.remove(MediaColumns.DATE_EXPIRES);
1173 values.remove(MediaColumns.DISPLAY_NAME);
1174 values.remove(MediaColumns.BUCKET_ID);
1175 values.remove(MediaColumns.BUCKET_DISPLAY_NAME);
Jeff Sharkeyc55994b2019-12-20 19:43:59 -07001176
1177 final String data = values.getAsString(MediaColumns.DATA);
1178 if (TextUtils.isEmpty(data)) return;
1179
1180 final File file = new File(data);
1181 final File fileLower = new File(data.toLowerCase(Locale.ROOT));
1182
Jeff Sharkey89149b62020-03-29 22:03:44 -06001183 values.put(MediaColumns.VOLUME_NAME, extractVolumeName(data));
1184 values.put(MediaColumns.RELATIVE_PATH, extractRelativePath(data));
Jeff Sharkey89149b62020-03-29 22:03:44 -06001185 final String displayName = extractDisplayName(data);
1186 final Matcher matcher = FileUtils.PATTERN_EXPIRES_FILE.matcher(displayName);
1187 if (matcher.matches()) {
1188 values.put(MediaColumns.IS_PENDING,
1189 matcher.group(1).equals(FileUtils.PREFIX_PENDING) ? 1 : 0);
1190 values.put(MediaColumns.IS_TRASHED,
1191 matcher.group(1).equals(FileUtils.PREFIX_TRASHED) ? 1 : 0);
1192 values.put(MediaColumns.DATE_EXPIRES, Long.parseLong(matcher.group(2)));
1193 values.put(MediaColumns.DISPLAY_NAME, matcher.group(3));
1194 } else {
Sahana Raoea587fc2020-06-03 15:56:23 +01001195 if (isForFuse) {
1196 // Allow Fuse thread to set IS_PENDING when using DATA column.
1197 // TODO(b/156867379) Unset IS_PENDING when Fuse thread doesn't explicitly specify
1198 // IS_PENDING. It can't be done now because we scan after create. Scan doesn't
1199 // explicitly specify the value of IS_PENDING.
1200 } else {
1201 values.put(MediaColumns.IS_PENDING, 0);
1202 }
Jeff Sharkey89149b62020-03-29 22:03:44 -06001203 values.put(MediaColumns.IS_TRASHED, 0);
1204 values.putNull(MediaColumns.DATE_EXPIRES);
1205 values.put(MediaColumns.DISPLAY_NAME, displayName);
1206 }
Jeff Sharkeyc55994b2019-12-20 19:43:59 -07001207
1208 // Buckets are the parent directory
1209 final String parent = fileLower.getParent();
1210 if (parent != null) {
Jeff Sharkey89149b62020-03-29 22:03:44 -06001211 values.put(MediaColumns.BUCKET_ID, parent.hashCode());
Jeff Sharkeyc55994b2019-12-20 19:43:59 -07001212 // The relative path for files in the top directory is "/"
Jeff Sharkey89149b62020-03-29 22:03:44 -06001213 if (!"/".equals(values.getAsString(MediaColumns.RELATIVE_PATH))) {
1214 values.put(MediaColumns.BUCKET_DISPLAY_NAME, file.getParentFile().getName());
Jeff Sharkeyc55994b2019-12-20 19:43:59 -07001215 }
1216 }
1217 }
Sahana Raof21671d2020-03-09 16:49:26 +00001218
Jeff Sharkey89149b62020-03-29 22:03:44 -06001219 /**
1220 * Compute {@link MediaColumns#DATA} from several scattered
1221 * {@link MediaColumns} values. This method performs no enforcement of
1222 * argument validity.
1223 */
1224 public static void computeDataFromValues(@NonNull ContentValues values,
Sahana Raoea587fc2020-06-03 15:56:23 +01001225 @NonNull File volumePath, boolean isForFuse) {
Jeff Sharkey89149b62020-03-29 22:03:44 -06001226 values.remove(MediaColumns.DATA);
1227
1228 final String displayName = values.getAsString(MediaColumns.DISPLAY_NAME);
1229 final String resolvedDisplayName;
Sahana Raoea587fc2020-06-03 15:56:23 +01001230 // Pending file path shouldn't be rewritten for files inserted via filepath.
1231 if (!isForFuse && getAsBoolean(values, MediaColumns.IS_PENDING, false)) {
Jeff Sharkey89149b62020-03-29 22:03:44 -06001232 final long dateExpires = getAsLong(values, MediaColumns.DATE_EXPIRES,
1233 (System.currentTimeMillis() + DEFAULT_DURATION_PENDING) / 1000);
Ivan Chiang9aa80952020-12-04 13:11:53 +08001234 final String combinedString = String.format(
Corina54fb8ba2020-11-27 13:38:09 +00001235 Locale.US, ".%s-%d-%s", FileUtils.PREFIX_PENDING, dateExpires, displayName);
Ivan Chiang9aa80952020-12-04 13:11:53 +08001236 // trim the file name to avoid ENAMETOOLONG error
1237 // after trim the file, if the user unpending the file,
1238 // the file name is not the original one
1239 resolvedDisplayName = trimFilename(combinedString, MAX_FILENAME_BYTES);
Jeff Sharkey89149b62020-03-29 22:03:44 -06001240 } else if (getAsBoolean(values, MediaColumns.IS_TRASHED, false)) {
1241 final long dateExpires = getAsLong(values, MediaColumns.DATE_EXPIRES,
1242 (System.currentTimeMillis() + DEFAULT_DURATION_TRASHED) / 1000);
Ivan Chiang9aa80952020-12-04 13:11:53 +08001243 final String combinedString = String.format(
Corina54fb8ba2020-11-27 13:38:09 +00001244 Locale.US, ".%s-%d-%s", FileUtils.PREFIX_TRASHED, dateExpires, displayName);
Ivan Chiang9aa80952020-12-04 13:11:53 +08001245 // trim the file name to avoid ENAMETOOLONG error
1246 // after trim the file, if the user untrashes the file,
1247 // the file name is not the original one
1248 resolvedDisplayName = trimFilename(combinedString, MAX_FILENAME_BYTES);
Jeff Sharkey89149b62020-03-29 22:03:44 -06001249 } else {
1250 resolvedDisplayName = displayName;
1251 }
1252
1253 final File filePath = buildPath(volumePath,
1254 values.getAsString(MediaColumns.RELATIVE_PATH), resolvedDisplayName);
1255 values.put(MediaColumns.DATA, filePath.getAbsolutePath());
1256 }
1257
Sahana Rao76b8b5b2020-04-17 20:21:59 +01001258 public static void sanitizeValues(@NonNull ContentValues values,
1259 boolean rewriteHiddenFileName) {
Jeff Sharkey89149b62020-03-29 22:03:44 -06001260 final String[] relativePath = values.getAsString(MediaColumns.RELATIVE_PATH).split("/");
1261 for (int i = 0; i < relativePath.length; i++) {
Sahana Rao76b8b5b2020-04-17 20:21:59 +01001262 relativePath[i] = sanitizeDisplayName(relativePath[i], rewriteHiddenFileName);
Jeff Sharkey89149b62020-03-29 22:03:44 -06001263 }
1264 values.put(MediaColumns.RELATIVE_PATH,
1265 String.join("/", relativePath) + "/");
Sahana Rao76b8b5b2020-04-17 20:21:59 +01001266
1267 final String displayName = values.getAsString(MediaColumns.DISPLAY_NAME);
Jeff Sharkey89149b62020-03-29 22:03:44 -06001268 values.put(MediaColumns.DISPLAY_NAME,
Sahana Rao76b8b5b2020-04-17 20:21:59 +01001269 sanitizeDisplayName(displayName, rewriteHiddenFileName));
Jeff Sharkey89149b62020-03-29 22:03:44 -06001270 }
1271
Sahana Raof21671d2020-03-09 16:49:26 +00001272 /** {@hide} **/
1273 @Nullable
1274 public static String getAbsoluteSanitizedPath(String path) {
1275 final String[] pathSegments = sanitizePath(path);
1276 if (pathSegments.length == 0) {
1277 return null;
1278 }
1279 return path = "/" + String.join("/",
1280 Arrays.copyOfRange(pathSegments, 1, pathSegments.length));
1281 }
1282
1283 /** {@hide} */
1284 public static @NonNull String[] sanitizePath(@Nullable String path) {
1285 if (path == null) {
1286 return new String[0];
1287 } else {
1288 final String[] segments = path.split("/");
1289 // If the path corresponds to the top level directory, then we return an empty path
1290 // which denotes the top level directory
1291 if (segments.length == 0) {
1292 return new String[] { "" };
1293 }
1294 for (int i = 0; i < segments.length; i++) {
1295 segments[i] = sanitizeDisplayName(segments[i]);
1296 }
1297 return segments;
1298 }
1299 }
1300
1301 /**
Sahana Rao76b8b5b2020-04-17 20:21:59 +01001302 * Sanitizes given name by mutating the file name to make it valid for a FAT filesystem.
Sahana Raof21671d2020-03-09 16:49:26 +00001303 * @hide
1304 */
1305 public static @Nullable String sanitizeDisplayName(@Nullable String name) {
Sahana Rao76b8b5b2020-04-17 20:21:59 +01001306 return sanitizeDisplayName(name, /*rewriteHiddenFileName*/ false);
1307 }
1308
1309 /**
1310 * Sanitizes given name by appending '_' to make it non-hidden and mutating the file name to
1311 * make it valid for a FAT filesystem.
1312 * @hide
1313 */
1314 public static @Nullable String sanitizeDisplayName(@Nullable String name,
1315 boolean rewriteHiddenFileName) {
Sahana Raof21671d2020-03-09 16:49:26 +00001316 if (name == null) {
1317 return null;
Sahana Rao76b8b5b2020-04-17 20:21:59 +01001318 } else if (rewriteHiddenFileName && name.startsWith(".")) {
Sahana Raof21671d2020-03-09 16:49:26 +00001319 // The resulting file must not be hidden.
Sahana Rao76b8b5b2020-04-17 20:21:59 +01001320 return "_" + name;
Sahana Raof21671d2020-03-09 16:49:26 +00001321 } else {
1322 return buildValidFatFilename(name);
1323 }
1324 }
shafika202fdd2020-05-11 20:19:27 +01001325
1326 /**
Martijn Coenen070bce12020-06-08 21:18:24 +02001327 * Test if this given directory should be considered hidden.
1328 */
1329 @VisibleForTesting
1330 public static boolean isDirectoryHidden(@NonNull File dir) {
1331 final String name = dir.getName();
1332 if (name.startsWith(".")) {
1333 return true;
1334 }
1335
1336 final File nomedia = new File(dir, ".nomedia");
Sahana Raoe44080f2020-09-25 11:13:55 +01001337
Martijn Coenen070bce12020-06-08 21:18:24 +02001338 // check for .nomedia presence
Sahana Raoe44080f2020-09-25 11:13:55 +01001339 if (!nomedia.exists()) {
1340 return false;
Martijn Coenen070bce12020-06-08 21:18:24 +02001341 }
Sahana Raoe44080f2020-09-25 11:13:55 +01001342
1343 // Handle top-level default directories. These directories should always be visible,
1344 // regardless of .nomedia presence.
1345 final String[] relativePath = sanitizePath(extractRelativePath(dir.getAbsolutePath()));
1346 final boolean isTopLevelDir =
1347 relativePath.length == 1 && TextUtils.isEmpty(relativePath[0]);
1348 if (isTopLevelDir && isDefaultDirectoryName(name)) {
1349 nomedia.delete();
1350 return false;
1351 }
1352
1353 // DCIM/Camera should always be visible regardless of .nomedia presence.
1354 if (CAMERA_RELATIVE_PATH.equalsIgnoreCase(
1355 extractRelativePathForDirectory(dir.getAbsolutePath()))) {
1356 nomedia.delete();
1357 return false;
1358 }
1359
1360 // .nomedia is present which makes this directory as hidden directory
1361 Logging.logPersistent("Observed non-standard " + nomedia);
1362 return true;
Martijn Coenen070bce12020-06-08 21:18:24 +02001363 }
1364
1365 /**
1366 * Test if this given file should be considered hidden.
1367 */
1368 @VisibleForTesting
1369 public static boolean isFileHidden(@NonNull File file) {
1370 final String name = file.getName();
1371
1372 // Handle well-known file names that are pending or trashed; they
1373 // normally appear hidden, but we give them special treatment
1374 if (PATTERN_EXPIRES_FILE.matcher(name).matches()) {
1375 return false;
1376 }
1377
1378 // Otherwise fall back to file name
1379 if (name.startsWith(".")) {
1380 return true;
1381 }
1382 return false;
1383 }
1384
1385 /**
shafika202fdd2020-05-11 20:19:27 +01001386 * Clears all app's external cache directories, i.e. for each app we delete
1387 * /sdcard/Android/data/app/cache/* but we keep the directory itself.
1388 *
1389 * @return 0 in case of success, or {@link OsConstants#EIO} if any error occurs.
1390 *
1391 * <p>This method doesn't perform any checks, so make sure that the calling package is allowed
1392 * to clear cache directories first.
1393 *
1394 * <p>If this method returned {@link OsConstants#EIO}, then we can't guarantee whether all, none
1395 * or part of the directories were cleared.
1396 */
1397 public static int clearAppCacheDirectories() {
1398 int status = 0;
1399 Log.i(TAG, "Clearing cache for all apps");
1400 final File rootDataDir = buildPath(Environment.getExternalStorageDirectory(),
1401 "Android", "data");
1402 for (File appDataDir : rootDataDir.listFiles()) {
1403 try {
1404 final File appCacheDir = new File(appDataDir, "cache");
1405 if (appCacheDir.isDirectory()) {
1406 FileUtils.deleteContents(appCacheDir);
1407 }
1408 } catch (Exception e) {
1409 // We want to avoid crashing MediaProvider at all costs, so we handle all "generic"
1410 // exceptions here, and just report to the caller that an IO exception has occurred.
1411 // We still try to clear the rest of the directories.
1412 Log.e(TAG, "Couldn't delete all app cache dirs!", e);
1413 status = OsConstants.EIO;
1414 }
1415 }
1416 return status;
1417 }
Zim7e730f32020-09-30 11:38:50 +01001418
1419 /**
1420 * @return {@code true} if {@code dir} is dirty and should be scanned, {@code false} otherwise.
1421 */
1422 public static boolean isDirectoryDirty(File dir) {
1423 File nomedia = new File(dir, ".nomedia");
1424 if (nomedia.exists()) {
1425 try {
1426 Optional<String> expectedPath = readString(nomedia);
1427 // Returns true If .nomedia file is empty or content doesn't match |dir|
1428 // Returns false otherwise
1429 return !expectedPath.isPresent()
1430 || !expectedPath.get().equals(dir.getPath());
1431 } catch (IOException e) {
1432 Log.w(TAG, "Failed to read directory dirty" + dir);
1433 }
1434 }
1435 return true;
1436 }
1437
1438 /**
1439 * {@code isDirty} == {@code true} will force {@code dir} scanning even if it's hidden
1440 * {@code isDirty} == {@code false} will skip {@code dir} scanning on next scan.
1441 */
1442 public static void setDirectoryDirty(File dir, boolean isDirty) {
1443 File nomedia = new File(dir, ".nomedia");
1444 if (nomedia.exists()) {
1445 try {
1446 writeString(nomedia, isDirty ? Optional.of("") : Optional.of(dir.getPath()));
1447 } catch (IOException e) {
1448 Log.w(TAG, "Failed to change directory dirty: " + dir + ". isDirty: " + isDirty);
1449 }
1450 }
1451 }
1452
1453 /**
1454 * @return the folder containing the top-most .nomedia in {@code file} hierarchy.
1455 * E.g input as /sdcard/foo/bar/ will return /sdcard/foo
1456 * even if foo and bar contain .nomedia files.
1457 *
1458 * Returns {@code null} if there's no .nomedia in hierarchy
1459 */
1460 public static File getTopLevelNoMedia(@NonNull File file) {
Sahana Rao7abc52c2020-12-07 16:02:03 +00001461 File topNoMediaDir = null;
Zim7e730f32020-09-30 11:38:50 +01001462
1463 File parent = file;
1464 while (parent != null) {
1465 File nomedia = new File(parent, ".nomedia");
1466 if (nomedia.exists()) {
Sahana Rao7abc52c2020-12-07 16:02:03 +00001467 topNoMediaDir = parent;
Zim7e730f32020-09-30 11:38:50 +01001468 }
1469 parent = parent.getParentFile();
1470 }
1471
Sahana Rao7abc52c2020-12-07 16:02:03 +00001472 return topNoMediaDir;
Zim7e730f32020-09-30 11:38:50 +01001473 }
Jeff Sharkey7ea24f22019-08-22 10:14:18 -06001474}