blob: e0e3dca1a9ca717adcb25d0069607c518f68e912 [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 Coenenf5cd0992020-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;
Zim3bec66b2020-09-30 11:38:50 +010060import android.util.ArraySet;
Jeff Sharkey7ea24f22019-08-22 10:14:18 -060061import android.util.Log;
62import android.webkit.MimeTypeMap;
63
64import androidx.annotation.NonNull;
65import androidx.annotation.Nullable;
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -070066import androidx.annotation.VisibleForTesting;
Jeff Sharkey7ea24f22019-08-22 10:14:18 -060067
68import java.io.File;
Jeff Sharkey9a497642020-04-23 13:15:10 -060069import java.io.FileDescriptor;
Jeff Sharkey7ea24f22019-08-22 10:14:18 -060070import java.io.FileNotFoundException;
71import java.io.IOException;
72import java.io.InputStream;
73import java.io.OutputStream;
74import java.nio.charset.StandardCharsets;
Jeff Sharkeybb4e5e62020-02-09 17:14:08 -070075import java.nio.file.FileVisitResult;
76import java.nio.file.FileVisitor;
77import java.nio.file.Files;
78import java.nio.file.NoSuchFileException;
79import java.nio.file.Path;
Jeff Sharkeybb4e5e62020-02-09 17:14:08 -070080import java.nio.file.attribute.BasicFileAttributes;
Jeff Sharkeyc5c39142019-12-15 22:46:03 -070081import java.util.ArrayList;
Jeff Sharkey5278ead2020-01-07 16:40:18 -070082import java.util.Arrays;
Jeff Sharkey7ea24f22019-08-22 10:14:18 -060083import java.util.Collection;
Jeff Sharkey5278ead2020-01-07 16:40:18 -070084import java.util.Comparator;
Jeff Sharkey06b38ca2019-12-17 15:51:12 -070085import java.util.Iterator;
Jeff Sharkey470b97e2019-10-15 16:32:04 -060086import java.util.Locale;
Jeff Sharkey7ea24f22019-08-22 10:14:18 -060087import java.util.Objects;
Jeff Sharkeybb4e5e62020-02-09 17:14:08 -070088import java.util.Optional;
Zim3bec66b2020-09-30 11:38:50 +010089import java.util.Set;
Jeff Sharkeybb4e5e62020-02-09 17:14:08 -070090import 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 {
Jeff Sharkey9a497642020-04-23 13:15:10 -060095 /**
96 * Drop-in replacement for {@link ParcelFileDescriptor#open(File, int)}
97 * which adds security features like {@link OsConstants#O_CLOEXEC} and
98 * {@link OsConstants#O_NOFOLLOW}.
99 */
100 public static @NonNull ParcelFileDescriptor openSafely(@NonNull File file, int pfdFlags)
101 throws FileNotFoundException {
102 final int posixFlags = translateModePfdToPosix(pfdFlags) | O_CLOEXEC | O_NOFOLLOW;
103 try {
104 final FileDescriptor fd = Os.open(file.getAbsolutePath(), posixFlags,
105 S_IRWXU | S_IRWXG);
106 try {
107 return ParcelFileDescriptor.dup(fd);
108 } finally {
109 closeQuietly(fd);
110 }
111 } catch (IOException | ErrnoException e) {
112 throw new FileNotFoundException(e.getMessage());
113 }
114 }
115
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600116 public static void closeQuietly(@Nullable AutoCloseable closeable) {
117 android.os.FileUtils.closeQuietly(closeable);
118 }
119
Jeff Sharkey9a497642020-04-23 13:15:10 -0600120 public static void closeQuietly(@Nullable FileDescriptor fd) {
121 if (fd == null) return;
122 try {
123 Os.close(fd);
124 } catch (ErrnoException ignored) {
125 }
126 }
127
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600128 public static long copy(@NonNull InputStream in, @NonNull OutputStream out) throws IOException {
129 return android.os.FileUtils.copy(in, out);
130 }
131
132 public static File buildPath(File base, String... segments) {
133 File cur = base;
134 for (String segment : segments) {
135 if (cur == null) {
136 cur = new File(segment);
137 } else {
138 cur = new File(cur, segment);
139 }
140 }
141 return cur;
142 }
143
144 /**
Jeff Sharkey5278ead2020-01-07 16:40:18 -0700145 * Delete older files in a directory until only those matching the given
146 * constraints remain.
147 *
148 * @param minCount Always keep at least this many files.
149 * @param minAgeMs Always keep files younger than this age, in milliseconds.
150 * @return if any files were deleted.
151 */
152 public static boolean deleteOlderFiles(File dir, int minCount, long minAgeMs) {
153 if (minCount < 0 || minAgeMs < 0) {
154 throw new IllegalArgumentException("Constraints must be positive or 0");
155 }
156
157 final File[] files = dir.listFiles();
158 if (files == null) return false;
159
160 // Sort with newest files first
161 Arrays.sort(files, new Comparator<File>() {
162 @Override
163 public int compare(File lhs, File rhs) {
164 return Long.compare(rhs.lastModified(), lhs.lastModified());
165 }
166 });
167
168 // Keep at least minCount files
169 boolean deleted = false;
170 for (int i = minCount; i < files.length; i++) {
171 final File file = files[i];
172
173 // Keep files newer than minAgeMs
174 final long age = System.currentTimeMillis() - file.lastModified();
175 if (age > minAgeMs) {
176 if (file.delete()) {
177 Log.d(TAG, "Deleted old file " + file);
178 deleted = true;
179 }
180 }
181 }
182 return deleted;
183 }
184
185 /**
Jeff Sharkeyf06febd2020-04-07 13:03:30 -0600186 * Shamelessly borrowed from {@code android.os.FileUtils}.
187 */
188 public static int translateModeStringToPosix(String mode) {
189 // Sanity check for invalid chars
190 for (int i = 0; i < mode.length(); i++) {
191 switch (mode.charAt(i)) {
192 case 'r':
193 case 'w':
194 case 't':
195 case 'a':
196 break;
197 default:
198 throw new IllegalArgumentException("Bad mode: " + mode);
199 }
200 }
201
202 int res = 0;
203 if (mode.startsWith("rw")) {
204 res = O_RDWR | O_CREAT;
205 } else if (mode.startsWith("w")) {
206 res = O_WRONLY | O_CREAT;
207 } else if (mode.startsWith("r")) {
208 res = O_RDONLY;
209 } else {
210 throw new IllegalArgumentException("Bad mode: " + mode);
211 }
212 if (mode.indexOf('t') != -1) {
213 res |= O_TRUNC;
214 }
215 if (mode.indexOf('a') != -1) {
216 res |= O_APPEND;
217 }
218 return res;
219 }
220
221 /**
222 * Shamelessly borrowed from {@code android.os.FileUtils}.
223 */
224 public static String translateModePosixToString(int mode) {
225 String res = "";
226 if ((mode & O_ACCMODE) == O_RDWR) {
227 res = "rw";
228 } else if ((mode & O_ACCMODE) == O_WRONLY) {
229 res = "w";
230 } else if ((mode & O_ACCMODE) == O_RDONLY) {
231 res = "r";
232 } else {
233 throw new IllegalArgumentException("Bad mode: " + mode);
234 }
235 if ((mode & O_TRUNC) == O_TRUNC) {
236 res += "t";
237 }
238 if ((mode & O_APPEND) == O_APPEND) {
239 res += "a";
240 }
241 return res;
242 }
243
244 /**
245 * Shamelessly borrowed from {@code android.os.FileUtils}.
246 */
247 public static int translateModePosixToPfd(int mode) {
248 int res = 0;
249 if ((mode & O_ACCMODE) == O_RDWR) {
250 res = MODE_READ_WRITE;
251 } else if ((mode & O_ACCMODE) == O_WRONLY) {
252 res = MODE_WRITE_ONLY;
253 } else if ((mode & O_ACCMODE) == O_RDONLY) {
254 res = MODE_READ_ONLY;
255 } else {
256 throw new IllegalArgumentException("Bad mode: " + mode);
257 }
258 if ((mode & O_CREAT) == O_CREAT) {
259 res |= MODE_CREATE;
260 }
261 if ((mode & O_TRUNC) == O_TRUNC) {
262 res |= MODE_TRUNCATE;
263 }
264 if ((mode & O_APPEND) == O_APPEND) {
265 res |= MODE_APPEND;
266 }
267 return res;
268 }
269
270 /**
271 * Shamelessly borrowed from {@code android.os.FileUtils}.
272 */
273 public static int translateModePfdToPosix(int mode) {
274 int res = 0;
275 if ((mode & MODE_READ_WRITE) == MODE_READ_WRITE) {
276 res = O_RDWR;
277 } else if ((mode & MODE_WRITE_ONLY) == MODE_WRITE_ONLY) {
278 res = O_WRONLY;
279 } else if ((mode & MODE_READ_ONLY) == MODE_READ_ONLY) {
280 res = O_RDONLY;
281 } else {
282 throw new IllegalArgumentException("Bad mode: " + mode);
283 }
284 if ((mode & MODE_CREATE) == MODE_CREATE) {
285 res |= O_CREAT;
286 }
287 if ((mode & MODE_TRUNCATE) == MODE_TRUNCATE) {
288 res |= O_TRUNC;
289 }
290 if ((mode & MODE_APPEND) == MODE_APPEND) {
291 res |= O_APPEND;
292 }
293 return res;
294 }
295
296 /**
297 * Shamelessly borrowed from {@code android.os.FileUtils}.
298 */
299 public static int translateModeAccessToPosix(int mode) {
300 if (mode == F_OK) {
301 // There's not an exact mapping, so we attempt a read-only open to
302 // determine if a file exists
303 return O_RDONLY;
304 } else if ((mode & (R_OK | W_OK)) == (R_OK | W_OK)) {
305 return O_RDWR;
306 } else if ((mode & R_OK) == R_OK) {
307 return O_RDONLY;
308 } else if ((mode & W_OK) == W_OK) {
309 return O_WRONLY;
310 } else {
311 throw new IllegalArgumentException("Bad mode: " + mode);
312 }
313 }
314
315 /**
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600316 * Test if a file lives under the given directory, either as a direct child
317 * or a distant grandchild.
318 * <p>
319 * Both files <em>must</em> have been resolved using
320 * {@link File#getCanonicalFile()} to avoid symlink or path traversal
321 * attacks.
322 *
323 * @hide
324 */
325 public static boolean contains(File[] dirs, File file) {
326 for (File dir : dirs) {
327 if (contains(dir, file)) {
328 return true;
329 }
330 }
331 return false;
332 }
333
334 /** {@hide} */
335 public static boolean contains(Collection<File> dirs, File file) {
336 for (File dir : dirs) {
337 if (contains(dir, file)) {
338 return true;
339 }
340 }
341 return false;
342 }
343
344 /**
345 * Test if a file lives under the given directory, either as a direct child
346 * or a distant grandchild.
347 * <p>
348 * Both files <em>must</em> have been resolved using
349 * {@link File#getCanonicalFile()} to avoid symlink or path traversal
350 * attacks.
351 *
352 * @hide
353 */
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600354 public static boolean contains(File dir, File file) {
355 if (dir == null || file == null) return false;
356 return contains(dir.getAbsolutePath(), file.getAbsolutePath());
357 }
358
359 /**
360 * Test if a file lives under the given directory, either as a direct child
361 * or a distant grandchild.
362 * <p>
363 * Both files <em>must</em> have been resolved using
364 * {@link File#getCanonicalFile()} to avoid symlink or path traversal
365 * attacks.
366 *
367 * @hide
368 */
369 public static boolean contains(String dirPath, String filePath) {
370 if (dirPath.equals(filePath)) {
371 return true;
372 }
373 if (!dirPath.endsWith("/")) {
374 dirPath += "/";
375 }
376 return filePath.startsWith(dirPath);
377 }
378
Jeff Sharkeybb4e5e62020-02-09 17:14:08 -0700379 /**
380 * Write {@link String} to the given {@link File}. Deletes any existing file
381 * when the argument is {@link Optional#empty()}.
382 */
383 public static void writeString(@NonNull File file, @NonNull Optional<String> value)
384 throws IOException {
385 if (value.isPresent()) {
386 Files.write(file.toPath(), value.get().getBytes(StandardCharsets.UTF_8));
387 } else {
388 file.delete();
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600389 }
Jeff Sharkeybb4e5e62020-02-09 17:14:08 -0700390 }
391
392 /**
393 * Read given {@link File} as a single {@link String}. Returns
394 * {@link Optional#empty()} when the file doesn't exist.
395 */
396 public static @NonNull Optional<String> readString(@NonNull File file) throws IOException {
397 try {
398 final String value = new String(Files.readAllBytes(file.toPath()),
399 StandardCharsets.UTF_8);
400 return Optional.of(value);
401 } catch (NoSuchFileException e) {
402 return Optional.empty();
403 }
404 }
405
406 /**
407 * Recursively walk the contents of the given {@link Path}, invoking the
408 * given {@link Consumer} for every file and directory encountered. This is
409 * typically used for recursively deleting a directory tree.
410 * <p>
411 * Gracefully attempts to process as much as possible in the face of any
412 * failures.
413 */
414 public static void walkFileTreeContents(@NonNull Path path, @NonNull Consumer<Path> operation) {
415 try {
416 Files.walkFileTree(path, new FileVisitor<Path>() {
417 @Override
418 public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
419 return FileVisitResult.CONTINUE;
420 }
421
422 @Override
423 public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
424 if (!Objects.equals(path, file)) {
425 operation.accept(file);
426 }
427 return FileVisitResult.CONTINUE;
428 }
429
430 @Override
431 public FileVisitResult visitFileFailed(Path file, IOException e) {
432 Log.w(TAG, "Failed to visit " + file, e);
433 return FileVisitResult.CONTINUE;
434 }
435
436 @Override
437 public FileVisitResult postVisitDirectory(Path dir, IOException e) {
438 if (!Objects.equals(path, dir)) {
439 operation.accept(dir);
440 }
441 return FileVisitResult.CONTINUE;
442 }
443 });
444 } catch (IOException e) {
445 Log.w(TAG, "Failed to walk " + path, e);
446 }
447 }
448
449 /**
450 * Recursively delete all contents inside the given directory. Gracefully
451 * attempts to delete as much as possible in the face of any failures.
452 *
Jeff Sharkey89149b62020-03-29 22:03:44 -0600453 * @deprecated if you're calling this from inside {@code MediaProvider}, you
Jeff Sharkeybb4e5e62020-02-09 17:14:08 -0700454 * likely want to call {@link #forEach} with a separate
455 * invocation to invalidate FUSE entries.
456 */
457 @Deprecated
458 public static void deleteContents(@NonNull File dir) {
459 walkFileTreeContents(dir.toPath(), (path) -> {
460 path.toFile().delete();
461 });
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600462 }
463
464 private static boolean isValidFatFilenameChar(char c) {
465 if ((0x00 <= c && c <= 0x1f)) {
466 return false;
467 }
468 switch (c) {
469 case '"':
470 case '*':
471 case '/':
472 case ':':
473 case '<':
474 case '>':
475 case '?':
476 case '\\':
477 case '|':
478 case 0x7F:
479 return false;
480 default:
481 return true;
482 }
483 }
484
485 /**
486 * Check if given filename is valid for a FAT filesystem.
487 *
488 * @hide
489 */
490 public static boolean isValidFatFilename(String name) {
491 return (name != null) && name.equals(buildValidFatFilename(name));
492 }
493
494 /**
495 * Mutate the given filename to make it valid for a FAT filesystem,
496 * replacing any invalid characters with "_".
497 *
498 * @hide
499 */
500 public static String buildValidFatFilename(String name) {
501 if (TextUtils.isEmpty(name) || ".".equals(name) || "..".equals(name)) {
502 return "(invalid)";
503 }
504 final StringBuilder res = new StringBuilder(name.length());
505 for (int i = 0; i < name.length(); i++) {
506 final char c = name.charAt(i);
507 if (isValidFatFilenameChar(c)) {
508 res.append(c);
509 } else {
510 res.append('_');
511 }
512 }
513 // Even though vfat allows 255 UCS-2 chars, we might eventually write to
514 // ext4 through a FUSE layer, so use that limit.
515 trimFilename(res, 255);
516 return res.toString();
517 }
518
519 /** {@hide} */
520 // @VisibleForTesting
521 public static String trimFilename(String str, int maxBytes) {
522 final StringBuilder res = new StringBuilder(str);
523 trimFilename(res, maxBytes);
524 return res.toString();
525 }
526
527 /** {@hide} */
528 private static void trimFilename(StringBuilder res, int maxBytes) {
529 byte[] raw = res.toString().getBytes(StandardCharsets.UTF_8);
530 if (raw.length > maxBytes) {
531 maxBytes -= 3;
532 while (raw.length > maxBytes) {
533 res.deleteCharAt(res.length() / 2);
534 raw = res.toString().getBytes(StandardCharsets.UTF_8);
535 }
536 res.insert(res.length() / 2, "...");
537 }
538 }
539
540 /** {@hide} */
541 private static File buildUniqueFileWithExtension(File parent, String name, String ext)
542 throws FileNotFoundException {
Jeff Sharkey06b38ca2019-12-17 15:51:12 -0700543 final Iterator<String> names = buildUniqueNameIterator(parent, name);
544 while (names.hasNext()) {
545 File file = buildFile(parent, names.next(), ext);
546 if (!file.exists()) {
547 return file;
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600548 }
Jeff Sharkey06b38ca2019-12-17 15:51:12 -0700549 }
550 throw new FileNotFoundException("Failed to create unique file");
551 }
552
553 private static final Pattern PATTERN_DCF_STRICT = Pattern
554 .compile("([A-Z0-9_]{4})([0-9]{4})");
555 private static final Pattern PATTERN_DCF_RELAXED = Pattern
556 .compile("((?:IMG|MVIMG|VID)_[0-9]{8}_[0-9]{6})(?:~([0-9]+))?");
557
558 private static boolean isDcim(@NonNull File dir) {
559 while (dir != null) {
560 if (Objects.equals("DCIM", dir.getName())) {
561 return true;
562 }
563 dir = dir.getParentFile();
564 }
565 return false;
566 }
567
568 private static @NonNull Iterator<String> buildUniqueNameIterator(@NonNull File parent,
569 @NonNull String name) {
570 if (isDcim(parent)) {
571 final Matcher dcfStrict = PATTERN_DCF_STRICT.matcher(name);
572 if (dcfStrict.matches()) {
573 // Generate names like "IMG_1001"
574 final String prefix = dcfStrict.group(1);
575 return new Iterator<String>() {
576 int i = Integer.parseInt(dcfStrict.group(2));
577 @Override
578 public String next() {
Corina54fb8ba2020-11-27 13:38:09 +0000579 final String res = String.format(Locale.US, "%s%04d", prefix, i);
Jeff Sharkey06b38ca2019-12-17 15:51:12 -0700580 i++;
581 return res;
582 }
583 @Override
584 public boolean hasNext() {
585 return i <= 9999;
586 }
587 };
588 }
589
590 final Matcher dcfRelaxed = PATTERN_DCF_RELAXED.matcher(name);
591 if (dcfRelaxed.matches()) {
592 // Generate names like "IMG_20190102_030405~2"
593 final String prefix = dcfRelaxed.group(1);
594 return new Iterator<String>() {
Corina54fb8ba2020-11-27 13:38:09 +0000595 int i = TextUtils.isEmpty(dcfRelaxed.group(2))
596 ? 1
Jeff Sharkey06b38ca2019-12-17 15:51:12 -0700597 : Integer.parseInt(dcfRelaxed.group(2));
598 @Override
599 public String next() {
Corina54fb8ba2020-11-27 13:38:09 +0000600 final String res = (i == 1)
601 ? prefix
602 : String.format(Locale.US, "%s~%d", prefix, i);
Jeff Sharkey06b38ca2019-12-17 15:51:12 -0700603 i++;
604 return res;
605 }
606 @Override
607 public boolean hasNext() {
608 return i <= 99;
609 }
610 };
611 }
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600612 }
613
Jeff Sharkey06b38ca2019-12-17 15:51:12 -0700614 // Generate names like "foo (2)"
615 return new Iterator<String>() {
616 int i = 0;
617 @Override
618 public String next() {
619 final String res = (i == 0) ? name : name + " (" + i + ")";
620 i++;
621 return res;
622 }
623 @Override
624 public boolean hasNext() {
625 return i < 32;
626 }
627 };
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600628 }
629
630 /**
631 * Generates a unique file name under the given parent directory. If the display name doesn't
632 * have an extension that matches the requested MIME type, the default extension for that MIME
633 * type is appended. If a file already exists, the name is appended with a numerical value to
634 * make it unique.
635 *
636 * For example, the display name 'example' with 'text/plain' MIME might produce
637 * 'example.txt' or 'example (1).txt', etc.
638 *
639 * @throws FileNotFoundException
640 * @hide
641 */
642 public static File buildUniqueFile(File parent, String mimeType, String displayName)
643 throws FileNotFoundException {
644 final String[] parts = splitFileName(mimeType, displayName);
645 return buildUniqueFileWithExtension(parent, parts[0], parts[1]);
646 }
647
648 /** {@hide} */
649 public static File buildNonUniqueFile(File parent, String mimeType, String displayName) {
650 final String[] parts = splitFileName(mimeType, displayName);
651 return buildFile(parent, parts[0], parts[1]);
652 }
653
654 /**
655 * Generates a unique file name under the given parent directory, keeping
656 * any extension intact.
657 *
658 * @hide
659 */
660 public static File buildUniqueFile(File parent, String displayName)
661 throws FileNotFoundException {
662 final String name;
663 final String ext;
664
665 // Extract requested extension from display name
666 final int lastDot = displayName.lastIndexOf('.');
667 if (lastDot >= 0) {
668 name = displayName.substring(0, lastDot);
669 ext = displayName.substring(lastDot + 1);
670 } else {
671 name = displayName;
672 ext = null;
673 }
674
675 return buildUniqueFileWithExtension(parent, name, ext);
676 }
677
678 /**
679 * Splits file name into base name and extension.
680 * If the display name doesn't have an extension that matches the requested MIME type, the
681 * extension is regarded as a part of filename and default extension for that MIME type is
682 * appended.
683 *
684 * @hide
685 */
686 public static String[] splitFileName(String mimeType, String displayName) {
687 String name;
688 String ext;
689
690 {
691 String mimeTypeFromExt;
692
693 // Extract requested extension from display name
694 final int lastDot = displayName.lastIndexOf('.');
Sahana Raof3c8a162020-05-15 21:58:36 +0100695 if (lastDot > 0) {
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600696 name = displayName.substring(0, lastDot);
697 ext = displayName.substring(lastDot + 1);
698 mimeTypeFromExt = MimeTypeMap.getSingleton().getMimeTypeFromExtension(
Jeff Sharkey470b97e2019-10-15 16:32:04 -0600699 ext.toLowerCase(Locale.ROOT));
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600700 } else {
701 name = displayName;
702 ext = null;
703 mimeTypeFromExt = null;
704 }
705
706 if (mimeTypeFromExt == null) {
707 mimeTypeFromExt = ClipDescription.MIMETYPE_UNKNOWN;
708 }
709
710 final String extFromMimeType;
Jeff Sharkeyc4a5f812020-05-03 21:07:14 -0600711 if (ClipDescription.MIMETYPE_UNKNOWN.equalsIgnoreCase(mimeType)) {
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600712 extFromMimeType = null;
713 } else {
714 extFromMimeType = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType);
715 }
716
Jeff Sharkeyc4a5f812020-05-03 21:07:14 -0600717 if (MimeUtils.equalIgnoreCase(mimeType, mimeTypeFromExt)
718 || MimeUtils.equalIgnoreCase(ext, extFromMimeType)) {
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600719 // Extension maps back to requested MIME type; allow it
720 } else {
721 // No match; insist that create file matches requested MIME
722 name = displayName;
723 ext = extFromMimeType;
724 }
725 }
726
727 if (ext == null) {
728 ext = "";
729 }
730
731 return new String[] { name, ext };
732 }
733
734 /** {@hide} */
735 private static File buildFile(File parent, String name, String ext) {
736 if (TextUtils.isEmpty(ext)) {
737 return new File(parent, name);
738 } else {
739 return new File(parent, name + "." + ext);
740 }
741 }
Jeff Sharkeye152d5762019-10-11 17:14:51 -0600742
743 public static @Nullable String extractDisplayName(@Nullable String data) {
744 if (data == null) return null;
Jeff Sharkeye76c4262019-12-06 14:46:00 -0700745 if (data.indexOf('/') == -1) {
746 return data;
747 }
Jeff Sharkeye152d5762019-10-11 17:14:51 -0600748 if (data.endsWith("/")) {
749 data = data.substring(0, data.length() - 1);
750 }
751 return data.substring(data.lastIndexOf('/') + 1);
752 }
753
754 public static @Nullable String extractFileName(@Nullable String data) {
755 if (data == null) return null;
756 data = extractDisplayName(data);
757
758 final int lastDot = data.lastIndexOf('.');
759 if (lastDot == -1) {
760 return data;
761 } else {
762 return data.substring(0, lastDot);
763 }
764 }
765
766 public static @Nullable String extractFileExtension(@Nullable String data) {
767 if (data == null) return null;
768 data = extractDisplayName(data);
769
770 final int lastDot = data.lastIndexOf('.');
771 if (lastDot == -1) {
772 return null;
773 } else {
774 return data.substring(lastDot + 1);
775 }
776 }
Jeff Sharkeyc5c39142019-12-15 22:46:03 -0700777
778 /**
Jeff Sharkeyc55994b2019-12-20 19:43:59 -0700779 * Return list of paths that should be scanned with
780 * {@link com.android.providers.media.scan.MediaScanner} for the given
781 * volume name.
Jeff Sharkeyc5c39142019-12-15 22:46:03 -0700782 */
783 public static @NonNull Collection<File> getVolumeScanPaths(@NonNull Context context,
784 @NonNull String volumeName) throws FileNotFoundException {
785 final ArrayList<File> res = new ArrayList<>();
786 switch (volumeName) {
787 case MediaStore.VOLUME_INTERNAL: {
788 res.addAll(Environment.getInternalMediaDirectories());
789 break;
790 }
791 case MediaStore.VOLUME_EXTERNAL: {
792 for (String resolvedVolumeName : MediaStore.getExternalVolumeNames(context)) {
793 res.add(getVolumePath(context, resolvedVolumeName));
794 }
795 break;
796 }
797 default: {
798 res.add(getVolumePath(context, volumeName));
799 }
800 }
801 return res;
802 }
803
804 /**
805 * Return path where the given volume name is mounted.
806 */
807 public static @NonNull File getVolumePath(@NonNull Context context,
808 @NonNull String volumeName) throws FileNotFoundException {
809 switch (volumeName) {
810 case MediaStore.VOLUME_INTERNAL:
811 case MediaStore.VOLUME_EXTERNAL:
812 throw new FileNotFoundException(volumeName + " has no associated path");
813 }
814
815 final Uri uri = MediaStore.Files.getContentUri(volumeName);
Zimdde18a62020-09-10 10:29:47 +0100816 File path = null;
817
818 try {
819 path = context.getSystemService(StorageManager.class).getStorageVolume(uri)
820 .getDirectory();
821 } catch (IllegalStateException e) {
822 Log.w("Ignoring volume not found exception", e);
823 }
824
Jeff Sharkeyf95b06f2020-06-02 11:10:35 -0600825 if (path != null) {
826 return path;
827 } else {
828 throw new FileNotFoundException(volumeName + " has no associated path");
829 }
Jeff Sharkeyc5c39142019-12-15 22:46:03 -0700830 }
831
832 /**
shafik536982a2020-05-14 17:54:05 +0100833 * Returns the content URI for the volume that contains the given path.
834 *
835 * <p>{@link MediaStore.Files#getContentUriForPath(String)} can't detect public volumes and can
836 * only return the URI for the primary external storage, that's why this utility should be used
837 * instead.
838 */
839 public static @NonNull Uri getContentUriForPath(@NonNull String path) {
840 Objects.requireNonNull(path);
841 return MediaStore.Files.getContentUri(extractVolumeName(path));
842 }
843
844 /**
Jeff Sharkeyc5c39142019-12-15 22:46:03 -0700845 * Return volume name which hosts the given path.
846 */
Martijn Coenenf5cd0992020-11-02 11:46:32 +0100847 public static @NonNull String getVolumeName(@NonNull Context context, @NonNull File path)
848 throws FileNotFoundException {
Jeff Sharkeyc5c39142019-12-15 22:46:03 -0700849 if (contains(Environment.getStorageDirectory(), path)) {
Martijn Coenenf5cd0992020-11-02 11:46:32 +0100850 StorageVolume volume = context.getSystemService(StorageManager.class)
851 .getStorageVolume(path);
852 if (volume == null) {
853 throw new FileNotFoundException("Can't find volume for " + path.getPath());
854 }
855 return volume.getMediaStoreVolumeName();
Jeff Sharkeyc5c39142019-12-15 22:46:03 -0700856 } else {
857 return MediaStore.VOLUME_INTERNAL;
858 }
859 }
Jeff Sharkey5ea5c282019-12-18 14:06:28 -0700860
861 public static final Pattern PATTERN_DOWNLOADS_FILE = Pattern.compile(
862 "(?i)^/storage/[^/]+/(?:[0-9]+/)?(?:Android/sandbox/[^/]+/)?Download/.+");
863 public static final Pattern PATTERN_DOWNLOADS_DIRECTORY = Pattern.compile(
864 "(?i)^/storage/[^/]+/(?:[0-9]+/)?(?:Android/sandbox/[^/]+/)?Download/?");
Jeff Sharkey89149b62020-03-29 22:03:44 -0600865 public static final Pattern PATTERN_EXPIRES_FILE = Pattern.compile(
Sahana Raoea587fc2020-06-03 15:56:23 +0100866 "(?i)^\\.(pending|trashed)-(\\d+)-([^/]+)$");
867 public static final Pattern PATTERN_PENDING_FILEPATH_FOR_SQL = Pattern.compile(
868 ".*/\\.pending-(\\d+)-([^/]+)$");
Jeff Sharkey89149b62020-03-29 22:03:44 -0600869
870 /**
871 * File prefix indicating that the file {@link MediaColumns#IS_PENDING}.
872 */
873 public static final String PREFIX_PENDING = "pending";
874
875 /**
876 * File prefix indicating that the file {@link MediaColumns#IS_TRASHED}.
877 */
878 public static final String PREFIX_TRASHED = "trashed";
879
880 /**
881 * Default duration that {@link MediaColumns#IS_PENDING} items should be
882 * preserved for until automatically cleaned by {@link #runIdleMaintenance}.
883 */
Jeff Sharkey05c3a032020-04-09 16:57:04 -0600884 public static final long DEFAULT_DURATION_PENDING = 7 * DateUtils.DAY_IN_MILLIS;
Jeff Sharkey89149b62020-03-29 22:03:44 -0600885
886 /**
887 * Default duration that {@link MediaColumns#IS_TRASHED} items should be
888 * preserved for until automatically cleaned by {@link #runIdleMaintenance}.
889 */
Jeff Sharkey05c3a032020-04-09 16:57:04 -0600890 public static final long DEFAULT_DURATION_TRASHED = 30 * DateUtils.DAY_IN_MILLIS;
Jeff Sharkey5ea5c282019-12-18 14:06:28 -0700891
892 public static boolean isDownload(@NonNull String path) {
893 return PATTERN_DOWNLOADS_FILE.matcher(path).matches();
894 }
895
896 public static boolean isDownloadDir(@NonNull String path) {
897 return PATTERN_DOWNLOADS_DIRECTORY.matcher(path).matches();
898 }
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -0700899
900 /**
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -0700901 * Regex that matches paths in all well-known package-specific directories,
902 * and which captures the package name as the first group.
903 */
904 public static final Pattern PATTERN_OWNED_PATH = Pattern.compile(
Ricky Waif40c4022020-04-15 19:00:06 +0100905 "(?i)^/storage/[^/]+/(?:[0-9]+/)?Android/(?:data|media|obb|sandbox)/([^/]+)(/?.*)?");
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -0700906
907 /**
Ricky Waifeb9d9b2020-04-06 19:14:46 +0100908 * Regex that matches Android/obb or Android/data path.
909 */
910 public static final Pattern PATTERN_DATA_OR_OBB_PATH = Pattern.compile(
911 "(?i)^/storage/[^/]+/(?:[0-9]+/)?Android/(?:data|obb)/?$");
912
Sahana Raod3120da2020-09-25 11:13:55 +0100913 @VisibleForTesting
914 public static final String[] DEFAULT_FOLDER_NAMES = {
915 Environment.DIRECTORY_MUSIC,
916 Environment.DIRECTORY_PODCASTS,
917 Environment.DIRECTORY_RINGTONES,
918 Environment.DIRECTORY_ALARMS,
919 Environment.DIRECTORY_NOTIFICATIONS,
920 Environment.DIRECTORY_PICTURES,
921 Environment.DIRECTORY_MOVIES,
922 Environment.DIRECTORY_DOWNLOADS,
923 Environment.DIRECTORY_DCIM,
924 Environment.DIRECTORY_DOCUMENTS,
925 Environment.DIRECTORY_AUDIOBOOKS,
926 };
927
Ricky Waifeb9d9b2020-04-06 19:14:46 +0100928 /**
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -0700929 * Regex that matches paths for {@link MediaColumns#RELATIVE_PATH}; it
930 * captures both top-level paths and sandboxed paths.
931 */
932 private static final Pattern PATTERN_RELATIVE_PATH = Pattern.compile(
nolyn.luc92835c2019-08-26 13:58:05 +0800933 "(?i)^/storage/(?:emulated/[0-9]+/|[^/]+/)(Android/sandbox/([^/]+)/)?");
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -0700934
935 /**
936 * Regex that matches paths under well-known storage paths.
937 */
938 private static final Pattern PATTERN_VOLUME_NAME = Pattern.compile(
939 "(?i)^/storage/([^/]+)");
940
Sahana Raod3120da2020-09-25 11:13:55 +0100941 private static final String CAMERA_RELATIVE_PATH =
942 String.format("%s/%s/", Environment.DIRECTORY_DCIM, "Camera");
943
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -0700944 private static @Nullable String normalizeUuid(@Nullable String fsUuid) {
Jeff Sharkeyc4a5f812020-05-03 21:07:14 -0600945 return fsUuid != null ? fsUuid.toLowerCase(Locale.ROOT) : null;
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -0700946 }
947
Jeff Sharkey89149b62020-03-29 22:03:44 -0600948 public static @Nullable String extractVolumePath(@Nullable String data) {
949 if (data == null) return null;
950 final Matcher matcher = PATTERN_RELATIVE_PATH.matcher(data);
951 if (matcher.find()) {
952 return data.substring(0, matcher.end());
953 } else {
954 return null;
955 }
956 }
957
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -0700958 public static @Nullable String extractVolumeName(@Nullable String data) {
959 if (data == null) return null;
960 final Matcher matcher = PATTERN_VOLUME_NAME.matcher(data);
961 if (matcher.find()) {
962 final String volumeName = matcher.group(1);
963 if (volumeName.equals("emulated")) {
964 return MediaStore.VOLUME_EXTERNAL_PRIMARY;
965 } else {
966 return normalizeUuid(volumeName);
967 }
968 } else {
969 return MediaStore.VOLUME_INTERNAL;
970 }
971 }
972
973 public static @Nullable String extractRelativePath(@Nullable String data) {
974 if (data == null) return null;
975 final Matcher matcher = PATTERN_RELATIVE_PATH.matcher(data);
976 if (matcher.find()) {
977 final int lastSlash = data.lastIndexOf('/');
978 if (lastSlash == -1 || lastSlash < matcher.end()) {
979 // This is a file in the top-level directory, so relative path is "/"
980 // which is different than null, which means unknown path
981 return "/";
982 } else {
983 return data.substring(matcher.end(), lastSlash + 1);
984 }
985 } else {
986 return null;
987 }
988 }
989
990 /**
991 * Returns relative path for the directory.
992 */
993 @VisibleForTesting
994 public static @Nullable String extractRelativePathForDirectory(@Nullable String directoryPath) {
995 if (directoryPath == null) return null;
Sahana Rao54dafa32020-06-22 08:42:37 +0100996
997 if (directoryPath.equals("/storage/emulated") ||
998 directoryPath.equals("/storage/emulated/")) {
999 // This path is not reachable for MediaProvider.
1000 return null;
1001 }
1002
1003 // We are extracting relative path for the directory itself, we add "/" so that we can use
1004 // same PATTERN_RELATIVE_PATH to match relative path for directory. For example, relative
1005 // path of '/storage/<volume_name>' is null where as relative path for directory is "/", for
1006 // PATTERN_RELATIVE_PATH to match '/storage/<volume_name>', it should end with "/".
1007 if (!directoryPath.endsWith("/")) {
1008 // Relative path for directory should end with "/".
1009 directoryPath += "/";
1010 }
1011
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001012 final Matcher matcher = PATTERN_RELATIVE_PATH.matcher(directoryPath);
1013 if (matcher.find()) {
Sahana Rao54dafa32020-06-22 08:42:37 +01001014 if (matcher.end() == directoryPath.length()) {
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001015 // This is the top-level directory, so relative path is "/"
1016 return "/";
1017 }
Sahana Rao54dafa32020-06-22 08:42:37 +01001018 return directoryPath.substring(matcher.end());
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001019 }
1020 return null;
1021 }
1022
1023 public static @Nullable String extractPathOwnerPackageName(@Nullable String path) {
1024 if (path == null) return null;
1025 final Matcher m = PATTERN_OWNED_PATH.matcher(path);
1026 if (m.matches()) {
1027 return m.group(1);
1028 } else {
1029 return null;
1030 }
1031 }
1032
1033 /**
Ricky Waifeb9d9b2020-04-06 19:14:46 +01001034 * Returns true if relative path is Android/data or Android/obb path.
1035 */
1036 public static boolean isDataOrObbPath(String path) {
1037 if (path == null) return false;
1038 final Matcher m = PATTERN_DATA_OR_OBB_PATH.matcher(path);
1039 return m.matches();
1040 }
1041
1042 /**
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001043 * Returns the name of the top level directory, or null if the path doesn't go through the
1044 * external storage directory.
1045 */
1046 @Nullable
1047 public static String extractTopLevelDir(String path) {
Sahana Rao920f6662020-04-30 15:13:42 +01001048 final String relativePath = extractRelativePath(path);
1049 if (relativePath == null) {
1050 return null;
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001051 }
Sahana Rao920f6662020-04-30 15:13:42 +01001052 final String[] relativePathSegments = relativePath.split("/");
1053 return relativePathSegments.length > 0 ? relativePathSegments[0] : null;
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001054 }
Jeff Sharkeyc55994b2019-12-20 19:43:59 -07001055
Sahana Raod3120da2020-09-25 11:13:55 +01001056 public static boolean isDefaultDirectoryName(@Nullable String dirName) {
1057 for (String defaultDirName : DEFAULT_FOLDER_NAMES) {
1058 if (defaultDirName.equalsIgnoreCase(dirName)) {
1059 return true;
1060 }
1061 }
1062 return false;
1063 }
1064
Jeff Sharkey89149b62020-03-29 22:03:44 -06001065 /**
Jeff Sharkey05c3a032020-04-09 16:57:04 -06001066 * Compute the value of {@link MediaColumns#DATE_EXPIRES} based on other
1067 * columns being modified by this operation.
1068 */
1069 public static void computeDateExpires(@NonNull ContentValues values) {
1070 // External apps have no ability to change this field
1071 values.remove(MediaColumns.DATE_EXPIRES);
1072
1073 // Only define the field when this modification is actually adjusting
1074 // one of the flags that should influence the expiration
Jeff Sharkeya9473e92020-04-17 15:54:30 -06001075 final Object pending = values.get(MediaColumns.IS_PENDING);
Jeff Sharkey05c3a032020-04-09 16:57:04 -06001076 if (pending != null) {
Jeff Sharkeya9473e92020-04-17 15:54:30 -06001077 if (parseBoolean(pending, false)) {
Jeff Sharkey05c3a032020-04-09 16:57:04 -06001078 values.put(MediaColumns.DATE_EXPIRES,
1079 (System.currentTimeMillis() + DEFAULT_DURATION_PENDING) / 1000);
1080 } else {
1081 values.putNull(MediaColumns.DATE_EXPIRES);
1082 }
1083 }
Jeff Sharkeya9473e92020-04-17 15:54:30 -06001084 final Object trashed = values.get(MediaColumns.IS_TRASHED);
Jeff Sharkey05c3a032020-04-09 16:57:04 -06001085 if (trashed != null) {
Jeff Sharkeya9473e92020-04-17 15:54:30 -06001086 if (parseBoolean(trashed, false)) {
Jeff Sharkey05c3a032020-04-09 16:57:04 -06001087 values.put(MediaColumns.DATE_EXPIRES,
1088 (System.currentTimeMillis() + DEFAULT_DURATION_TRASHED) / 1000);
1089 } else {
1090 values.putNull(MediaColumns.DATE_EXPIRES);
1091 }
1092 }
1093 }
1094
1095 /**
Jeff Sharkey89149b62020-03-29 22:03:44 -06001096 * Compute several scattered {@link MediaColumns} values from
1097 * {@link MediaColumns#DATA}. This method performs no enforcement of
1098 * argument validity.
1099 */
Sahana Raoea587fc2020-06-03 15:56:23 +01001100 public static void computeValuesFromData(@NonNull ContentValues values, boolean isForFuse) {
Jeff Sharkeyc55994b2019-12-20 19:43:59 -07001101 // Worst case we have to assume no bucket details
Jeff Sharkey89149b62020-03-29 22:03:44 -06001102 values.remove(MediaColumns.VOLUME_NAME);
1103 values.remove(MediaColumns.RELATIVE_PATH);
Jeff Sharkey89149b62020-03-29 22:03:44 -06001104 values.remove(MediaColumns.IS_TRASHED);
1105 values.remove(MediaColumns.DATE_EXPIRES);
1106 values.remove(MediaColumns.DISPLAY_NAME);
1107 values.remove(MediaColumns.BUCKET_ID);
1108 values.remove(MediaColumns.BUCKET_DISPLAY_NAME);
Jeff Sharkeyc55994b2019-12-20 19:43:59 -07001109
1110 final String data = values.getAsString(MediaColumns.DATA);
1111 if (TextUtils.isEmpty(data)) return;
1112
1113 final File file = new File(data);
1114 final File fileLower = new File(data.toLowerCase(Locale.ROOT));
1115
Jeff Sharkey89149b62020-03-29 22:03:44 -06001116 values.put(MediaColumns.VOLUME_NAME, extractVolumeName(data));
1117 values.put(MediaColumns.RELATIVE_PATH, extractRelativePath(data));
Jeff Sharkey89149b62020-03-29 22:03:44 -06001118 final String displayName = extractDisplayName(data);
1119 final Matcher matcher = FileUtils.PATTERN_EXPIRES_FILE.matcher(displayName);
1120 if (matcher.matches()) {
1121 values.put(MediaColumns.IS_PENDING,
1122 matcher.group(1).equals(FileUtils.PREFIX_PENDING) ? 1 : 0);
1123 values.put(MediaColumns.IS_TRASHED,
1124 matcher.group(1).equals(FileUtils.PREFIX_TRASHED) ? 1 : 0);
1125 values.put(MediaColumns.DATE_EXPIRES, Long.parseLong(matcher.group(2)));
1126 values.put(MediaColumns.DISPLAY_NAME, matcher.group(3));
1127 } else {
Sahana Raoea587fc2020-06-03 15:56:23 +01001128 if (isForFuse) {
1129 // Allow Fuse thread to set IS_PENDING when using DATA column.
1130 // TODO(b/156867379) Unset IS_PENDING when Fuse thread doesn't explicitly specify
1131 // IS_PENDING. It can't be done now because we scan after create. Scan doesn't
1132 // explicitly specify the value of IS_PENDING.
1133 } else {
1134 values.put(MediaColumns.IS_PENDING, 0);
1135 }
Jeff Sharkey89149b62020-03-29 22:03:44 -06001136 values.put(MediaColumns.IS_TRASHED, 0);
1137 values.putNull(MediaColumns.DATE_EXPIRES);
1138 values.put(MediaColumns.DISPLAY_NAME, displayName);
1139 }
Jeff Sharkeyc55994b2019-12-20 19:43:59 -07001140
1141 // Buckets are the parent directory
1142 final String parent = fileLower.getParent();
1143 if (parent != null) {
Jeff Sharkey89149b62020-03-29 22:03:44 -06001144 values.put(MediaColumns.BUCKET_ID, parent.hashCode());
Jeff Sharkeyc55994b2019-12-20 19:43:59 -07001145 // The relative path for files in the top directory is "/"
Jeff Sharkey89149b62020-03-29 22:03:44 -06001146 if (!"/".equals(values.getAsString(MediaColumns.RELATIVE_PATH))) {
1147 values.put(MediaColumns.BUCKET_DISPLAY_NAME, file.getParentFile().getName());
Jeff Sharkeyc55994b2019-12-20 19:43:59 -07001148 }
1149 }
1150 }
Sahana Raof21671d2020-03-09 16:49:26 +00001151
Jeff Sharkey89149b62020-03-29 22:03:44 -06001152 /**
1153 * Compute {@link MediaColumns#DATA} from several scattered
1154 * {@link MediaColumns} values. This method performs no enforcement of
1155 * argument validity.
1156 */
1157 public static void computeDataFromValues(@NonNull ContentValues values,
Sahana Raoea587fc2020-06-03 15:56:23 +01001158 @NonNull File volumePath, boolean isForFuse) {
Jeff Sharkey89149b62020-03-29 22:03:44 -06001159 values.remove(MediaColumns.DATA);
1160
1161 final String displayName = values.getAsString(MediaColumns.DISPLAY_NAME);
1162 final String resolvedDisplayName;
Sahana Raoea587fc2020-06-03 15:56:23 +01001163 // Pending file path shouldn't be rewritten for files inserted via filepath.
1164 if (!isForFuse && getAsBoolean(values, MediaColumns.IS_PENDING, false)) {
Jeff Sharkey89149b62020-03-29 22:03:44 -06001165 final long dateExpires = getAsLong(values, MediaColumns.DATE_EXPIRES,
1166 (System.currentTimeMillis() + DEFAULT_DURATION_PENDING) / 1000);
Corina54fb8ba2020-11-27 13:38:09 +00001167 resolvedDisplayName = String.format(
1168 Locale.US, ".%s-%d-%s", FileUtils.PREFIX_PENDING, dateExpires, displayName);
Jeff Sharkey89149b62020-03-29 22:03:44 -06001169 } else if (getAsBoolean(values, MediaColumns.IS_TRASHED, false)) {
1170 final long dateExpires = getAsLong(values, MediaColumns.DATE_EXPIRES,
1171 (System.currentTimeMillis() + DEFAULT_DURATION_TRASHED) / 1000);
Corina54fb8ba2020-11-27 13:38:09 +00001172 resolvedDisplayName = String.format(
1173 Locale.US, ".%s-%d-%s", FileUtils.PREFIX_TRASHED, dateExpires, displayName);
Jeff Sharkey89149b62020-03-29 22:03:44 -06001174 } else {
1175 resolvedDisplayName = displayName;
1176 }
1177
1178 final File filePath = buildPath(volumePath,
1179 values.getAsString(MediaColumns.RELATIVE_PATH), resolvedDisplayName);
1180 values.put(MediaColumns.DATA, filePath.getAbsolutePath());
1181 }
1182
Sahana Rao76b8b5b2020-04-17 20:21:59 +01001183 public static void sanitizeValues(@NonNull ContentValues values,
1184 boolean rewriteHiddenFileName) {
Jeff Sharkey89149b62020-03-29 22:03:44 -06001185 final String[] relativePath = values.getAsString(MediaColumns.RELATIVE_PATH).split("/");
1186 for (int i = 0; i < relativePath.length; i++) {
Sahana Rao76b8b5b2020-04-17 20:21:59 +01001187 relativePath[i] = sanitizeDisplayName(relativePath[i], rewriteHiddenFileName);
Jeff Sharkey89149b62020-03-29 22:03:44 -06001188 }
1189 values.put(MediaColumns.RELATIVE_PATH,
1190 String.join("/", relativePath) + "/");
Sahana Rao76b8b5b2020-04-17 20:21:59 +01001191
1192 final String displayName = values.getAsString(MediaColumns.DISPLAY_NAME);
Jeff Sharkey89149b62020-03-29 22:03:44 -06001193 values.put(MediaColumns.DISPLAY_NAME,
Sahana Rao76b8b5b2020-04-17 20:21:59 +01001194 sanitizeDisplayName(displayName, rewriteHiddenFileName));
Jeff Sharkey89149b62020-03-29 22:03:44 -06001195 }
1196
Sahana Raof21671d2020-03-09 16:49:26 +00001197 /** {@hide} **/
1198 @Nullable
1199 public static String getAbsoluteSanitizedPath(String path) {
1200 final String[] pathSegments = sanitizePath(path);
1201 if (pathSegments.length == 0) {
1202 return null;
1203 }
1204 return path = "/" + String.join("/",
1205 Arrays.copyOfRange(pathSegments, 1, pathSegments.length));
1206 }
1207
1208 /** {@hide} */
1209 public static @NonNull String[] sanitizePath(@Nullable String path) {
1210 if (path == null) {
1211 return new String[0];
1212 } else {
1213 final String[] segments = path.split("/");
1214 // If the path corresponds to the top level directory, then we return an empty path
1215 // which denotes the top level directory
1216 if (segments.length == 0) {
1217 return new String[] { "" };
1218 }
1219 for (int i = 0; i < segments.length; i++) {
1220 segments[i] = sanitizeDisplayName(segments[i]);
1221 }
1222 return segments;
1223 }
1224 }
1225
1226 /**
Sahana Rao76b8b5b2020-04-17 20:21:59 +01001227 * Sanitizes given name by mutating the file name to make it valid for a FAT filesystem.
Sahana Raof21671d2020-03-09 16:49:26 +00001228 * @hide
1229 */
1230 public static @Nullable String sanitizeDisplayName(@Nullable String name) {
Sahana Rao76b8b5b2020-04-17 20:21:59 +01001231 return sanitizeDisplayName(name, /*rewriteHiddenFileName*/ false);
1232 }
1233
1234 /**
1235 * Sanitizes given name by appending '_' to make it non-hidden and mutating the file name to
1236 * make it valid for a FAT filesystem.
1237 * @hide
1238 */
1239 public static @Nullable String sanitizeDisplayName(@Nullable String name,
1240 boolean rewriteHiddenFileName) {
Sahana Raof21671d2020-03-09 16:49:26 +00001241 if (name == null) {
1242 return null;
Sahana Rao76b8b5b2020-04-17 20:21:59 +01001243 } else if (rewriteHiddenFileName && name.startsWith(".")) {
Sahana Raof21671d2020-03-09 16:49:26 +00001244 // The resulting file must not be hidden.
Sahana Rao76b8b5b2020-04-17 20:21:59 +01001245 return "_" + name;
Sahana Raof21671d2020-03-09 16:49:26 +00001246 } else {
1247 return buildValidFatFilename(name);
1248 }
1249 }
shafika202fdd2020-05-11 20:19:27 +01001250
1251 /**
Martijn Coenen070bce12020-06-08 21:18:24 +02001252 * Test if this given directory should be considered hidden.
1253 */
1254 @VisibleForTesting
1255 public static boolean isDirectoryHidden(@NonNull File dir) {
1256 final String name = dir.getName();
1257 if (name.startsWith(".")) {
1258 return true;
1259 }
1260
1261 final File nomedia = new File(dir, ".nomedia");
Sahana Raod3120da2020-09-25 11:13:55 +01001262
Martijn Coenen070bce12020-06-08 21:18:24 +02001263 // check for .nomedia presence
Sahana Raod3120da2020-09-25 11:13:55 +01001264 if (!nomedia.exists()) {
1265 return false;
Martijn Coenen070bce12020-06-08 21:18:24 +02001266 }
Sahana Raod3120da2020-09-25 11:13:55 +01001267
1268 // Handle top-level default directories. These directories should always be visible,
1269 // regardless of .nomedia presence.
1270 final String[] relativePath = sanitizePath(extractRelativePath(dir.getAbsolutePath()));
1271 final boolean isTopLevelDir =
1272 relativePath.length == 1 && TextUtils.isEmpty(relativePath[0]);
1273 if (isTopLevelDir && isDefaultDirectoryName(name)) {
1274 nomedia.delete();
1275 return false;
1276 }
1277
1278 // DCIM/Camera should always be visible regardless of .nomedia presence.
1279 if (CAMERA_RELATIVE_PATH.equalsIgnoreCase(
1280 extractRelativePathForDirectory(dir.getAbsolutePath()))) {
1281 nomedia.delete();
1282 return false;
1283 }
1284
1285 // .nomedia is present which makes this directory as hidden directory
1286 Logging.logPersistent("Observed non-standard " + nomedia);
1287 return true;
Martijn Coenen070bce12020-06-08 21:18:24 +02001288 }
1289
1290 /**
1291 * Test if this given file should be considered hidden.
1292 */
1293 @VisibleForTesting
1294 public static boolean isFileHidden(@NonNull File file) {
1295 final String name = file.getName();
1296
1297 // Handle well-known file names that are pending or trashed; they
1298 // normally appear hidden, but we give them special treatment
1299 if (PATTERN_EXPIRES_FILE.matcher(name).matches()) {
1300 return false;
1301 }
1302
1303 // Otherwise fall back to file name
1304 if (name.startsWith(".")) {
1305 return true;
1306 }
1307 return false;
1308 }
1309
1310 /**
shafika202fdd2020-05-11 20:19:27 +01001311 * Clears all app's external cache directories, i.e. for each app we delete
1312 * /sdcard/Android/data/app/cache/* but we keep the directory itself.
1313 *
1314 * @return 0 in case of success, or {@link OsConstants#EIO} if any error occurs.
1315 *
1316 * <p>This method doesn't perform any checks, so make sure that the calling package is allowed
1317 * to clear cache directories first.
1318 *
1319 * <p>If this method returned {@link OsConstants#EIO}, then we can't guarantee whether all, none
1320 * or part of the directories were cleared.
1321 */
1322 public static int clearAppCacheDirectories() {
1323 int status = 0;
1324 Log.i(TAG, "Clearing cache for all apps");
1325 final File rootDataDir = buildPath(Environment.getExternalStorageDirectory(),
1326 "Android", "data");
1327 for (File appDataDir : rootDataDir.listFiles()) {
1328 try {
1329 final File appCacheDir = new File(appDataDir, "cache");
1330 if (appCacheDir.isDirectory()) {
1331 FileUtils.deleteContents(appCacheDir);
1332 }
1333 } catch (Exception e) {
1334 // We want to avoid crashing MediaProvider at all costs, so we handle all "generic"
1335 // exceptions here, and just report to the caller that an IO exception has occurred.
1336 // We still try to clear the rest of the directories.
1337 Log.e(TAG, "Couldn't delete all app cache dirs!", e);
1338 status = OsConstants.EIO;
1339 }
1340 }
1341 return status;
1342 }
Zim3bec66b2020-09-30 11:38:50 +01001343
1344 /**
1345 * @return {@code true} if {@code dir} is dirty and should be scanned, {@code false} otherwise.
1346 */
1347 public static boolean isDirectoryDirty(File dir) {
1348 File nomedia = new File(dir, ".nomedia");
1349 if (nomedia.exists()) {
1350 try {
1351 Optional<String> expectedPath = readString(nomedia);
1352 // Returns true If .nomedia file is empty or content doesn't match |dir|
1353 // Returns false otherwise
1354 return !expectedPath.isPresent()
1355 || !expectedPath.get().equals(dir.getPath());
1356 } catch (IOException e) {
1357 Log.w(TAG, "Failed to read directory dirty" + dir);
1358 }
1359 }
1360 return true;
1361 }
1362
1363 /**
1364 * {@code isDirty} == {@code true} will force {@code dir} scanning even if it's hidden
1365 * {@code isDirty} == {@code false} will skip {@code dir} scanning on next scan.
1366 */
1367 public static void setDirectoryDirty(File dir, boolean isDirty) {
1368 File nomedia = new File(dir, ".nomedia");
1369 if (nomedia.exists()) {
1370 try {
1371 writeString(nomedia, isDirty ? Optional.of("") : Optional.of(dir.getPath()));
1372 } catch (IOException e) {
1373 Log.w(TAG, "Failed to change directory dirty: " + dir + ". isDirty: " + isDirty);
1374 }
1375 }
1376 }
1377
1378 /**
1379 * @return the folder containing the top-most .nomedia in {@code file} hierarchy.
1380 * E.g input as /sdcard/foo/bar/ will return /sdcard/foo
1381 * even if foo and bar contain .nomedia files.
1382 *
1383 * Returns {@code null} if there's no .nomedia in hierarchy
1384 */
1385 public static File getTopLevelNoMedia(@NonNull File file) {
1386 File topNoMedia = null;
1387
1388 File parent = file;
1389 while (parent != null) {
1390 File nomedia = new File(parent, ".nomedia");
1391 if (nomedia.exists()) {
1392 topNoMedia = nomedia;
1393 }
1394 parent = parent.getParentFile();
1395 }
1396
1397 return topNoMedia;
1398 }
Jeff Sharkey7ea24f22019-08-22 10:14:18 -06001399}