blob: e711d0c3d41e4ccd466f9a449122fb00afe76bfd [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;
52import android.provider.MediaStore;
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -070053import android.provider.MediaStore.MediaColumns;
Jeff Sharkey9a497642020-04-23 13:15:10 -060054import android.system.ErrnoException;
55import android.system.Os;
56import android.system.OsConstants;
Jeff Sharkey7ea24f22019-08-22 10:14:18 -060057import android.text.TextUtils;
Jeff Sharkey89149b62020-03-29 22:03:44 -060058import android.text.format.DateUtils;
Jeff Sharkey7ea24f22019-08-22 10:14:18 -060059import android.util.Log;
60import android.webkit.MimeTypeMap;
61
62import androidx.annotation.NonNull;
63import androidx.annotation.Nullable;
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -070064import androidx.annotation.VisibleForTesting;
Jeff Sharkey7ea24f22019-08-22 10:14:18 -060065
66import java.io.File;
Jeff Sharkey9a497642020-04-23 13:15:10 -060067import java.io.FileDescriptor;
Jeff Sharkey7ea24f22019-08-22 10:14:18 -060068import java.io.FileNotFoundException;
69import java.io.IOException;
70import java.io.InputStream;
71import java.io.OutputStream;
72import java.nio.charset.StandardCharsets;
Jeff Sharkeybb4e5e62020-02-09 17:14:08 -070073import java.nio.file.FileVisitResult;
74import java.nio.file.FileVisitor;
75import java.nio.file.Files;
76import java.nio.file.NoSuchFileException;
77import java.nio.file.Path;
Jeff Sharkeybb4e5e62020-02-09 17:14:08 -070078import java.nio.file.attribute.BasicFileAttributes;
Jeff Sharkeyc5c39142019-12-15 22:46:03 -070079import java.util.ArrayList;
Jeff Sharkey5278ead2020-01-07 16:40:18 -070080import java.util.Arrays;
Jeff Sharkey7ea24f22019-08-22 10:14:18 -060081import java.util.Collection;
Jeff Sharkey5278ead2020-01-07 16:40:18 -070082import java.util.Comparator;
Jeff Sharkey06b38ca2019-12-17 15:51:12 -070083import java.util.Iterator;
Jeff Sharkey470b97e2019-10-15 16:32:04 -060084import java.util.Locale;
Jeff Sharkey7ea24f22019-08-22 10:14:18 -060085import java.util.Objects;
Jeff Sharkeybb4e5e62020-02-09 17:14:08 -070086import java.util.Optional;
87import java.util.function.Consumer;
Jeff Sharkey06b38ca2019-12-17 15:51:12 -070088import java.util.regex.Matcher;
89import java.util.regex.Pattern;
Jeff Sharkey7ea24f22019-08-22 10:14:18 -060090
91public class FileUtils {
Jeff Sharkey9a497642020-04-23 13:15:10 -060092 /**
93 * Drop-in replacement for {@link ParcelFileDescriptor#open(File, int)}
94 * which adds security features like {@link OsConstants#O_CLOEXEC} and
95 * {@link OsConstants#O_NOFOLLOW}.
96 */
97 public static @NonNull ParcelFileDescriptor openSafely(@NonNull File file, int pfdFlags)
98 throws FileNotFoundException {
99 final int posixFlags = translateModePfdToPosix(pfdFlags) | O_CLOEXEC | O_NOFOLLOW;
100 try {
101 final FileDescriptor fd = Os.open(file.getAbsolutePath(), posixFlags,
102 S_IRWXU | S_IRWXG);
103 try {
104 return ParcelFileDescriptor.dup(fd);
105 } finally {
106 closeQuietly(fd);
107 }
108 } catch (IOException | ErrnoException e) {
109 throw new FileNotFoundException(e.getMessage());
110 }
111 }
112
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600113 public static void closeQuietly(@Nullable AutoCloseable closeable) {
114 android.os.FileUtils.closeQuietly(closeable);
115 }
116
Jeff Sharkey9a497642020-04-23 13:15:10 -0600117 public static void closeQuietly(@Nullable FileDescriptor fd) {
118 if (fd == null) return;
119 try {
120 Os.close(fd);
121 } catch (ErrnoException ignored) {
122 }
123 }
124
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600125 public static long copy(@NonNull InputStream in, @NonNull OutputStream out) throws IOException {
126 return android.os.FileUtils.copy(in, out);
127 }
128
129 public static File buildPath(File base, String... segments) {
130 File cur = base;
131 for (String segment : segments) {
132 if (cur == null) {
133 cur = new File(segment);
134 } else {
135 cur = new File(cur, segment);
136 }
137 }
138 return cur;
139 }
140
141 /**
Jeff Sharkey5278ead2020-01-07 16:40:18 -0700142 * Delete older files in a directory until only those matching the given
143 * constraints remain.
144 *
145 * @param minCount Always keep at least this many files.
146 * @param minAgeMs Always keep files younger than this age, in milliseconds.
147 * @return if any files were deleted.
148 */
149 public static boolean deleteOlderFiles(File dir, int minCount, long minAgeMs) {
150 if (minCount < 0 || minAgeMs < 0) {
151 throw new IllegalArgumentException("Constraints must be positive or 0");
152 }
153
154 final File[] files = dir.listFiles();
155 if (files == null) return false;
156
157 // Sort with newest files first
158 Arrays.sort(files, new Comparator<File>() {
159 @Override
160 public int compare(File lhs, File rhs) {
161 return Long.compare(rhs.lastModified(), lhs.lastModified());
162 }
163 });
164
165 // Keep at least minCount files
166 boolean deleted = false;
167 for (int i = minCount; i < files.length; i++) {
168 final File file = files[i];
169
170 // Keep files newer than minAgeMs
171 final long age = System.currentTimeMillis() - file.lastModified();
172 if (age > minAgeMs) {
173 if (file.delete()) {
174 Log.d(TAG, "Deleted old file " + file);
175 deleted = true;
176 }
177 }
178 }
179 return deleted;
180 }
181
182 /**
Jeff Sharkeyf06febd2020-04-07 13:03:30 -0600183 * Shamelessly borrowed from {@code android.os.FileUtils}.
184 */
185 public static int translateModeStringToPosix(String mode) {
186 // Sanity check for invalid chars
187 for (int i = 0; i < mode.length(); i++) {
188 switch (mode.charAt(i)) {
189 case 'r':
190 case 'w':
191 case 't':
192 case 'a':
193 break;
194 default:
195 throw new IllegalArgumentException("Bad mode: " + mode);
196 }
197 }
198
199 int res = 0;
200 if (mode.startsWith("rw")) {
201 res = O_RDWR | O_CREAT;
202 } else if (mode.startsWith("w")) {
203 res = O_WRONLY | O_CREAT;
204 } else if (mode.startsWith("r")) {
205 res = O_RDONLY;
206 } else {
207 throw new IllegalArgumentException("Bad mode: " + mode);
208 }
209 if (mode.indexOf('t') != -1) {
210 res |= O_TRUNC;
211 }
212 if (mode.indexOf('a') != -1) {
213 res |= O_APPEND;
214 }
215 return res;
216 }
217
218 /**
219 * Shamelessly borrowed from {@code android.os.FileUtils}.
220 */
221 public static String translateModePosixToString(int mode) {
222 String res = "";
223 if ((mode & O_ACCMODE) == O_RDWR) {
224 res = "rw";
225 } else if ((mode & O_ACCMODE) == O_WRONLY) {
226 res = "w";
227 } else if ((mode & O_ACCMODE) == O_RDONLY) {
228 res = "r";
229 } else {
230 throw new IllegalArgumentException("Bad mode: " + mode);
231 }
232 if ((mode & O_TRUNC) == O_TRUNC) {
233 res += "t";
234 }
235 if ((mode & O_APPEND) == O_APPEND) {
236 res += "a";
237 }
238 return res;
239 }
240
241 /**
242 * Shamelessly borrowed from {@code android.os.FileUtils}.
243 */
244 public static int translateModePosixToPfd(int mode) {
245 int res = 0;
246 if ((mode & O_ACCMODE) == O_RDWR) {
247 res = MODE_READ_WRITE;
248 } else if ((mode & O_ACCMODE) == O_WRONLY) {
249 res = MODE_WRITE_ONLY;
250 } else if ((mode & O_ACCMODE) == O_RDONLY) {
251 res = MODE_READ_ONLY;
252 } else {
253 throw new IllegalArgumentException("Bad mode: " + mode);
254 }
255 if ((mode & O_CREAT) == O_CREAT) {
256 res |= MODE_CREATE;
257 }
258 if ((mode & O_TRUNC) == O_TRUNC) {
259 res |= MODE_TRUNCATE;
260 }
261 if ((mode & O_APPEND) == O_APPEND) {
262 res |= MODE_APPEND;
263 }
264 return res;
265 }
266
267 /**
268 * Shamelessly borrowed from {@code android.os.FileUtils}.
269 */
270 public static int translateModePfdToPosix(int mode) {
271 int res = 0;
272 if ((mode & MODE_READ_WRITE) == MODE_READ_WRITE) {
273 res = O_RDWR;
274 } else if ((mode & MODE_WRITE_ONLY) == MODE_WRITE_ONLY) {
275 res = O_WRONLY;
276 } else if ((mode & MODE_READ_ONLY) == MODE_READ_ONLY) {
277 res = O_RDONLY;
278 } else {
279 throw new IllegalArgumentException("Bad mode: " + mode);
280 }
281 if ((mode & MODE_CREATE) == MODE_CREATE) {
282 res |= O_CREAT;
283 }
284 if ((mode & MODE_TRUNCATE) == MODE_TRUNCATE) {
285 res |= O_TRUNC;
286 }
287 if ((mode & MODE_APPEND) == MODE_APPEND) {
288 res |= O_APPEND;
289 }
290 return res;
291 }
292
293 /**
294 * Shamelessly borrowed from {@code android.os.FileUtils}.
295 */
296 public static int translateModeAccessToPosix(int mode) {
297 if (mode == F_OK) {
298 // There's not an exact mapping, so we attempt a read-only open to
299 // determine if a file exists
300 return O_RDONLY;
301 } else if ((mode & (R_OK | W_OK)) == (R_OK | W_OK)) {
302 return O_RDWR;
303 } else if ((mode & R_OK) == R_OK) {
304 return O_RDONLY;
305 } else if ((mode & W_OK) == W_OK) {
306 return O_WRONLY;
307 } else {
308 throw new IllegalArgumentException("Bad mode: " + mode);
309 }
310 }
311
312 /**
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600313 * Test if a file lives under the given directory, either as a direct child
314 * or a distant grandchild.
315 * <p>
316 * Both files <em>must</em> have been resolved using
317 * {@link File#getCanonicalFile()} to avoid symlink or path traversal
318 * attacks.
319 *
320 * @hide
321 */
322 public static boolean contains(File[] dirs, File file) {
323 for (File dir : dirs) {
324 if (contains(dir, file)) {
325 return true;
326 }
327 }
328 return false;
329 }
330
331 /** {@hide} */
332 public static boolean contains(Collection<File> dirs, File file) {
333 for (File dir : dirs) {
334 if (contains(dir, file)) {
335 return true;
336 }
337 }
338 return false;
339 }
340
341 /**
342 * Test if a file lives under the given directory, either as a direct child
343 * or a distant grandchild.
344 * <p>
345 * Both files <em>must</em> have been resolved using
346 * {@link File#getCanonicalFile()} to avoid symlink or path traversal
347 * attacks.
348 *
349 * @hide
350 */
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600351 public static boolean contains(File dir, File file) {
352 if (dir == null || file == null) return false;
353 return contains(dir.getAbsolutePath(), file.getAbsolutePath());
354 }
355
356 /**
357 * Test if a file lives under the given directory, either as a direct child
358 * or a distant grandchild.
359 * <p>
360 * Both files <em>must</em> have been resolved using
361 * {@link File#getCanonicalFile()} to avoid symlink or path traversal
362 * attacks.
363 *
364 * @hide
365 */
366 public static boolean contains(String dirPath, String filePath) {
367 if (dirPath.equals(filePath)) {
368 return true;
369 }
370 if (!dirPath.endsWith("/")) {
371 dirPath += "/";
372 }
373 return filePath.startsWith(dirPath);
374 }
375
Jeff Sharkeybb4e5e62020-02-09 17:14:08 -0700376 /**
377 * Write {@link String} to the given {@link File}. Deletes any existing file
378 * when the argument is {@link Optional#empty()}.
379 */
380 public static void writeString(@NonNull File file, @NonNull Optional<String> value)
381 throws IOException {
382 if (value.isPresent()) {
383 Files.write(file.toPath(), value.get().getBytes(StandardCharsets.UTF_8));
384 } else {
385 file.delete();
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600386 }
Jeff Sharkeybb4e5e62020-02-09 17:14:08 -0700387 }
388
389 /**
390 * Read given {@link File} as a single {@link String}. Returns
391 * {@link Optional#empty()} when the file doesn't exist.
392 */
393 public static @NonNull Optional<String> readString(@NonNull File file) throws IOException {
394 try {
395 final String value = new String(Files.readAllBytes(file.toPath()),
396 StandardCharsets.UTF_8);
397 return Optional.of(value);
398 } catch (NoSuchFileException e) {
399 return Optional.empty();
400 }
401 }
402
403 /**
404 * Recursively walk the contents of the given {@link Path}, invoking the
405 * given {@link Consumer} for every file and directory encountered. This is
406 * typically used for recursively deleting a directory tree.
407 * <p>
408 * Gracefully attempts to process as much as possible in the face of any
409 * failures.
410 */
411 public static void walkFileTreeContents(@NonNull Path path, @NonNull Consumer<Path> operation) {
412 try {
413 Files.walkFileTree(path, new FileVisitor<Path>() {
414 @Override
415 public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
416 return FileVisitResult.CONTINUE;
417 }
418
419 @Override
420 public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
421 if (!Objects.equals(path, file)) {
422 operation.accept(file);
423 }
424 return FileVisitResult.CONTINUE;
425 }
426
427 @Override
428 public FileVisitResult visitFileFailed(Path file, IOException e) {
429 Log.w(TAG, "Failed to visit " + file, e);
430 return FileVisitResult.CONTINUE;
431 }
432
433 @Override
434 public FileVisitResult postVisitDirectory(Path dir, IOException e) {
435 if (!Objects.equals(path, dir)) {
436 operation.accept(dir);
437 }
438 return FileVisitResult.CONTINUE;
439 }
440 });
441 } catch (IOException e) {
442 Log.w(TAG, "Failed to walk " + path, e);
443 }
444 }
445
446 /**
447 * Recursively delete all contents inside the given directory. Gracefully
448 * attempts to delete as much as possible in the face of any failures.
449 *
Jeff Sharkey89149b62020-03-29 22:03:44 -0600450 * @deprecated if you're calling this from inside {@code MediaProvider}, you
Jeff Sharkeybb4e5e62020-02-09 17:14:08 -0700451 * likely want to call {@link #forEach} with a separate
452 * invocation to invalidate FUSE entries.
453 */
454 @Deprecated
455 public static void deleteContents(@NonNull File dir) {
456 walkFileTreeContents(dir.toPath(), (path) -> {
457 path.toFile().delete();
458 });
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600459 }
460
461 private static boolean isValidFatFilenameChar(char c) {
462 if ((0x00 <= c && c <= 0x1f)) {
463 return false;
464 }
465 switch (c) {
466 case '"':
467 case '*':
468 case '/':
469 case ':':
470 case '<':
471 case '>':
472 case '?':
473 case '\\':
474 case '|':
475 case 0x7F:
476 return false;
477 default:
478 return true;
479 }
480 }
481
482 /**
483 * Check if given filename is valid for a FAT filesystem.
484 *
485 * @hide
486 */
487 public static boolean isValidFatFilename(String name) {
488 return (name != null) && name.equals(buildValidFatFilename(name));
489 }
490
491 /**
492 * Mutate the given filename to make it valid for a FAT filesystem,
493 * replacing any invalid characters with "_".
494 *
495 * @hide
496 */
497 public static String buildValidFatFilename(String name) {
498 if (TextUtils.isEmpty(name) || ".".equals(name) || "..".equals(name)) {
499 return "(invalid)";
500 }
501 final StringBuilder res = new StringBuilder(name.length());
502 for (int i = 0; i < name.length(); i++) {
503 final char c = name.charAt(i);
504 if (isValidFatFilenameChar(c)) {
505 res.append(c);
506 } else {
507 res.append('_');
508 }
509 }
510 // Even though vfat allows 255 UCS-2 chars, we might eventually write to
511 // ext4 through a FUSE layer, so use that limit.
512 trimFilename(res, 255);
513 return res.toString();
514 }
515
516 /** {@hide} */
517 // @VisibleForTesting
518 public static String trimFilename(String str, int maxBytes) {
519 final StringBuilder res = new StringBuilder(str);
520 trimFilename(res, maxBytes);
521 return res.toString();
522 }
523
524 /** {@hide} */
525 private static void trimFilename(StringBuilder res, int maxBytes) {
526 byte[] raw = res.toString().getBytes(StandardCharsets.UTF_8);
527 if (raw.length > maxBytes) {
528 maxBytes -= 3;
529 while (raw.length > maxBytes) {
530 res.deleteCharAt(res.length() / 2);
531 raw = res.toString().getBytes(StandardCharsets.UTF_8);
532 }
533 res.insert(res.length() / 2, "...");
534 }
535 }
536
537 /** {@hide} */
538 private static File buildUniqueFileWithExtension(File parent, String name, String ext)
539 throws FileNotFoundException {
Jeff Sharkey06b38ca2019-12-17 15:51:12 -0700540 final Iterator<String> names = buildUniqueNameIterator(parent, name);
541 while (names.hasNext()) {
542 File file = buildFile(parent, names.next(), ext);
543 if (!file.exists()) {
544 return file;
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600545 }
Jeff Sharkey06b38ca2019-12-17 15:51:12 -0700546 }
547 throw new FileNotFoundException("Failed to create unique file");
548 }
549
550 private static final Pattern PATTERN_DCF_STRICT = Pattern
551 .compile("([A-Z0-9_]{4})([0-9]{4})");
552 private static final Pattern PATTERN_DCF_RELAXED = Pattern
553 .compile("((?:IMG|MVIMG|VID)_[0-9]{8}_[0-9]{6})(?:~([0-9]+))?");
554
555 private static boolean isDcim(@NonNull File dir) {
556 while (dir != null) {
557 if (Objects.equals("DCIM", dir.getName())) {
558 return true;
559 }
560 dir = dir.getParentFile();
561 }
562 return false;
563 }
564
565 private static @NonNull Iterator<String> buildUniqueNameIterator(@NonNull File parent,
566 @NonNull String name) {
567 if (isDcim(parent)) {
568 final Matcher dcfStrict = PATTERN_DCF_STRICT.matcher(name);
569 if (dcfStrict.matches()) {
570 // Generate names like "IMG_1001"
571 final String prefix = dcfStrict.group(1);
572 return new Iterator<String>() {
573 int i = Integer.parseInt(dcfStrict.group(2));
574 @Override
575 public String next() {
576 final String res = String.format("%s%04d", prefix, i);
577 i++;
578 return res;
579 }
580 @Override
581 public boolean hasNext() {
582 return i <= 9999;
583 }
584 };
585 }
586
587 final Matcher dcfRelaxed = PATTERN_DCF_RELAXED.matcher(name);
588 if (dcfRelaxed.matches()) {
589 // Generate names like "IMG_20190102_030405~2"
590 final String prefix = dcfRelaxed.group(1);
591 return new Iterator<String>() {
592 int i = TextUtils.isEmpty(dcfRelaxed.group(2)) ? 1
593 : Integer.parseInt(dcfRelaxed.group(2));
594 @Override
595 public String next() {
596 final String res = (i == 1) ? prefix : String.format("%s~%d", prefix, i);
597 i++;
598 return res;
599 }
600 @Override
601 public boolean hasNext() {
602 return i <= 99;
603 }
604 };
605 }
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600606 }
607
Jeff Sharkey06b38ca2019-12-17 15:51:12 -0700608 // Generate names like "foo (2)"
609 return new Iterator<String>() {
610 int i = 0;
611 @Override
612 public String next() {
613 final String res = (i == 0) ? name : name + " (" + i + ")";
614 i++;
615 return res;
616 }
617 @Override
618 public boolean hasNext() {
619 return i < 32;
620 }
621 };
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600622 }
623
624 /**
625 * Generates a unique file name under the given parent directory. If the display name doesn't
626 * have an extension that matches the requested MIME type, the default extension for that MIME
627 * type is appended. If a file already exists, the name is appended with a numerical value to
628 * make it unique.
629 *
630 * For example, the display name 'example' with 'text/plain' MIME might produce
631 * 'example.txt' or 'example (1).txt', etc.
632 *
633 * @throws FileNotFoundException
634 * @hide
635 */
636 public static File buildUniqueFile(File parent, String mimeType, String displayName)
637 throws FileNotFoundException {
638 final String[] parts = splitFileName(mimeType, displayName);
639 return buildUniqueFileWithExtension(parent, parts[0], parts[1]);
640 }
641
642 /** {@hide} */
643 public static File buildNonUniqueFile(File parent, String mimeType, String displayName) {
644 final String[] parts = splitFileName(mimeType, displayName);
645 return buildFile(parent, parts[0], parts[1]);
646 }
647
648 /**
649 * Generates a unique file name under the given parent directory, keeping
650 * any extension intact.
651 *
652 * @hide
653 */
654 public static File buildUniqueFile(File parent, String displayName)
655 throws FileNotFoundException {
656 final String name;
657 final String ext;
658
659 // Extract requested extension from display name
660 final int lastDot = displayName.lastIndexOf('.');
661 if (lastDot >= 0) {
662 name = displayName.substring(0, lastDot);
663 ext = displayName.substring(lastDot + 1);
664 } else {
665 name = displayName;
666 ext = null;
667 }
668
669 return buildUniqueFileWithExtension(parent, name, ext);
670 }
671
672 /**
673 * Splits file name into base name and extension.
674 * If the display name doesn't have an extension that matches the requested MIME type, the
675 * extension is regarded as a part of filename and default extension for that MIME type is
676 * appended.
677 *
678 * @hide
679 */
680 public static String[] splitFileName(String mimeType, String displayName) {
681 String name;
682 String ext;
683
684 {
685 String mimeTypeFromExt;
686
687 // Extract requested extension from display name
688 final int lastDot = displayName.lastIndexOf('.');
689 if (lastDot >= 0) {
690 name = displayName.substring(0, lastDot);
691 ext = displayName.substring(lastDot + 1);
692 mimeTypeFromExt = MimeTypeMap.getSingleton().getMimeTypeFromExtension(
Jeff Sharkey470b97e2019-10-15 16:32:04 -0600693 ext.toLowerCase(Locale.ROOT));
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600694 } else {
695 name = displayName;
696 ext = null;
697 mimeTypeFromExt = null;
698 }
699
700 if (mimeTypeFromExt == null) {
701 mimeTypeFromExt = ClipDescription.MIMETYPE_UNKNOWN;
702 }
703
704 final String extFromMimeType;
705 if (ClipDescription.MIMETYPE_UNKNOWN.equals(mimeType)) {
706 extFromMimeType = null;
707 } else {
708 extFromMimeType = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType);
709 }
710
711 if (Objects.equals(mimeType, mimeTypeFromExt) || Objects.equals(ext, extFromMimeType)) {
712 // Extension maps back to requested MIME type; allow it
713 } else {
714 // No match; insist that create file matches requested MIME
715 name = displayName;
716 ext = extFromMimeType;
717 }
718 }
719
720 if (ext == null) {
721 ext = "";
722 }
723
724 return new String[] { name, ext };
725 }
726
727 /** {@hide} */
728 private static File buildFile(File parent, String name, String ext) {
729 if (TextUtils.isEmpty(ext)) {
730 return new File(parent, name);
731 } else {
732 return new File(parent, name + "." + ext);
733 }
734 }
Jeff Sharkeye152d5762019-10-11 17:14:51 -0600735
736 public static @Nullable String extractDisplayName(@Nullable String data) {
737 if (data == null) return null;
Jeff Sharkeye76c4262019-12-06 14:46:00 -0700738 if (data.indexOf('/') == -1) {
739 return data;
740 }
Jeff Sharkeye152d5762019-10-11 17:14:51 -0600741 if (data.endsWith("/")) {
742 data = data.substring(0, data.length() - 1);
743 }
744 return data.substring(data.lastIndexOf('/') + 1);
745 }
746
747 public static @Nullable String extractFileName(@Nullable String data) {
748 if (data == null) return null;
749 data = extractDisplayName(data);
750
751 final int lastDot = data.lastIndexOf('.');
752 if (lastDot == -1) {
753 return data;
754 } else {
755 return data.substring(0, lastDot);
756 }
757 }
758
759 public static @Nullable String extractFileExtension(@Nullable String data) {
760 if (data == null) return null;
761 data = extractDisplayName(data);
762
763 final int lastDot = data.lastIndexOf('.');
764 if (lastDot == -1) {
765 return null;
766 } else {
767 return data.substring(lastDot + 1);
768 }
769 }
Jeff Sharkeyc5c39142019-12-15 22:46:03 -0700770
771 /**
Jeff Sharkeyc55994b2019-12-20 19:43:59 -0700772 * Return list of paths that should be scanned with
773 * {@link com.android.providers.media.scan.MediaScanner} for the given
774 * volume name.
Jeff Sharkeyc5c39142019-12-15 22:46:03 -0700775 */
776 public static @NonNull Collection<File> getVolumeScanPaths(@NonNull Context context,
777 @NonNull String volumeName) throws FileNotFoundException {
778 final ArrayList<File> res = new ArrayList<>();
779 switch (volumeName) {
780 case MediaStore.VOLUME_INTERNAL: {
781 res.addAll(Environment.getInternalMediaDirectories());
782 break;
783 }
784 case MediaStore.VOLUME_EXTERNAL: {
785 for (String resolvedVolumeName : MediaStore.getExternalVolumeNames(context)) {
786 res.add(getVolumePath(context, resolvedVolumeName));
787 }
788 break;
789 }
790 default: {
791 res.add(getVolumePath(context, volumeName));
792 }
793 }
794 return res;
795 }
796
797 /**
798 * Return path where the given volume name is mounted.
799 */
800 public static @NonNull File getVolumePath(@NonNull Context context,
801 @NonNull String volumeName) throws FileNotFoundException {
802 switch (volumeName) {
803 case MediaStore.VOLUME_INTERNAL:
804 case MediaStore.VOLUME_EXTERNAL:
805 throw new FileNotFoundException(volumeName + " has no associated path");
806 }
807
808 final Uri uri = MediaStore.Files.getContentUri(volumeName);
809 return context.getSystemService(StorageManager.class).getStorageVolume(uri)
810 .getDirectory();
811 }
812
813 /**
814 * Return volume name which hosts the given path.
815 */
816 public static @NonNull String getVolumeName(@NonNull Context context, @NonNull File path) {
817 if (contains(Environment.getStorageDirectory(), path)) {
818 return context.getSystemService(StorageManager.class).getStorageVolume(path)
819 .getMediaStoreVolumeName();
820 } else {
821 return MediaStore.VOLUME_INTERNAL;
822 }
823 }
Jeff Sharkey5ea5c282019-12-18 14:06:28 -0700824
825 public static final Pattern PATTERN_DOWNLOADS_FILE = Pattern.compile(
826 "(?i)^/storage/[^/]+/(?:[0-9]+/)?(?:Android/sandbox/[^/]+/)?Download/.+");
827 public static final Pattern PATTERN_DOWNLOADS_DIRECTORY = Pattern.compile(
828 "(?i)^/storage/[^/]+/(?:[0-9]+/)?(?:Android/sandbox/[^/]+/)?Download/?");
Jeff Sharkey89149b62020-03-29 22:03:44 -0600829 public static final Pattern PATTERN_EXPIRES_FILE = Pattern.compile(
830 "(?i)^\\.(pending|trashed)-(\\d+)-(.+)$");
831
832 /**
833 * File prefix indicating that the file {@link MediaColumns#IS_PENDING}.
834 */
835 public static final String PREFIX_PENDING = "pending";
836
837 /**
838 * File prefix indicating that the file {@link MediaColumns#IS_TRASHED}.
839 */
840 public static final String PREFIX_TRASHED = "trashed";
841
842 /**
843 * Default duration that {@link MediaColumns#IS_PENDING} items should be
844 * preserved for until automatically cleaned by {@link #runIdleMaintenance}.
845 */
Jeff Sharkey05c3a032020-04-09 16:57:04 -0600846 public static final long DEFAULT_DURATION_PENDING = 7 * DateUtils.DAY_IN_MILLIS;
Jeff Sharkey89149b62020-03-29 22:03:44 -0600847
848 /**
849 * Default duration that {@link MediaColumns#IS_TRASHED} items should be
850 * preserved for until automatically cleaned by {@link #runIdleMaintenance}.
851 */
Jeff Sharkey05c3a032020-04-09 16:57:04 -0600852 public static final long DEFAULT_DURATION_TRASHED = 30 * DateUtils.DAY_IN_MILLIS;
Jeff Sharkey5ea5c282019-12-18 14:06:28 -0700853
854 public static boolean isDownload(@NonNull String path) {
855 return PATTERN_DOWNLOADS_FILE.matcher(path).matches();
856 }
857
858 public static boolean isDownloadDir(@NonNull String path) {
859 return PATTERN_DOWNLOADS_DIRECTORY.matcher(path).matches();
860 }
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -0700861
862 /**
863 * Regex that matches any valid path in external storage,
864 * and captures the top-level directory as the first group.
865 */
866 private static final Pattern PATTERN_TOP_LEVEL_DIR = Pattern.compile(
867 "(?i)^/storage/[^/]+/[0-9]+/([^/]+)(/.*)?");
868 /**
869 * Regex that matches paths in all well-known package-specific directories,
870 * and which captures the package name as the first group.
871 */
872 public static final Pattern PATTERN_OWNED_PATH = Pattern.compile(
Ricky Waif40c4022020-04-15 19:00:06 +0100873 "(?i)^/storage/[^/]+/(?:[0-9]+/)?Android/(?:data|media|obb|sandbox)/([^/]+)(/?.*)?");
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -0700874
875 /**
Ricky Waifeb9d9b2020-04-06 19:14:46 +0100876 * Regex that matches Android/obb or Android/data path.
877 */
878 public static final Pattern PATTERN_DATA_OR_OBB_PATH = Pattern.compile(
879 "(?i)^/storage/[^/]+/(?:[0-9]+/)?Android/(?:data|obb)/?$");
880
881 /**
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -0700882 * Regex that matches paths for {@link MediaColumns#RELATIVE_PATH}; it
883 * captures both top-level paths and sandboxed paths.
884 */
885 private static final Pattern PATTERN_RELATIVE_PATH = Pattern.compile(
nolyn.luc92835c2019-08-26 13:58:05 +0800886 "(?i)^/storage/(?:emulated/[0-9]+/|[^/]+/)(Android/sandbox/([^/]+)/)?");
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -0700887
888 /**
889 * Regex that matches paths under well-known storage paths.
890 */
891 private static final Pattern PATTERN_VOLUME_NAME = Pattern.compile(
892 "(?i)^/storage/([^/]+)");
893
894 private static @Nullable String normalizeUuid(@Nullable String fsUuid) {
895 return fsUuid != null ? fsUuid.toLowerCase(Locale.US) : null;
896 }
897
Jeff Sharkey89149b62020-03-29 22:03:44 -0600898 public static @Nullable String extractVolumePath(@Nullable String data) {
899 if (data == null) return null;
900 final Matcher matcher = PATTERN_RELATIVE_PATH.matcher(data);
901 if (matcher.find()) {
902 return data.substring(0, matcher.end());
903 } else {
904 return null;
905 }
906 }
907
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -0700908 public static @Nullable String extractVolumeName(@Nullable String data) {
909 if (data == null) return null;
910 final Matcher matcher = PATTERN_VOLUME_NAME.matcher(data);
911 if (matcher.find()) {
912 final String volumeName = matcher.group(1);
913 if (volumeName.equals("emulated")) {
914 return MediaStore.VOLUME_EXTERNAL_PRIMARY;
915 } else {
916 return normalizeUuid(volumeName);
917 }
918 } else {
919 return MediaStore.VOLUME_INTERNAL;
920 }
921 }
922
923 public static @Nullable String extractRelativePath(@Nullable String data) {
924 if (data == null) return null;
925 final Matcher matcher = PATTERN_RELATIVE_PATH.matcher(data);
926 if (matcher.find()) {
927 final int lastSlash = data.lastIndexOf('/');
928 if (lastSlash == -1 || lastSlash < matcher.end()) {
929 // This is a file in the top-level directory, so relative path is "/"
930 // which is different than null, which means unknown path
931 return "/";
932 } else {
933 return data.substring(matcher.end(), lastSlash + 1);
934 }
935 } else {
936 return null;
937 }
938 }
939
940 /**
941 * Returns relative path for the directory.
942 */
943 @VisibleForTesting
944 public static @Nullable String extractRelativePathForDirectory(@Nullable String directoryPath) {
945 if (directoryPath == null) return null;
946 final Matcher matcher = PATTERN_RELATIVE_PATH.matcher(directoryPath);
947 if (matcher.find()) {
948 if (matcher.end() == directoryPath.length() - 1) {
949 // This is the top-level directory, so relative path is "/"
950 return "/";
951 }
952 return directoryPath.substring(matcher.end()) + "/";
953 }
954 return null;
955 }
956
957 public static @Nullable String extractPathOwnerPackageName(@Nullable String path) {
958 if (path == null) return null;
959 final Matcher m = PATTERN_OWNED_PATH.matcher(path);
960 if (m.matches()) {
961 return m.group(1);
962 } else {
963 return null;
964 }
965 }
966
967 /**
Ricky Waifeb9d9b2020-04-06 19:14:46 +0100968 * Returns true if relative path is Android/data or Android/obb path.
969 */
970 public static boolean isDataOrObbPath(String path) {
971 if (path == null) return false;
972 final Matcher m = PATTERN_DATA_OR_OBB_PATH.matcher(path);
973 return m.matches();
974 }
975
976 /**
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -0700977 * Returns the name of the top level directory, or null if the path doesn't go through the
978 * external storage directory.
979 */
980 @Nullable
981 public static String extractTopLevelDir(String path) {
982 Matcher m = PATTERN_TOP_LEVEL_DIR.matcher(path);
983 if (m.matches()) {
984 return m.group(1);
985 }
986 return null;
987 }
Jeff Sharkeyc55994b2019-12-20 19:43:59 -0700988
Jeff Sharkey89149b62020-03-29 22:03:44 -0600989 /**
Jeff Sharkey05c3a032020-04-09 16:57:04 -0600990 * Compute the value of {@link MediaColumns#DATE_EXPIRES} based on other
991 * columns being modified by this operation.
992 */
993 public static void computeDateExpires(@NonNull ContentValues values) {
994 // External apps have no ability to change this field
995 values.remove(MediaColumns.DATE_EXPIRES);
996
997 // Only define the field when this modification is actually adjusting
998 // one of the flags that should influence the expiration
Jeff Sharkeya9473e92020-04-17 15:54:30 -0600999 final Object pending = values.get(MediaColumns.IS_PENDING);
Jeff Sharkey05c3a032020-04-09 16:57:04 -06001000 if (pending != null) {
Jeff Sharkeya9473e92020-04-17 15:54:30 -06001001 if (parseBoolean(pending, false)) {
Jeff Sharkey05c3a032020-04-09 16:57:04 -06001002 values.put(MediaColumns.DATE_EXPIRES,
1003 (System.currentTimeMillis() + DEFAULT_DURATION_PENDING) / 1000);
1004 } else {
1005 values.putNull(MediaColumns.DATE_EXPIRES);
1006 }
1007 }
Jeff Sharkeya9473e92020-04-17 15:54:30 -06001008 final Object trashed = values.get(MediaColumns.IS_TRASHED);
Jeff Sharkey05c3a032020-04-09 16:57:04 -06001009 if (trashed != null) {
Jeff Sharkeya9473e92020-04-17 15:54:30 -06001010 if (parseBoolean(trashed, false)) {
Jeff Sharkey05c3a032020-04-09 16:57:04 -06001011 values.put(MediaColumns.DATE_EXPIRES,
1012 (System.currentTimeMillis() + DEFAULT_DURATION_TRASHED) / 1000);
1013 } else {
1014 values.putNull(MediaColumns.DATE_EXPIRES);
1015 }
1016 }
1017 }
1018
1019 /**
Jeff Sharkey89149b62020-03-29 22:03:44 -06001020 * Compute several scattered {@link MediaColumns} values from
1021 * {@link MediaColumns#DATA}. This method performs no enforcement of
1022 * argument validity.
1023 */
1024 public static void computeValuesFromData(@NonNull ContentValues values) {
Jeff Sharkeyc55994b2019-12-20 19:43:59 -07001025 // Worst case we have to assume no bucket details
Jeff Sharkey89149b62020-03-29 22:03:44 -06001026 values.remove(MediaColumns.VOLUME_NAME);
1027 values.remove(MediaColumns.RELATIVE_PATH);
Jeff Sharkey89149b62020-03-29 22:03:44 -06001028 values.remove(MediaColumns.IS_PENDING);
1029 values.remove(MediaColumns.IS_TRASHED);
1030 values.remove(MediaColumns.DATE_EXPIRES);
1031 values.remove(MediaColumns.DISPLAY_NAME);
1032 values.remove(MediaColumns.BUCKET_ID);
1033 values.remove(MediaColumns.BUCKET_DISPLAY_NAME);
Jeff Sharkeyc55994b2019-12-20 19:43:59 -07001034
1035 final String data = values.getAsString(MediaColumns.DATA);
1036 if (TextUtils.isEmpty(data)) return;
1037
1038 final File file = new File(data);
1039 final File fileLower = new File(data.toLowerCase(Locale.ROOT));
1040
Jeff Sharkey89149b62020-03-29 22:03:44 -06001041 values.put(MediaColumns.VOLUME_NAME, extractVolumeName(data));
1042 values.put(MediaColumns.RELATIVE_PATH, extractRelativePath(data));
Jeff Sharkey89149b62020-03-29 22:03:44 -06001043 final String displayName = extractDisplayName(data);
1044 final Matcher matcher = FileUtils.PATTERN_EXPIRES_FILE.matcher(displayName);
1045 if (matcher.matches()) {
1046 values.put(MediaColumns.IS_PENDING,
1047 matcher.group(1).equals(FileUtils.PREFIX_PENDING) ? 1 : 0);
1048 values.put(MediaColumns.IS_TRASHED,
1049 matcher.group(1).equals(FileUtils.PREFIX_TRASHED) ? 1 : 0);
1050 values.put(MediaColumns.DATE_EXPIRES, Long.parseLong(matcher.group(2)));
1051 values.put(MediaColumns.DISPLAY_NAME, matcher.group(3));
1052 } else {
1053 values.put(MediaColumns.IS_PENDING, 0);
1054 values.put(MediaColumns.IS_TRASHED, 0);
1055 values.putNull(MediaColumns.DATE_EXPIRES);
1056 values.put(MediaColumns.DISPLAY_NAME, displayName);
1057 }
Jeff Sharkeyc55994b2019-12-20 19:43:59 -07001058
1059 // Buckets are the parent directory
1060 final String parent = fileLower.getParent();
1061 if (parent != null) {
Jeff Sharkey89149b62020-03-29 22:03:44 -06001062 values.put(MediaColumns.BUCKET_ID, parent.hashCode());
Jeff Sharkeyc55994b2019-12-20 19:43:59 -07001063 // The relative path for files in the top directory is "/"
Jeff Sharkey89149b62020-03-29 22:03:44 -06001064 if (!"/".equals(values.getAsString(MediaColumns.RELATIVE_PATH))) {
1065 values.put(MediaColumns.BUCKET_DISPLAY_NAME, file.getParentFile().getName());
Jeff Sharkeyc55994b2019-12-20 19:43:59 -07001066 }
1067 }
1068 }
Sahana Raof21671d2020-03-09 16:49:26 +00001069
Jeff Sharkey89149b62020-03-29 22:03:44 -06001070 /**
1071 * Compute {@link MediaColumns#DATA} from several scattered
1072 * {@link MediaColumns} values. This method performs no enforcement of
1073 * argument validity.
1074 */
1075 public static void computeDataFromValues(@NonNull ContentValues values,
1076 @NonNull File volumePath) {
1077 values.remove(MediaColumns.DATA);
1078
1079 final String displayName = values.getAsString(MediaColumns.DISPLAY_NAME);
1080 final String resolvedDisplayName;
1081 if (getAsBoolean(values, MediaColumns.IS_PENDING, false)) {
1082 final long dateExpires = getAsLong(values, MediaColumns.DATE_EXPIRES,
1083 (System.currentTimeMillis() + DEFAULT_DURATION_PENDING) / 1000);
1084 resolvedDisplayName = String.format(".%s-%d-%s",
1085 FileUtils.PREFIX_PENDING, dateExpires, displayName);
1086 } else if (getAsBoolean(values, MediaColumns.IS_TRASHED, false)) {
1087 final long dateExpires = getAsLong(values, MediaColumns.DATE_EXPIRES,
1088 (System.currentTimeMillis() + DEFAULT_DURATION_TRASHED) / 1000);
1089 resolvedDisplayName = String.format(".%s-%d-%s",
1090 FileUtils.PREFIX_TRASHED, dateExpires, displayName);
1091 } else {
1092 resolvedDisplayName = displayName;
1093 }
1094
1095 final File filePath = buildPath(volumePath,
1096 values.getAsString(MediaColumns.RELATIVE_PATH), resolvedDisplayName);
1097 values.put(MediaColumns.DATA, filePath.getAbsolutePath());
1098 }
1099
1100 public static void sanitizeValues(@NonNull ContentValues values) {
1101 final String[] relativePath = values.getAsString(MediaColumns.RELATIVE_PATH).split("/");
1102 for (int i = 0; i < relativePath.length; i++) {
1103 relativePath[i] = sanitizeDisplayName(relativePath[i]);
1104 }
1105 values.put(MediaColumns.RELATIVE_PATH,
1106 String.join("/", relativePath) + "/");
1107 values.put(MediaColumns.DISPLAY_NAME,
1108 sanitizeDisplayName(values.getAsString(MediaColumns.DISPLAY_NAME)));
1109 }
1110
Sahana Raof21671d2020-03-09 16:49:26 +00001111 /** {@hide} **/
1112 @Nullable
1113 public static String getAbsoluteSanitizedPath(String path) {
1114 final String[] pathSegments = sanitizePath(path);
1115 if (pathSegments.length == 0) {
1116 return null;
1117 }
1118 return path = "/" + String.join("/",
1119 Arrays.copyOfRange(pathSegments, 1, pathSegments.length));
1120 }
1121
1122 /** {@hide} */
1123 public static @NonNull String[] sanitizePath(@Nullable String path) {
1124 if (path == null) {
1125 return new String[0];
1126 } else {
1127 final String[] segments = path.split("/");
1128 // If the path corresponds to the top level directory, then we return an empty path
1129 // which denotes the top level directory
1130 if (segments.length == 0) {
1131 return new String[] { "" };
1132 }
1133 for (int i = 0; i < segments.length; i++) {
1134 segments[i] = sanitizeDisplayName(segments[i]);
1135 }
1136 return segments;
1137 }
1138 }
1139
1140 /**
1141 * Sanitizes given name by appending '_' to make it non-hidden and mutating the file
1142 * name to make it valid for a FAT filesystem.
1143 * @hide
1144 */
1145 public static @Nullable String sanitizeDisplayName(@Nullable String name) {
1146 if (name == null) {
1147 return null;
1148 } else if (name.startsWith(".")) {
1149 // The resulting file must not be hidden.
1150 return buildValidFatFilename("_" + name);
1151 } else {
1152 return buildValidFatFilename(name);
1153 }
1154 }
Jeff Sharkey7ea24f22019-08-22 10:14:18 -06001155}