blob: e5c62d160f545eb925b57b879b6e8138e36ac1d8 [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;
Martijn Coenenb5d6dde2021-04-01 16:17:41 +020048import android.content.pm.PackageManager;
Jeff Sharkeyc5c39142019-12-15 22:46:03 -070049import android.net.Uri;
50import android.os.Environment;
Jeff Sharkey9a497642020-04-23 13:15:10 -060051import android.os.ParcelFileDescriptor;
Hyoungho Choidb8f9382021-07-22 20:37:58 +090052import android.os.SystemProperties;
Dipankar Bhardwajcf7037d2022-06-07 07:37:18 +000053import android.os.UserHandle;
Jeff Sharkeyc5c39142019-12-15 22:46:03 -070054import android.os.storage.StorageManager;
Martijn Coenen2bf49fa2020-11-02 11:46:32 +010055import android.os.storage.StorageVolume;
Jeff Sharkeyc5c39142019-12-15 22:46:03 -070056import android.provider.MediaStore;
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -070057import android.provider.MediaStore.MediaColumns;
Jeff Sharkey9a497642020-04-23 13:15:10 -060058import android.system.ErrnoException;
59import android.system.Os;
60import android.system.OsConstants;
Jeff Sharkey7ea24f22019-08-22 10:14:18 -060061import android.text.TextUtils;
Jeff Sharkey89149b62020-03-29 22:03:44 -060062import android.text.format.DateUtils;
Jeff Sharkey7ea24f22019-08-22 10:14:18 -060063import android.util.Log;
64import android.webkit.MimeTypeMap;
65
66import androidx.annotation.NonNull;
67import androidx.annotation.Nullable;
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -070068import androidx.annotation.VisibleForTesting;
Jeff Sharkey7ea24f22019-08-22 10:14:18 -060069
Ivan Chiang7fce4a52021-01-25 16:53:54 +080070import com.android.modules.utils.build.SdkLevel;
71
Jeff Sharkey7ea24f22019-08-22 10:14:18 -060072import java.io.File;
Jeff Sharkey9a497642020-04-23 13:15:10 -060073import java.io.FileDescriptor;
Jeff Sharkey7ea24f22019-08-22 10:14:18 -060074import java.io.FileNotFoundException;
75import java.io.IOException;
76import java.io.InputStream;
77import java.io.OutputStream;
78import java.nio.charset.StandardCharsets;
Jeff Sharkeybb4e5e62020-02-09 17:14:08 -070079import java.nio.file.FileVisitResult;
80import java.nio.file.FileVisitor;
81import java.nio.file.Files;
82import java.nio.file.NoSuchFileException;
83import java.nio.file.Path;
Jeff Sharkeybb4e5e62020-02-09 17:14:08 -070084import java.nio.file.attribute.BasicFileAttributes;
Jeff Sharkeyc5c39142019-12-15 22:46:03 -070085import java.util.ArrayList;
Jeff Sharkey5278ead2020-01-07 16:40:18 -070086import java.util.Arrays;
Jeff Sharkey7ea24f22019-08-22 10:14:18 -060087import java.util.Collection;
Jeff Sharkey5278ead2020-01-07 16:40:18 -070088import java.util.Comparator;
Jeff Sharkey06b38ca2019-12-17 15:51:12 -070089import java.util.Iterator;
Jeff Sharkey470b97e2019-10-15 16:32:04 -060090import java.util.Locale;
Jeff Sharkey7ea24f22019-08-22 10:14:18 -060091import java.util.Objects;
Jeff Sharkeybb4e5e62020-02-09 17:14:08 -070092import java.util.Optional;
93import java.util.function.Consumer;
Jeff Sharkey06b38ca2019-12-17 15:51:12 -070094import java.util.regex.Matcher;
95import java.util.regex.Pattern;
Jeff Sharkey7ea24f22019-08-22 10:14:18 -060096
97public class FileUtils {
Ivan Chiang9aa80952020-12-04 13:11:53 +080098 // Even though vfat allows 255 UCS-2 chars, we might eventually write to
99 // ext4 through a FUSE layer, so use that limit.
100 @VisibleForTesting
101 static final int MAX_FILENAME_BYTES = 255;
102
Jeff Sharkey9a497642020-04-23 13:15:10 -0600103 /**
104 * Drop-in replacement for {@link ParcelFileDescriptor#open(File, int)}
105 * which adds security features like {@link OsConstants#O_CLOEXEC} and
106 * {@link OsConstants#O_NOFOLLOW}.
107 */
108 public static @NonNull ParcelFileDescriptor openSafely(@NonNull File file, int pfdFlags)
109 throws FileNotFoundException {
110 final int posixFlags = translateModePfdToPosix(pfdFlags) | O_CLOEXEC | O_NOFOLLOW;
111 try {
112 final FileDescriptor fd = Os.open(file.getAbsolutePath(), posixFlags,
113 S_IRWXU | S_IRWXG);
114 try {
115 return ParcelFileDescriptor.dup(fd);
116 } finally {
117 closeQuietly(fd);
118 }
119 } catch (IOException | ErrnoException e) {
120 throw new FileNotFoundException(e.getMessage());
121 }
122 }
123
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600124 public static void closeQuietly(@Nullable AutoCloseable closeable) {
125 android.os.FileUtils.closeQuietly(closeable);
126 }
127
Jeff Sharkey9a497642020-04-23 13:15:10 -0600128 public static void closeQuietly(@Nullable FileDescriptor fd) {
129 if (fd == null) return;
130 try {
131 Os.close(fd);
132 } catch (ErrnoException ignored) {
133 }
134 }
135
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600136 public static long copy(@NonNull InputStream in, @NonNull OutputStream out) throws IOException {
137 return android.os.FileUtils.copy(in, out);
138 }
139
140 public static File buildPath(File base, String... segments) {
141 File cur = base;
142 for (String segment : segments) {
143 if (cur == null) {
144 cur = new File(segment);
145 } else {
146 cur = new File(cur, segment);
147 }
148 }
149 return cur;
150 }
151
152 /**
Jeff Sharkey5278ead2020-01-07 16:40:18 -0700153 * Delete older files in a directory until only those matching the given
154 * constraints remain.
155 *
156 * @param minCount Always keep at least this many files.
157 * @param minAgeMs Always keep files younger than this age, in milliseconds.
158 * @return if any files were deleted.
159 */
160 public static boolean deleteOlderFiles(File dir, int minCount, long minAgeMs) {
161 if (minCount < 0 || minAgeMs < 0) {
162 throw new IllegalArgumentException("Constraints must be positive or 0");
163 }
164
165 final File[] files = dir.listFiles();
166 if (files == null) return false;
167
168 // Sort with newest files first
169 Arrays.sort(files, new Comparator<File>() {
170 @Override
171 public int compare(File lhs, File rhs) {
172 return Long.compare(rhs.lastModified(), lhs.lastModified());
173 }
174 });
175
176 // Keep at least minCount files
177 boolean deleted = false;
178 for (int i = minCount; i < files.length; i++) {
179 final File file = files[i];
180
181 // Keep files newer than minAgeMs
182 final long age = System.currentTimeMillis() - file.lastModified();
183 if (age > minAgeMs) {
184 if (file.delete()) {
185 Log.d(TAG, "Deleted old file " + file);
186 deleted = true;
187 }
188 }
189 }
190 return deleted;
191 }
192
193 /**
Jeff Sharkeyf06febd2020-04-07 13:03:30 -0600194 * Shamelessly borrowed from {@code android.os.FileUtils}.
195 */
196 public static int translateModeStringToPosix(String mode) {
197 // Sanity check for invalid chars
198 for (int i = 0; i < mode.length(); i++) {
199 switch (mode.charAt(i)) {
200 case 'r':
201 case 'w':
202 case 't':
203 case 'a':
204 break;
205 default:
206 throw new IllegalArgumentException("Bad mode: " + mode);
207 }
208 }
209
210 int res = 0;
211 if (mode.startsWith("rw")) {
212 res = O_RDWR | O_CREAT;
213 } else if (mode.startsWith("w")) {
214 res = O_WRONLY | O_CREAT;
215 } else if (mode.startsWith("r")) {
216 res = O_RDONLY;
217 } else {
218 throw new IllegalArgumentException("Bad mode: " + mode);
219 }
220 if (mode.indexOf('t') != -1) {
221 res |= O_TRUNC;
222 }
223 if (mode.indexOf('a') != -1) {
224 res |= O_APPEND;
225 }
226 return res;
227 }
228
229 /**
230 * Shamelessly borrowed from {@code android.os.FileUtils}.
231 */
232 public static String translateModePosixToString(int mode) {
233 String res = "";
234 if ((mode & O_ACCMODE) == O_RDWR) {
235 res = "rw";
236 } else if ((mode & O_ACCMODE) == O_WRONLY) {
237 res = "w";
238 } else if ((mode & O_ACCMODE) == O_RDONLY) {
239 res = "r";
240 } else {
241 throw new IllegalArgumentException("Bad mode: " + mode);
242 }
243 if ((mode & O_TRUNC) == O_TRUNC) {
244 res += "t";
245 }
246 if ((mode & O_APPEND) == O_APPEND) {
247 res += "a";
248 }
249 return res;
250 }
251
252 /**
253 * Shamelessly borrowed from {@code android.os.FileUtils}.
254 */
255 public static int translateModePosixToPfd(int mode) {
256 int res = 0;
257 if ((mode & O_ACCMODE) == O_RDWR) {
258 res = MODE_READ_WRITE;
259 } else if ((mode & O_ACCMODE) == O_WRONLY) {
260 res = MODE_WRITE_ONLY;
261 } else if ((mode & O_ACCMODE) == O_RDONLY) {
262 res = MODE_READ_ONLY;
263 } else {
264 throw new IllegalArgumentException("Bad mode: " + mode);
265 }
266 if ((mode & O_CREAT) == O_CREAT) {
267 res |= MODE_CREATE;
268 }
269 if ((mode & O_TRUNC) == O_TRUNC) {
270 res |= MODE_TRUNCATE;
271 }
272 if ((mode & O_APPEND) == O_APPEND) {
273 res |= MODE_APPEND;
274 }
275 return res;
276 }
277
278 /**
279 * Shamelessly borrowed from {@code android.os.FileUtils}.
280 */
281 public static int translateModePfdToPosix(int mode) {
282 int res = 0;
283 if ((mode & MODE_READ_WRITE) == MODE_READ_WRITE) {
284 res = O_RDWR;
285 } else if ((mode & MODE_WRITE_ONLY) == MODE_WRITE_ONLY) {
286 res = O_WRONLY;
287 } else if ((mode & MODE_READ_ONLY) == MODE_READ_ONLY) {
288 res = O_RDONLY;
289 } else {
290 throw new IllegalArgumentException("Bad mode: " + mode);
291 }
292 if ((mode & MODE_CREATE) == MODE_CREATE) {
293 res |= O_CREAT;
294 }
295 if ((mode & MODE_TRUNCATE) == MODE_TRUNCATE) {
296 res |= O_TRUNC;
297 }
298 if ((mode & MODE_APPEND) == MODE_APPEND) {
299 res |= O_APPEND;
300 }
301 return res;
302 }
303
304 /**
305 * Shamelessly borrowed from {@code android.os.FileUtils}.
306 */
307 public static int translateModeAccessToPosix(int mode) {
308 if (mode == F_OK) {
309 // There's not an exact mapping, so we attempt a read-only open to
310 // determine if a file exists
311 return O_RDONLY;
312 } else if ((mode & (R_OK | W_OK)) == (R_OK | W_OK)) {
313 return O_RDWR;
314 } else if ((mode & R_OK) == R_OK) {
315 return O_RDONLY;
316 } else if ((mode & W_OK) == W_OK) {
317 return O_WRONLY;
318 } else {
319 throw new IllegalArgumentException("Bad mode: " + mode);
320 }
321 }
322
323 /**
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600324 * Test if a file lives under the given directory, either as a direct child
325 * or a distant grandchild.
326 * <p>
327 * Both files <em>must</em> have been resolved using
328 * {@link File#getCanonicalFile()} to avoid symlink or path traversal
329 * attacks.
330 *
331 * @hide
332 */
333 public static boolean contains(File[] dirs, File file) {
334 for (File dir : dirs) {
335 if (contains(dir, file)) {
336 return true;
337 }
338 }
339 return false;
340 }
341
342 /** {@hide} */
343 public static boolean contains(Collection<File> dirs, File file) {
344 for (File dir : dirs) {
345 if (contains(dir, file)) {
346 return true;
347 }
348 }
349 return false;
350 }
351
352 /**
353 * Test if a file lives under the given directory, either as a direct child
354 * or a distant grandchild.
355 * <p>
356 * Both files <em>must</em> have been resolved using
357 * {@link File#getCanonicalFile()} to avoid symlink or path traversal
358 * attacks.
359 *
360 * @hide
361 */
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600362 public static boolean contains(File dir, File file) {
363 if (dir == null || file == null) return false;
364 return contains(dir.getAbsolutePath(), file.getAbsolutePath());
365 }
366
367 /**
368 * Test if a file lives under the given directory, either as a direct child
369 * or a distant grandchild.
370 * <p>
371 * Both files <em>must</em> have been resolved using
372 * {@link File#getCanonicalFile()} to avoid symlink or path traversal
373 * attacks.
374 *
375 * @hide
376 */
377 public static boolean contains(String dirPath, String filePath) {
378 if (dirPath.equals(filePath)) {
379 return true;
380 }
381 if (!dirPath.endsWith("/")) {
382 dirPath += "/";
383 }
384 return filePath.startsWith(dirPath);
385 }
386
Jeff Sharkeybb4e5e62020-02-09 17:14:08 -0700387 /**
388 * Write {@link String} to the given {@link File}. Deletes any existing file
389 * when the argument is {@link Optional#empty()}.
390 */
391 public static void writeString(@NonNull File file, @NonNull Optional<String> value)
392 throws IOException {
393 if (value.isPresent()) {
394 Files.write(file.toPath(), value.get().getBytes(StandardCharsets.UTF_8));
395 } else {
396 file.delete();
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600397 }
Jeff Sharkeybb4e5e62020-02-09 17:14:08 -0700398 }
399
Sahana Raoe64f5bb2020-12-01 12:35:49 +0000400 private static final int MAX_READ_STRING_SIZE = 4096;
401
Jeff Sharkeybb4e5e62020-02-09 17:14:08 -0700402 /**
403 * Read given {@link File} as a single {@link String}. Returns
Sahana Raoe64f5bb2020-12-01 12:35:49 +0000404 * {@link Optional#empty()} when
405 * <ul>
406 * <li> the file doesn't exist or
407 * <li> the size of the file exceeds {@code MAX_READ_STRING_SIZE}
408 * </ul>
Jeff Sharkeybb4e5e62020-02-09 17:14:08 -0700409 */
410 public static @NonNull Optional<String> readString(@NonNull File file) throws IOException {
411 try {
Sahana Raoe64f5bb2020-12-01 12:35:49 +0000412 if (file.length() <= MAX_READ_STRING_SIZE) {
413 final String value = new String(Files.readAllBytes(file.toPath()),
414 StandardCharsets.UTF_8);
415 return Optional.of(value);
416 }
417 // When file size exceeds MAX_READ_STRING_SIZE, file is either
418 // corrupted or doesn't the contain expected data. Hence we return
419 // Optional.empty() which will be interpreted as empty file.
420 Logging.logPersistent(String.format("Ignored reading %s, file size exceeds %d", file,
421 MAX_READ_STRING_SIZE));
422 } catch (NoSuchFileException ignored) {
Jeff Sharkeybb4e5e62020-02-09 17:14:08 -0700423 }
Sahana Raoe64f5bb2020-12-01 12:35:49 +0000424 return Optional.empty();
Jeff Sharkeybb4e5e62020-02-09 17:14:08 -0700425 }
426
427 /**
428 * Recursively walk the contents of the given {@link Path}, invoking the
429 * given {@link Consumer} for every file and directory encountered. This is
430 * typically used for recursively deleting a directory tree.
431 * <p>
432 * Gracefully attempts to process as much as possible in the face of any
433 * failures.
434 */
435 public static void walkFileTreeContents(@NonNull Path path, @NonNull Consumer<Path> operation) {
436 try {
437 Files.walkFileTree(path, new FileVisitor<Path>() {
438 @Override
439 public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
440 return FileVisitResult.CONTINUE;
441 }
442
443 @Override
444 public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
445 if (!Objects.equals(path, file)) {
446 operation.accept(file);
447 }
448 return FileVisitResult.CONTINUE;
449 }
450
451 @Override
452 public FileVisitResult visitFileFailed(Path file, IOException e) {
453 Log.w(TAG, "Failed to visit " + file, e);
454 return FileVisitResult.CONTINUE;
455 }
456
457 @Override
458 public FileVisitResult postVisitDirectory(Path dir, IOException e) {
459 if (!Objects.equals(path, dir)) {
460 operation.accept(dir);
461 }
462 return FileVisitResult.CONTINUE;
463 }
464 });
465 } catch (IOException e) {
466 Log.w(TAG, "Failed to walk " + path, e);
467 }
468 }
469
470 /**
471 * Recursively delete all contents inside the given directory. Gracefully
472 * attempts to delete as much as possible in the face of any failures.
473 *
Jeff Sharkey89149b62020-03-29 22:03:44 -0600474 * @deprecated if you're calling this from inside {@code MediaProvider}, you
Jeff Sharkeybb4e5e62020-02-09 17:14:08 -0700475 * likely want to call {@link #forEach} with a separate
476 * invocation to invalidate FUSE entries.
477 */
478 @Deprecated
479 public static void deleteContents(@NonNull File dir) {
480 walkFileTreeContents(dir.toPath(), (path) -> {
481 path.toFile().delete();
482 });
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600483 }
484
485 private static boolean isValidFatFilenameChar(char c) {
486 if ((0x00 <= c && c <= 0x1f)) {
487 return false;
488 }
489 switch (c) {
490 case '"':
491 case '*':
492 case '/':
493 case ':':
494 case '<':
495 case '>':
496 case '?':
497 case '\\':
498 case '|':
499 case 0x7F:
500 return false;
501 default:
502 return true;
503 }
504 }
505
506 /**
507 * Check if given filename is valid for a FAT filesystem.
508 *
509 * @hide
510 */
511 public static boolean isValidFatFilename(String name) {
512 return (name != null) && name.equals(buildValidFatFilename(name));
513 }
514
515 /**
516 * Mutate the given filename to make it valid for a FAT filesystem,
517 * replacing any invalid characters with "_".
518 *
519 * @hide
520 */
521 public static String buildValidFatFilename(String name) {
522 if (TextUtils.isEmpty(name) || ".".equals(name) || "..".equals(name)) {
523 return "(invalid)";
524 }
525 final StringBuilder res = new StringBuilder(name.length());
526 for (int i = 0; i < name.length(); i++) {
527 final char c = name.charAt(i);
528 if (isValidFatFilenameChar(c)) {
529 res.append(c);
530 } else {
531 res.append('_');
532 }
533 }
Ivan Chiang9aa80952020-12-04 13:11:53 +0800534
535 trimFilename(res, MAX_FILENAME_BYTES);
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600536 return res.toString();
537 }
538
539 /** {@hide} */
540 // @VisibleForTesting
541 public static String trimFilename(String str, int maxBytes) {
542 final StringBuilder res = new StringBuilder(str);
543 trimFilename(res, maxBytes);
544 return res.toString();
545 }
546
547 /** {@hide} */
548 private static void trimFilename(StringBuilder res, int maxBytes) {
549 byte[] raw = res.toString().getBytes(StandardCharsets.UTF_8);
550 if (raw.length > maxBytes) {
551 maxBytes -= 3;
552 while (raw.length > maxBytes) {
553 res.deleteCharAt(res.length() / 2);
554 raw = res.toString().getBytes(StandardCharsets.UTF_8);
555 }
556 res.insert(res.length() / 2, "...");
557 }
558 }
559
560 /** {@hide} */
561 private static File buildUniqueFileWithExtension(File parent, String name, String ext)
562 throws FileNotFoundException {
Jeff Sharkey06b38ca2019-12-17 15:51:12 -0700563 final Iterator<String> names = buildUniqueNameIterator(parent, name);
564 while (names.hasNext()) {
565 File file = buildFile(parent, names.next(), ext);
566 if (!file.exists()) {
567 return file;
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600568 }
Jeff Sharkey06b38ca2019-12-17 15:51:12 -0700569 }
570 throw new FileNotFoundException("Failed to create unique file");
571 }
572
573 private static final Pattern PATTERN_DCF_STRICT = Pattern
574 .compile("([A-Z0-9_]{4})([0-9]{4})");
575 private static final Pattern PATTERN_DCF_RELAXED = Pattern
576 .compile("((?:IMG|MVIMG|VID)_[0-9]{8}_[0-9]{6})(?:~([0-9]+))?");
577
578 private static boolean isDcim(@NonNull File dir) {
579 while (dir != null) {
580 if (Objects.equals("DCIM", dir.getName())) {
581 return true;
582 }
583 dir = dir.getParentFile();
584 }
585 return false;
586 }
587
588 private static @NonNull Iterator<String> buildUniqueNameIterator(@NonNull File parent,
589 @NonNull String name) {
590 if (isDcim(parent)) {
591 final Matcher dcfStrict = PATTERN_DCF_STRICT.matcher(name);
592 if (dcfStrict.matches()) {
593 // Generate names like "IMG_1001"
594 final String prefix = dcfStrict.group(1);
595 return new Iterator<String>() {
596 int i = Integer.parseInt(dcfStrict.group(2));
597 @Override
598 public String next() {
Corina54fb8ba2020-11-27 13:38:09 +0000599 final String res = String.format(Locale.US, "%s%04d", prefix, i);
Jeff Sharkey06b38ca2019-12-17 15:51:12 -0700600 i++;
601 return res;
602 }
603 @Override
604 public boolean hasNext() {
605 return i <= 9999;
606 }
607 };
608 }
609
610 final Matcher dcfRelaxed = PATTERN_DCF_RELAXED.matcher(name);
611 if (dcfRelaxed.matches()) {
612 // Generate names like "IMG_20190102_030405~2"
613 final String prefix = dcfRelaxed.group(1);
614 return new Iterator<String>() {
Corina54fb8ba2020-11-27 13:38:09 +0000615 int i = TextUtils.isEmpty(dcfRelaxed.group(2))
616 ? 1
Jeff Sharkey06b38ca2019-12-17 15:51:12 -0700617 : Integer.parseInt(dcfRelaxed.group(2));
618 @Override
619 public String next() {
Corina54fb8ba2020-11-27 13:38:09 +0000620 final String res = (i == 1)
621 ? prefix
622 : String.format(Locale.US, "%s~%d", prefix, i);
Jeff Sharkey06b38ca2019-12-17 15:51:12 -0700623 i++;
624 return res;
625 }
626 @Override
627 public boolean hasNext() {
628 return i <= 99;
629 }
630 };
631 }
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600632 }
633
Jeff Sharkey06b38ca2019-12-17 15:51:12 -0700634 // Generate names like "foo (2)"
635 return new Iterator<String>() {
636 int i = 0;
637 @Override
638 public String next() {
639 final String res = (i == 0) ? name : name + " (" + i + ")";
640 i++;
641 return res;
642 }
643 @Override
644 public boolean hasNext() {
645 return i < 32;
646 }
647 };
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600648 }
649
650 /**
651 * Generates a unique file name under the given parent directory. If the display name doesn't
652 * have an extension that matches the requested MIME type, the default extension for that MIME
653 * type is appended. If a file already exists, the name is appended with a numerical value to
654 * make it unique.
655 *
656 * For example, the display name 'example' with 'text/plain' MIME might produce
657 * 'example.txt' or 'example (1).txt', etc.
658 *
659 * @throws FileNotFoundException
660 * @hide
661 */
662 public static File buildUniqueFile(File parent, String mimeType, String displayName)
663 throws FileNotFoundException {
664 final String[] parts = splitFileName(mimeType, displayName);
665 return buildUniqueFileWithExtension(parent, parts[0], parts[1]);
666 }
667
668 /** {@hide} */
669 public static File buildNonUniqueFile(File parent, String mimeType, String displayName) {
670 final String[] parts = splitFileName(mimeType, displayName);
671 return buildFile(parent, parts[0], parts[1]);
672 }
673
674 /**
675 * Generates a unique file name under the given parent directory, keeping
676 * any extension intact.
677 *
678 * @hide
679 */
680 public static File buildUniqueFile(File parent, String displayName)
681 throws FileNotFoundException {
682 final String name;
683 final String ext;
684
685 // Extract requested extension from display name
686 final int lastDot = displayName.lastIndexOf('.');
687 if (lastDot >= 0) {
688 name = displayName.substring(0, lastDot);
689 ext = displayName.substring(lastDot + 1);
690 } else {
691 name = displayName;
692 ext = null;
693 }
694
695 return buildUniqueFileWithExtension(parent, name, ext);
696 }
697
698 /**
699 * Splits file name into base name and extension.
700 * If the display name doesn't have an extension that matches the requested MIME type, the
701 * extension is regarded as a part of filename and default extension for that MIME type is
702 * appended.
703 *
704 * @hide
705 */
706 public static String[] splitFileName(String mimeType, String displayName) {
707 String name;
708 String ext;
709
710 {
711 String mimeTypeFromExt;
712
713 // Extract requested extension from display name
714 final int lastDot = displayName.lastIndexOf('.');
Sahana Raof3c8a162020-05-15 21:58:36 +0100715 if (lastDot > 0) {
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600716 name = displayName.substring(0, lastDot);
717 ext = displayName.substring(lastDot + 1);
718 mimeTypeFromExt = MimeTypeMap.getSingleton().getMimeTypeFromExtension(
Jeff Sharkey470b97e2019-10-15 16:32:04 -0600719 ext.toLowerCase(Locale.ROOT));
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600720 } else {
721 name = displayName;
722 ext = null;
723 mimeTypeFromExt = null;
724 }
725
726 if (mimeTypeFromExt == null) {
727 mimeTypeFromExt = ClipDescription.MIMETYPE_UNKNOWN;
728 }
729
730 final String extFromMimeType;
Jeff Sharkeyc4a5f812020-05-03 21:07:14 -0600731 if (ClipDescription.MIMETYPE_UNKNOWN.equalsIgnoreCase(mimeType)) {
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600732 extFromMimeType = null;
733 } else {
734 extFromMimeType = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType);
735 }
736
Jeff Sharkeyc4a5f812020-05-03 21:07:14 -0600737 if (MimeUtils.equalIgnoreCase(mimeType, mimeTypeFromExt)
738 || MimeUtils.equalIgnoreCase(ext, extFromMimeType)) {
Jeff Sharkey7ea24f22019-08-22 10:14:18 -0600739 // Extension maps back to requested MIME type; allow it
740 } else {
741 // No match; insist that create file matches requested MIME
742 name = displayName;
743 ext = extFromMimeType;
744 }
745 }
746
747 if (ext == null) {
748 ext = "";
749 }
750
751 return new String[] { name, ext };
752 }
753
754 /** {@hide} */
755 private static File buildFile(File parent, String name, String ext) {
756 if (TextUtils.isEmpty(ext)) {
757 return new File(parent, name);
758 } else {
759 return new File(parent, name + "." + ext);
760 }
761 }
Jeff Sharkeye152d5762019-10-11 17:14:51 -0600762
763 public static @Nullable String extractDisplayName(@Nullable String data) {
764 if (data == null) return null;
Jeff Sharkeye76c4262019-12-06 14:46:00 -0700765 if (data.indexOf('/') == -1) {
766 return data;
767 }
Jeff Sharkeye152d5762019-10-11 17:14:51 -0600768 if (data.endsWith("/")) {
769 data = data.substring(0, data.length() - 1);
770 }
771 return data.substring(data.lastIndexOf('/') + 1);
772 }
773
774 public static @Nullable String extractFileName(@Nullable String data) {
775 if (data == null) return null;
776 data = extractDisplayName(data);
777
778 final int lastDot = data.lastIndexOf('.');
779 if (lastDot == -1) {
780 return data;
781 } else {
782 return data.substring(0, lastDot);
783 }
784 }
785
786 public static @Nullable String extractFileExtension(@Nullable String data) {
787 if (data == null) return null;
788 data = extractDisplayName(data);
789
790 final int lastDot = data.lastIndexOf('.');
791 if (lastDot == -1) {
792 return null;
793 } else {
794 return data.substring(lastDot + 1);
795 }
796 }
Jeff Sharkeyc5c39142019-12-15 22:46:03 -0700797
798 /**
Jeff Sharkeyc55994b2019-12-20 19:43:59 -0700799 * Return list of paths that should be scanned with
800 * {@link com.android.providers.media.scan.MediaScanner} for the given
801 * volume name.
Jeff Sharkeyc5c39142019-12-15 22:46:03 -0700802 */
803 public static @NonNull Collection<File> getVolumeScanPaths(@NonNull Context context,
804 @NonNull String volumeName) throws FileNotFoundException {
805 final ArrayList<File> res = new ArrayList<>();
806 switch (volumeName) {
807 case MediaStore.VOLUME_INTERNAL: {
808 res.addAll(Environment.getInternalMediaDirectories());
809 break;
810 }
811 case MediaStore.VOLUME_EXTERNAL: {
812 for (String resolvedVolumeName : MediaStore.getExternalVolumeNames(context)) {
813 res.add(getVolumePath(context, resolvedVolumeName));
814 }
815 break;
816 }
817 default: {
818 res.add(getVolumePath(context, volumeName));
819 }
820 }
821 return res;
822 }
823
824 /**
825 * Return path where the given volume name is mounted.
826 */
827 public static @NonNull File getVolumePath(@NonNull Context context,
828 @NonNull String volumeName) throws FileNotFoundException {
829 switch (volumeName) {
830 case MediaStore.VOLUME_INTERNAL:
831 case MediaStore.VOLUME_EXTERNAL:
832 throw new FileNotFoundException(volumeName + " has no associated path");
833 }
834
835 final Uri uri = MediaStore.Files.getContentUri(volumeName);
Zimfb3b1062020-09-10 10:29:47 +0100836 File path = null;
837
838 try {
839 path = context.getSystemService(StorageManager.class).getStorageVolume(uri)
840 .getDirectory();
841 } catch (IllegalStateException e) {
842 Log.w("Ignoring volume not found exception", e);
843 }
844
Jeff Sharkeyf95b06f2020-06-02 11:10:35 -0600845 if (path != null) {
846 return path;
847 } else {
848 throw new FileNotFoundException(volumeName + " has no associated path");
849 }
Jeff Sharkeyc5c39142019-12-15 22:46:03 -0700850 }
851
852 /**
shafik536982a2020-05-14 17:54:05 +0100853 * Returns the content URI for the volume that contains the given path.
854 *
855 * <p>{@link MediaStore.Files#getContentUriForPath(String)} can't detect public volumes and can
856 * only return the URI for the primary external storage, that's why this utility should be used
857 * instead.
858 */
859 public static @NonNull Uri getContentUriForPath(@NonNull String path) {
860 Objects.requireNonNull(path);
861 return MediaStore.Files.getContentUri(extractVolumeName(path));
862 }
863
864 /**
Martijn Coenenb5d6dde2021-04-01 16:17:41 +0200865 * Return StorageVolume corresponding to the file on Path
866 */
867 public static @NonNull StorageVolume getStorageVolume(@NonNull Context context,
868 @NonNull File path) throws FileNotFoundException {
869 int userId = extractUserId(path.getPath());
870 Context userContext = context;
871 if (userId >= 0 && (context.getUser().getIdentifier() != userId)) {
872 // This volume is for a different user than our context, create a context
873 // for that user to retrieve the correct volume.
874 try {
875 userContext = context.createPackageContextAsUser("system", 0,
876 UserHandle.of(userId));
877 } catch (PackageManager.NameNotFoundException e) {
878 throw new FileNotFoundException("Can't get package context for user " + userId);
879 }
880 }
881
882 StorageVolume volume = userContext.getSystemService(StorageManager.class)
883 .getStorageVolume(path);
884 if (volume == null) {
885 throw new FileNotFoundException("Can't find volume for " + path.getPath());
886 }
887
888 return volume;
889 }
890
891 /**
Jeff Sharkeyc5c39142019-12-15 22:46:03 -0700892 * Return volume name which hosts the given path.
893 */
Martijn Coenen2bf49fa2020-11-02 11:46:32 +0100894 public static @NonNull String getVolumeName(@NonNull Context context, @NonNull File path)
895 throws FileNotFoundException {
Jeff Sharkeyc5c39142019-12-15 22:46:03 -0700896 if (contains(Environment.getStorageDirectory(), path)) {
Martijn Coenenb5d6dde2021-04-01 16:17:41 +0200897 StorageVolume volume = getStorageVolume(context, path);
Martijn Coenen2bf49fa2020-11-02 11:46:32 +0100898 return volume.getMediaStoreVolumeName();
Jeff Sharkeyc5c39142019-12-15 22:46:03 -0700899 } else {
900 return MediaStore.VOLUME_INTERNAL;
901 }
902 }
Jeff Sharkey5ea5c282019-12-18 14:06:28 -0700903
904 public static final Pattern PATTERN_DOWNLOADS_FILE = Pattern.compile(
Zim78ffeeb2020-09-22 20:15:25 +0100905 "(?i)^/storage/[^/]+/(?:[0-9]+/)?Download/.+");
Jeff Sharkey5ea5c282019-12-18 14:06:28 -0700906 public static final Pattern PATTERN_DOWNLOADS_DIRECTORY = Pattern.compile(
Zim78ffeeb2020-09-22 20:15:25 +0100907 "(?i)^/storage/[^/]+/(?:[0-9]+/)?Download/?");
Jeff Sharkey89149b62020-03-29 22:03:44 -0600908 public static final Pattern PATTERN_EXPIRES_FILE = Pattern.compile(
Sahana Raoea587fc2020-06-03 15:56:23 +0100909 "(?i)^\\.(pending|trashed)-(\\d+)-([^/]+)$");
910 public static final Pattern PATTERN_PENDING_FILEPATH_FOR_SQL = Pattern.compile(
911 ".*/\\.pending-(\\d+)-([^/]+)$");
Jeff Sharkey89149b62020-03-29 22:03:44 -0600912
913 /**
914 * File prefix indicating that the file {@link MediaColumns#IS_PENDING}.
915 */
916 public static final String PREFIX_PENDING = "pending";
917
918 /**
919 * File prefix indicating that the file {@link MediaColumns#IS_TRASHED}.
920 */
921 public static final String PREFIX_TRASHED = "trashed";
922
923 /**
924 * Default duration that {@link MediaColumns#IS_PENDING} items should be
925 * preserved for until automatically cleaned by {@link #runIdleMaintenance}.
926 */
Jeff Sharkey05c3a032020-04-09 16:57:04 -0600927 public static final long DEFAULT_DURATION_PENDING = 7 * DateUtils.DAY_IN_MILLIS;
Jeff Sharkey89149b62020-03-29 22:03:44 -0600928
929 /**
930 * Default duration that {@link MediaColumns#IS_TRASHED} items should be
931 * preserved for until automatically cleaned by {@link #runIdleMaintenance}.
932 */
Jeff Sharkey05c3a032020-04-09 16:57:04 -0600933 public static final long DEFAULT_DURATION_TRASHED = 30 * DateUtils.DAY_IN_MILLIS;
Jeff Sharkey5ea5c282019-12-18 14:06:28 -0700934
Ivan Chiang447b3162021-03-29 10:52:05 +0800935 /**
936 * Default duration that expired items should be extended in
937 * {@link #runIdleMaintenance}.
938 */
939 public static final long DEFAULT_DURATION_EXTENDED = 7 * DateUtils.DAY_IN_MILLIS;
940
Jeff Sharkey5ea5c282019-12-18 14:06:28 -0700941 public static boolean isDownload(@NonNull String path) {
942 return PATTERN_DOWNLOADS_FILE.matcher(path).matches();
943 }
944
945 public static boolean isDownloadDir(@NonNull String path) {
946 return PATTERN_DOWNLOADS_DIRECTORY.matcher(path).matches();
947 }
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -0700948
Hyoungho Choidb8f9382021-07-22 20:37:58 +0900949 private static final boolean PROP_CROSS_USER_ALLOWED =
950 SystemProperties.getBoolean("external_storage.cross_user.enabled", false);
951
952 private static final String PROP_CROSS_USER_ROOT = isCrossUserEnabled()
953 ? SystemProperties.get("external_storage.cross_user.root", "") : "";
954
955 private static final String PROP_CROSS_USER_ROOT_PATTERN = ((PROP_CROSS_USER_ROOT.isEmpty())
956 ? "" : "(?:" + PROP_CROSS_USER_ROOT + "/)?");
957
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -0700958 /**
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -0700959 * Regex that matches paths in all well-known package-specific directories,
960 * and which captures the package name as the first group.
961 */
962 public static final Pattern PATTERN_OWNED_PATH = Pattern.compile(
Hyoungho Choidb8f9382021-07-22 20:37:58 +0900963 "(?i)^/storage/[^/]+/(?:[0-9]+/)?"
964 + PROP_CROSS_USER_ROOT_PATTERN
965 + "Android/(?:data|media|obb)/([^/]+)(/?.*)?");
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -0700966
967 /**
Abhijeet Kaure74d9bf2021-11-17 08:40:34 +0000968 * Regex that matches paths in all well-known package-specific relative directory
969 * path (as defined in {@link MediaColumns#RELATIVE_PATH})
970 * and which captures the package name as the first group.
Ricky Waifeb9d9b2020-04-06 19:14:46 +0100971 */
Abhijeet Kaure74d9bf2021-11-17 08:40:34 +0000972 private static final Pattern PATTERN_OWNED_RELATIVE_PATH = Pattern.compile(
973 "(?i)^Android/(?:data|media|obb)/([^/]+)(/?.*)?");
Ricky Waifeb9d9b2020-04-06 19:14:46 +0100974
Abhijeet Kaurb3ac2802020-10-07 16:42:42 +0100975 /**
Abhijeet Kaurc4458bb2022-06-15 17:01:29 +0100976 * Regex that matches exactly Android/obb or Android/data or Android/obb/ or Android/data/
977 * suffix absolute file path.
Abhijeet Kaurb3ac2802020-10-07 16:42:42 +0100978 */
Abhijeet Kaure74d9bf2021-11-17 08:40:34 +0000979 private static final Pattern PATTERN_DATA_OR_OBB_PATH = Pattern.compile(
Hyoungho Choidb8f9382021-07-22 20:37:58 +0900980 "(?i)^/storage/[^/]+/(?:[0-9]+/)?"
981 + PROP_CROSS_USER_ROOT_PATTERN
Abhijeet Kaurc4458bb2022-06-15 17:01:29 +0100982 + "Android/(?:data|obb)/?$");
Abhijeet Kaure74d9bf2021-11-17 08:40:34 +0000983
984 /**
985 * Regex that matches Android/obb or Android/data relative path (as defined in
986 * {@link MediaColumns#RELATIVE_PATH})
987 */
988 private static final Pattern PATTERN_DATA_OR_OBB_RELATIVE_PATH = Pattern.compile(
989 "(?i)^Android/(?:data|obb)(?:/.*)?$");
990
991 /**
992 * Regex that matches Android/obb {@link MediaColumns#RELATIVE_PATH}.
993 */
994 private static final Pattern PATTERN_OBB_OR_CHILD_RELATIVE_PATH = Pattern.compile(
995 "(?i)^Android/obb(?:/.*)?$");
Abhijeet Kaurb3ac2802020-10-07 16:42:42 +0100996
Ivan Chiangce81e9b2021-05-10 15:50:21 +0800997 /**
998 * The recordings directory. This is used for R OS. For S OS or later,
999 * we use {@link Environment#DIRECTORY_RECORDINGS} directly.
1000 */
1001 public static final String DIRECTORY_RECORDINGS = "Recordings";
1002
Sahana Raoe44080f2020-09-25 11:13:55 +01001003 @VisibleForTesting
Ivan Chiang7fce4a52021-01-25 16:53:54 +08001004 public static final String[] DEFAULT_FOLDER_NAMES;
1005 static {
1006 if (SdkLevel.isAtLeastS()) {
1007 DEFAULT_FOLDER_NAMES = new String[]{
1008 Environment.DIRECTORY_MUSIC,
1009 Environment.DIRECTORY_PODCASTS,
1010 Environment.DIRECTORY_RINGTONES,
1011 Environment.DIRECTORY_ALARMS,
1012 Environment.DIRECTORY_NOTIFICATIONS,
1013 Environment.DIRECTORY_PICTURES,
1014 Environment.DIRECTORY_MOVIES,
1015 Environment.DIRECTORY_DOWNLOADS,
1016 Environment.DIRECTORY_DCIM,
1017 Environment.DIRECTORY_DOCUMENTS,
1018 Environment.DIRECTORY_AUDIOBOOKS,
1019 Environment.DIRECTORY_RECORDINGS,
1020 };
1021 } else {
1022 DEFAULT_FOLDER_NAMES = new String[]{
1023 Environment.DIRECTORY_MUSIC,
1024 Environment.DIRECTORY_PODCASTS,
1025 Environment.DIRECTORY_RINGTONES,
1026 Environment.DIRECTORY_ALARMS,
1027 Environment.DIRECTORY_NOTIFICATIONS,
1028 Environment.DIRECTORY_PICTURES,
1029 Environment.DIRECTORY_MOVIES,
1030 Environment.DIRECTORY_DOWNLOADS,
1031 Environment.DIRECTORY_DCIM,
1032 Environment.DIRECTORY_DOCUMENTS,
1033 Environment.DIRECTORY_AUDIOBOOKS,
Ivan Chiangce81e9b2021-05-10 15:50:21 +08001034 DIRECTORY_RECORDINGS,
Ivan Chiang7fce4a52021-01-25 16:53:54 +08001035 };
1036 }
1037 }
Sahana Raoe44080f2020-09-25 11:13:55 +01001038
Ricky Waifeb9d9b2020-04-06 19:14:46 +01001039 /**
Zim78ffeeb2020-09-22 20:15:25 +01001040 * Regex that matches paths for {@link MediaColumns#RELATIVE_PATH}
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001041 */
1042 private static final Pattern PATTERN_RELATIVE_PATH = Pattern.compile(
Zim78ffeeb2020-09-22 20:15:25 +01001043 "(?i)^/storage/(?:emulated/[0-9]+/|[^/]+/)");
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001044
1045 /**
1046 * Regex that matches paths under well-known storage paths.
1047 */
1048 private static final Pattern PATTERN_VOLUME_NAME = Pattern.compile(
1049 "(?i)^/storage/([^/]+)");
1050
Saumya Pathakb60b33b2021-04-12 13:54:52 +00001051 /**
1052 * Regex that matches user-ids under well-known storage paths.
1053 */
1054 private static final Pattern PATTERN_USER_ID = Pattern.compile(
Martijn Coenend0b39792021-05-18 08:57:43 +02001055 "(?i)^/storage/emulated/([0-9]+)");
Saumya Pathakb60b33b2021-04-12 13:54:52 +00001056
Sahana Raoe44080f2020-09-25 11:13:55 +01001057 private static final String CAMERA_RELATIVE_PATH =
1058 String.format("%s/%s/", Environment.DIRECTORY_DCIM, "Camera");
1059
Hyoungho Choidb8f9382021-07-22 20:37:58 +09001060 public static boolean isCrossUserEnabled() {
1061 return PROP_CROSS_USER_ALLOWED || SdkLevel.isAtLeastS();
1062 }
1063
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001064 private static @Nullable String normalizeUuid(@Nullable String fsUuid) {
Jeff Sharkeyc4a5f812020-05-03 21:07:14 -06001065 return fsUuid != null ? fsUuid.toLowerCase(Locale.ROOT) : null;
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001066 }
1067
Saumya Pathakb60b33b2021-04-12 13:54:52 +00001068 public static int extractUserId(@Nullable String data) {
1069 if (data == null) return -1;
1070 final Matcher matcher = PATTERN_USER_ID.matcher(data);
1071 if (matcher.find()) {
1072 return Integer.parseInt(matcher.group(1));
1073 }
1074
1075 return -1;
1076 }
1077
Jeff Sharkey89149b62020-03-29 22:03:44 -06001078 public static @Nullable String extractVolumePath(@Nullable String data) {
1079 if (data == null) return null;
1080 final Matcher matcher = PATTERN_RELATIVE_PATH.matcher(data);
1081 if (matcher.find()) {
1082 return data.substring(0, matcher.end());
1083 } else {
1084 return null;
1085 }
1086 }
1087
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001088 public static @Nullable String extractVolumeName(@Nullable String data) {
1089 if (data == null) return null;
1090 final Matcher matcher = PATTERN_VOLUME_NAME.matcher(data);
1091 if (matcher.find()) {
1092 final String volumeName = matcher.group(1);
1093 if (volumeName.equals("emulated")) {
1094 return MediaStore.VOLUME_EXTERNAL_PRIMARY;
1095 } else {
1096 return normalizeUuid(volumeName);
1097 }
1098 } else {
1099 return MediaStore.VOLUME_INTERNAL;
1100 }
1101 }
1102
1103 public static @Nullable String extractRelativePath(@Nullable String data) {
1104 if (data == null) return null;
Abhijeet Kaur946c5fc2022-11-23 08:47:27 +00001105
Sergey Nikolaienkov6923b722023-03-28 12:22:31 +02001106 final String path;
1107 try {
1108 path = getCanonicalPath(data);
1109 } catch (IOException e) {
1110 Log.d(TAG, "Unable to get canonical path from invalid data path: " + data, e);
1111 return null;
1112 }
1113
1114 final Matcher matcher = PATTERN_RELATIVE_PATH.matcher(path);
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001115 if (matcher.find()) {
Sergey Nikolaienkov6923b722023-03-28 12:22:31 +02001116 final int lastSlash = path.lastIndexOf('/');
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001117 if (lastSlash == -1 || lastSlash < matcher.end()) {
1118 // This is a file in the top-level directory, so relative path is "/"
1119 // which is different than null, which means unknown path
1120 return "/";
1121 } else {
Sergey Nikolaienkov6923b722023-03-28 12:22:31 +02001122 return path.substring(matcher.end(), lastSlash + 1);
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001123 }
1124 } else {
1125 return null;
1126 }
1127 }
1128
1129 /**
Abhijeet Kaure74d9bf2021-11-17 08:40:34 +00001130 * Returns relative path with display name.
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001131 */
1132 @VisibleForTesting
Abhijeet Kaure74d9bf2021-11-17 08:40:34 +00001133 public static @Nullable String extractRelativePathWithDisplayName(@Nullable String path) {
1134 if (path == null) return null;
Sahana Rao54dafa32020-06-22 08:42:37 +01001135
Abhijeet Kaure74d9bf2021-11-17 08:40:34 +00001136 if (path.equals("/storage/emulated") || path.equals("/storage/emulated/")) {
Sahana Rao54dafa32020-06-22 08:42:37 +01001137 // This path is not reachable for MediaProvider.
1138 return null;
1139 }
1140
1141 // We are extracting relative path for the directory itself, we add "/" so that we can use
1142 // same PATTERN_RELATIVE_PATH to match relative path for directory. For example, relative
1143 // path of '/storage/<volume_name>' is null where as relative path for directory is "/", for
1144 // PATTERN_RELATIVE_PATH to match '/storage/<volume_name>', it should end with "/".
Abhijeet Kaure74d9bf2021-11-17 08:40:34 +00001145 if (!path.endsWith("/")) {
Sahana Rao54dafa32020-06-22 08:42:37 +01001146 // Relative path for directory should end with "/".
Abhijeet Kaure74d9bf2021-11-17 08:40:34 +00001147 path += "/";
Sahana Rao54dafa32020-06-22 08:42:37 +01001148 }
1149
Abhijeet Kaure74d9bf2021-11-17 08:40:34 +00001150 final Matcher matcher = PATTERN_RELATIVE_PATH.matcher(path);
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001151 if (matcher.find()) {
Abhijeet Kaure74d9bf2021-11-17 08:40:34 +00001152 if (matcher.end() == path.length()) {
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001153 // This is the top-level directory, so relative path is "/"
1154 return "/";
1155 }
Abhijeet Kaure74d9bf2021-11-17 08:40:34 +00001156 return path.substring(matcher.end());
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001157 }
1158 return null;
1159 }
1160
1161 public static @Nullable String extractPathOwnerPackageName(@Nullable String path) {
1162 if (path == null) return null;
1163 final Matcher m = PATTERN_OWNED_PATH.matcher(path);
1164 if (m.matches()) {
1165 return m.group(1);
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001166 }
Abhijeet Kaure74d9bf2021-11-17 08:40:34 +00001167 return null;
1168 }
1169
1170 public static @Nullable String extractOwnerPackageNameFromRelativePath(@Nullable String path) {
1171 if (path == null) return null;
1172 final Matcher m = PATTERN_OWNED_RELATIVE_PATH.matcher(path);
1173 if (m.matches()) {
1174 return m.group(1);
1175 }
1176 return null;
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001177 }
1178
Hyoungho Choidb8f9382021-07-22 20:37:58 +09001179 public static boolean isExternalMediaDirectory(@NonNull String path) {
1180 return isExternalMediaDirectory(path, PROP_CROSS_USER_ROOT);
1181 }
1182
1183 @VisibleForTesting
1184 static boolean isExternalMediaDirectory(@NonNull String path, String crossUserRoot) {
1185 final String relativePath = extractRelativePath(path);
1186 if (relativePath != null) {
1187 final String externalMediaDir = (crossUserRoot == null || crossUserRoot.isEmpty())
1188 ? "Android/media" : crossUserRoot + "/Android/media";
1189 return relativePath.startsWith(externalMediaDir);
1190 }
1191 return false;
1192 }
1193
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001194 /**
Abhijeet Kaure74d9bf2021-11-17 08:40:34 +00001195 * Returns true if path is Android/data or Android/obb path.
Ricky Waifeb9d9b2020-04-06 19:14:46 +01001196 */
Abhijeet Kaure74d9bf2021-11-17 08:40:34 +00001197 public static boolean isDataOrObbPath(@Nullable String path) {
Ricky Waifeb9d9b2020-04-06 19:14:46 +01001198 if (path == null) return false;
1199 final Matcher m = PATTERN_DATA_OR_OBB_PATH.matcher(path);
1200 return m.matches();
1201 }
1202
1203 /**
Abhijeet Kaure74d9bf2021-11-17 08:40:34 +00001204 * Returns true if relative path is Android/data or Android/obb path.
1205 */
1206 public static boolean isDataOrObbRelativePath(@Nullable String path) {
1207 if (path == null) return false;
1208 final Matcher m = PATTERN_DATA_OR_OBB_RELATIVE_PATH.matcher(path);
1209 return m.matches();
1210 }
1211
1212 /**
Abhijeet Kaurb3ac2802020-10-07 16:42:42 +01001213 * Returns true if relative path is Android/obb path.
1214 */
Abhijeet Kaure74d9bf2021-11-17 08:40:34 +00001215 public static boolean isObbOrChildRelativePath(@Nullable String path) {
Abhijeet Kaurb3ac2802020-10-07 16:42:42 +01001216 if (path == null) return false;
Abhijeet Kaure74d9bf2021-11-17 08:40:34 +00001217 final Matcher m = PATTERN_OBB_OR_CHILD_RELATIVE_PATH.matcher(path);
Abhijeet Kaurb3ac2802020-10-07 16:42:42 +01001218 return m.matches();
1219 }
1220
1221 /**
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001222 * Returns the name of the top level directory, or null if the path doesn't go through the
1223 * external storage directory.
1224 */
1225 @Nullable
1226 public static String extractTopLevelDir(String path) {
Sahana Rao920f6662020-04-30 15:13:42 +01001227 final String relativePath = extractRelativePath(path);
1228 if (relativePath == null) {
1229 return null;
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001230 }
Hyoungho Choidb8f9382021-07-22 20:37:58 +09001231
1232 return extractTopLevelDir(relativePath.split("/"));
1233 }
1234
1235 @Nullable
1236 public static String extractTopLevelDir(String[] relativePathSegments) {
1237 return extractTopLevelDir(relativePathSegments, PROP_CROSS_USER_ROOT);
1238 }
1239
1240 @VisibleForTesting
1241 @Nullable
1242 static String extractTopLevelDir(String[] relativePathSegments, String crossUserRoot) {
1243 if (relativePathSegments == null) return null;
1244
1245 final String topLevelDir = relativePathSegments.length > 0 ? relativePathSegments[0] : null;
1246 if (crossUserRoot != null && crossUserRoot.equals(topLevelDir)) {
1247 return relativePathSegments.length > 1 ? relativePathSegments[1] : null;
1248 }
1249
1250 return topLevelDir;
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001251 }
Jeff Sharkeyc55994b2019-12-20 19:43:59 -07001252
Sahana Raoe44080f2020-09-25 11:13:55 +01001253 public static boolean isDefaultDirectoryName(@Nullable String dirName) {
1254 for (String defaultDirName : DEFAULT_FOLDER_NAMES) {
1255 if (defaultDirName.equalsIgnoreCase(dirName)) {
1256 return true;
1257 }
1258 }
1259 return false;
1260 }
1261
Jeff Sharkey89149b62020-03-29 22:03:44 -06001262 /**
Jeff Sharkey05c3a032020-04-09 16:57:04 -06001263 * Compute the value of {@link MediaColumns#DATE_EXPIRES} based on other
1264 * columns being modified by this operation.
1265 */
1266 public static void computeDateExpires(@NonNull ContentValues values) {
1267 // External apps have no ability to change this field
1268 values.remove(MediaColumns.DATE_EXPIRES);
1269
1270 // Only define the field when this modification is actually adjusting
1271 // one of the flags that should influence the expiration
Jeff Sharkeya9473e92020-04-17 15:54:30 -06001272 final Object pending = values.get(MediaColumns.IS_PENDING);
Jeff Sharkey05c3a032020-04-09 16:57:04 -06001273 if (pending != null) {
Jeff Sharkeya9473e92020-04-17 15:54:30 -06001274 if (parseBoolean(pending, false)) {
Jeff Sharkey05c3a032020-04-09 16:57:04 -06001275 values.put(MediaColumns.DATE_EXPIRES,
1276 (System.currentTimeMillis() + DEFAULT_DURATION_PENDING) / 1000);
1277 } else {
1278 values.putNull(MediaColumns.DATE_EXPIRES);
1279 }
1280 }
Jeff Sharkeya9473e92020-04-17 15:54:30 -06001281 final Object trashed = values.get(MediaColumns.IS_TRASHED);
Jeff Sharkey05c3a032020-04-09 16:57:04 -06001282 if (trashed != null) {
Jeff Sharkeya9473e92020-04-17 15:54:30 -06001283 if (parseBoolean(trashed, false)) {
Jeff Sharkey05c3a032020-04-09 16:57:04 -06001284 values.put(MediaColumns.DATE_EXPIRES,
1285 (System.currentTimeMillis() + DEFAULT_DURATION_TRASHED) / 1000);
1286 } else {
1287 values.putNull(MediaColumns.DATE_EXPIRES);
1288 }
1289 }
1290 }
1291
1292 /**
Jeff Sharkey89149b62020-03-29 22:03:44 -06001293 * Compute several scattered {@link MediaColumns} values from
1294 * {@link MediaColumns#DATA}. This method performs no enforcement of
1295 * argument validity.
1296 */
Sahana Raoea587fc2020-06-03 15:56:23 +01001297 public static void computeValuesFromData(@NonNull ContentValues values, boolean isForFuse) {
Jeff Sharkeyc55994b2019-12-20 19:43:59 -07001298 // Worst case we have to assume no bucket details
Jeff Sharkey89149b62020-03-29 22:03:44 -06001299 values.remove(MediaColumns.VOLUME_NAME);
1300 values.remove(MediaColumns.RELATIVE_PATH);
Jeff Sharkey89149b62020-03-29 22:03:44 -06001301 values.remove(MediaColumns.IS_TRASHED);
1302 values.remove(MediaColumns.DATE_EXPIRES);
1303 values.remove(MediaColumns.DISPLAY_NAME);
1304 values.remove(MediaColumns.BUCKET_ID);
1305 values.remove(MediaColumns.BUCKET_DISPLAY_NAME);
Jeff Sharkeyc55994b2019-12-20 19:43:59 -07001306
Dipankar Bhardwaj428bb982023-07-06 10:01:20 +00001307 String data = values.getAsString(MediaColumns.DATA);
Jeff Sharkeyc55994b2019-12-20 19:43:59 -07001308 if (TextUtils.isEmpty(data)) return;
1309
Dipankar Bhardwaj428bb982023-07-06 10:01:20 +00001310 try {
1311 data = new File(data).getCanonicalPath();
1312 values.put(MediaColumns.DATA, data);
1313 } catch (IOException e) {
1314 throw new IllegalArgumentException(
1315 String.format(Locale.ROOT, "Invalid file path:%s in request.", data));
1316 }
1317
Jeff Sharkeyc55994b2019-12-20 19:43:59 -07001318 final File file = new File(data);
1319 final File fileLower = new File(data.toLowerCase(Locale.ROOT));
1320
Jeff Sharkey89149b62020-03-29 22:03:44 -06001321 values.put(MediaColumns.VOLUME_NAME, extractVolumeName(data));
1322 values.put(MediaColumns.RELATIVE_PATH, extractRelativePath(data));
Jeff Sharkey89149b62020-03-29 22:03:44 -06001323 final String displayName = extractDisplayName(data);
1324 final Matcher matcher = FileUtils.PATTERN_EXPIRES_FILE.matcher(displayName);
1325 if (matcher.matches()) {
1326 values.put(MediaColumns.IS_PENDING,
1327 matcher.group(1).equals(FileUtils.PREFIX_PENDING) ? 1 : 0);
1328 values.put(MediaColumns.IS_TRASHED,
1329 matcher.group(1).equals(FileUtils.PREFIX_TRASHED) ? 1 : 0);
1330 values.put(MediaColumns.DATE_EXPIRES, Long.parseLong(matcher.group(2)));
1331 values.put(MediaColumns.DISPLAY_NAME, matcher.group(3));
1332 } else {
Sahana Raoea587fc2020-06-03 15:56:23 +01001333 if (isForFuse) {
1334 // Allow Fuse thread to set IS_PENDING when using DATA column.
1335 // TODO(b/156867379) Unset IS_PENDING when Fuse thread doesn't explicitly specify
1336 // IS_PENDING. It can't be done now because we scan after create. Scan doesn't
1337 // explicitly specify the value of IS_PENDING.
1338 } else {
1339 values.put(MediaColumns.IS_PENDING, 0);
1340 }
Jeff Sharkey89149b62020-03-29 22:03:44 -06001341 values.put(MediaColumns.IS_TRASHED, 0);
1342 values.putNull(MediaColumns.DATE_EXPIRES);
1343 values.put(MediaColumns.DISPLAY_NAME, displayName);
1344 }
Jeff Sharkeyc55994b2019-12-20 19:43:59 -07001345
1346 // Buckets are the parent directory
1347 final String parent = fileLower.getParent();
1348 if (parent != null) {
Jeff Sharkey89149b62020-03-29 22:03:44 -06001349 values.put(MediaColumns.BUCKET_ID, parent.hashCode());
Jeff Sharkeyc55994b2019-12-20 19:43:59 -07001350 // The relative path for files in the top directory is "/"
Jeff Sharkey89149b62020-03-29 22:03:44 -06001351 if (!"/".equals(values.getAsString(MediaColumns.RELATIVE_PATH))) {
1352 values.put(MediaColumns.BUCKET_DISPLAY_NAME, file.getParentFile().getName());
Jeff Sharkeyc55994b2019-12-20 19:43:59 -07001353 }
1354 }
1355 }
Sahana Raof21671d2020-03-09 16:49:26 +00001356
Jeff Sharkey89149b62020-03-29 22:03:44 -06001357 /**
1358 * Compute {@link MediaColumns#DATA} from several scattered
1359 * {@link MediaColumns} values. This method performs no enforcement of
1360 * argument validity.
1361 */
1362 public static void computeDataFromValues(@NonNull ContentValues values,
Sahana Raoea587fc2020-06-03 15:56:23 +01001363 @NonNull File volumePath, boolean isForFuse) {
Jeff Sharkey89149b62020-03-29 22:03:44 -06001364 values.remove(MediaColumns.DATA);
1365
1366 final String displayName = values.getAsString(MediaColumns.DISPLAY_NAME);
1367 final String resolvedDisplayName;
Sahana Raoea587fc2020-06-03 15:56:23 +01001368 // Pending file path shouldn't be rewritten for files inserted via filepath.
1369 if (!isForFuse && getAsBoolean(values, MediaColumns.IS_PENDING, false)) {
Jeff Sharkey89149b62020-03-29 22:03:44 -06001370 final long dateExpires = getAsLong(values, MediaColumns.DATE_EXPIRES,
1371 (System.currentTimeMillis() + DEFAULT_DURATION_PENDING) / 1000);
Ivan Chiang9aa80952020-12-04 13:11:53 +08001372 final String combinedString = String.format(
Corina54fb8ba2020-11-27 13:38:09 +00001373 Locale.US, ".%s-%d-%s", FileUtils.PREFIX_PENDING, dateExpires, displayName);
Ivan Chiang9aa80952020-12-04 13:11:53 +08001374 // trim the file name to avoid ENAMETOOLONG error
1375 // after trim the file, if the user unpending the file,
1376 // the file name is not the original one
1377 resolvedDisplayName = trimFilename(combinedString, MAX_FILENAME_BYTES);
Jeff Sharkey89149b62020-03-29 22:03:44 -06001378 } else if (getAsBoolean(values, MediaColumns.IS_TRASHED, false)) {
1379 final long dateExpires = getAsLong(values, MediaColumns.DATE_EXPIRES,
1380 (System.currentTimeMillis() + DEFAULT_DURATION_TRASHED) / 1000);
Ivan Chiang9aa80952020-12-04 13:11:53 +08001381 final String combinedString = String.format(
Corina54fb8ba2020-11-27 13:38:09 +00001382 Locale.US, ".%s-%d-%s", FileUtils.PREFIX_TRASHED, dateExpires, displayName);
Ivan Chiang9aa80952020-12-04 13:11:53 +08001383 // trim the file name to avoid ENAMETOOLONG error
1384 // after trim the file, if the user untrashes the file,
1385 // the file name is not the original one
1386 resolvedDisplayName = trimFilename(combinedString, MAX_FILENAME_BYTES);
Jeff Sharkey89149b62020-03-29 22:03:44 -06001387 } else {
1388 resolvedDisplayName = displayName;
1389 }
1390
Dipankar Bhardwajcf7037d2022-06-07 07:37:18 +00001391 String relativePath = values.getAsString(MediaColumns.RELATIVE_PATH);
1392 if (relativePath == null) {
1393 relativePath = "";
1394 }
1395 try {
1396 final File filePath = buildPath(volumePath, relativePath, resolvedDisplayName);
1397 values.put(MediaColumns.DATA, filePath.getCanonicalPath());
1398 } catch (IOException e) {
1399 throw new IllegalArgumentException(
1400 String.format("Failure in conversion to canonical file path. Failure path: %s.",
1401 relativePath.concat(resolvedDisplayName)), e);
1402 }
Jeff Sharkey89149b62020-03-29 22:03:44 -06001403 }
1404
Sahana Rao76b8b5b2020-04-17 20:21:59 +01001405 public static void sanitizeValues(@NonNull ContentValues values,
1406 boolean rewriteHiddenFileName) {
Jeff Sharkey89149b62020-03-29 22:03:44 -06001407 final String[] relativePath = values.getAsString(MediaColumns.RELATIVE_PATH).split("/");
1408 for (int i = 0; i < relativePath.length; i++) {
Sahana Rao76b8b5b2020-04-17 20:21:59 +01001409 relativePath[i] = sanitizeDisplayName(relativePath[i], rewriteHiddenFileName);
Jeff Sharkey89149b62020-03-29 22:03:44 -06001410 }
1411 values.put(MediaColumns.RELATIVE_PATH,
1412 String.join("/", relativePath) + "/");
Sahana Rao76b8b5b2020-04-17 20:21:59 +01001413
1414 final String displayName = values.getAsString(MediaColumns.DISPLAY_NAME);
Jeff Sharkey89149b62020-03-29 22:03:44 -06001415 values.put(MediaColumns.DISPLAY_NAME,
Sahana Rao76b8b5b2020-04-17 20:21:59 +01001416 sanitizeDisplayName(displayName, rewriteHiddenFileName));
Jeff Sharkey89149b62020-03-29 22:03:44 -06001417 }
1418
Sahana Raof21671d2020-03-09 16:49:26 +00001419 /** {@hide} **/
1420 @Nullable
1421 public static String getAbsoluteSanitizedPath(String path) {
1422 final String[] pathSegments = sanitizePath(path);
1423 if (pathSegments.length == 0) {
1424 return null;
1425 }
1426 return path = "/" + String.join("/",
1427 Arrays.copyOfRange(pathSegments, 1, pathSegments.length));
1428 }
1429
1430 /** {@hide} */
1431 public static @NonNull String[] sanitizePath(@Nullable String path) {
1432 if (path == null) {
1433 return new String[0];
1434 } else {
1435 final String[] segments = path.split("/");
1436 // If the path corresponds to the top level directory, then we return an empty path
1437 // which denotes the top level directory
1438 if (segments.length == 0) {
1439 return new String[] { "" };
1440 }
1441 for (int i = 0; i < segments.length; i++) {
1442 segments[i] = sanitizeDisplayName(segments[i]);
1443 }
1444 return segments;
1445 }
1446 }
1447
1448 /**
Sahana Rao76b8b5b2020-04-17 20:21:59 +01001449 * Sanitizes given name by mutating the file name to make it valid for a FAT filesystem.
Sahana Raof21671d2020-03-09 16:49:26 +00001450 * @hide
1451 */
1452 public static @Nullable String sanitizeDisplayName(@Nullable String name) {
Sahana Rao76b8b5b2020-04-17 20:21:59 +01001453 return sanitizeDisplayName(name, /*rewriteHiddenFileName*/ false);
1454 }
1455
1456 /**
1457 * Sanitizes given name by appending '_' to make it non-hidden and mutating the file name to
1458 * make it valid for a FAT filesystem.
1459 * @hide
1460 */
1461 public static @Nullable String sanitizeDisplayName(@Nullable String name,
1462 boolean rewriteHiddenFileName) {
Sahana Raof21671d2020-03-09 16:49:26 +00001463 if (name == null) {
1464 return null;
Sahana Rao76b8b5b2020-04-17 20:21:59 +01001465 } else if (rewriteHiddenFileName && name.startsWith(".")) {
Sahana Raof21671d2020-03-09 16:49:26 +00001466 // The resulting file must not be hidden.
Sahana Rao76b8b5b2020-04-17 20:21:59 +01001467 return "_" + name;
Sahana Raof21671d2020-03-09 16:49:26 +00001468 } else {
1469 return buildValidFatFilename(name);
1470 }
1471 }
shafika202fdd2020-05-11 20:19:27 +01001472
1473 /**
Martijn Coenen070bce12020-06-08 21:18:24 +02001474 * Test if this given directory should be considered hidden.
1475 */
1476 @VisibleForTesting
1477 public static boolean isDirectoryHidden(@NonNull File dir) {
1478 final String name = dir.getName();
1479 if (name.startsWith(".")) {
1480 return true;
1481 }
1482
1483 final File nomedia = new File(dir, ".nomedia");
Sahana Raoe44080f2020-09-25 11:13:55 +01001484
Martijn Coenen070bce12020-06-08 21:18:24 +02001485 // check for .nomedia presence
Sahana Raoe44080f2020-09-25 11:13:55 +01001486 if (!nomedia.exists()) {
1487 return false;
Martijn Coenen070bce12020-06-08 21:18:24 +02001488 }
Sahana Raoe44080f2020-09-25 11:13:55 +01001489
1490 // Handle top-level default directories. These directories should always be visible,
1491 // regardless of .nomedia presence.
1492 final String[] relativePath = sanitizePath(extractRelativePath(dir.getAbsolutePath()));
1493 final boolean isTopLevelDir =
1494 relativePath.length == 1 && TextUtils.isEmpty(relativePath[0]);
1495 if (isTopLevelDir && isDefaultDirectoryName(name)) {
1496 nomedia.delete();
1497 return false;
1498 }
1499
1500 // DCIM/Camera should always be visible regardless of .nomedia presence.
1501 if (CAMERA_RELATIVE_PATH.equalsIgnoreCase(
Abhijeet Kaure74d9bf2021-11-17 08:40:34 +00001502 extractRelativePathWithDisplayName(dir.getAbsolutePath()))) {
Sahana Raoe44080f2020-09-25 11:13:55 +01001503 nomedia.delete();
1504 return false;
1505 }
1506
Sahana Rao83c82ef2021-06-10 17:15:58 +01001507 if (isScreenshotsDirNonHidden(relativePath, name)) {
1508 nomedia.delete();
1509 return false;
1510 }
1511
Sahana Raoe44080f2020-09-25 11:13:55 +01001512 // .nomedia is present which makes this directory as hidden directory
1513 Logging.logPersistent("Observed non-standard " + nomedia);
1514 return true;
Martijn Coenen070bce12020-06-08 21:18:24 +02001515 }
1516
1517 /**
Sahana Rao83c82ef2021-06-10 17:15:58 +01001518 * Consider Screenshots directory in root directory or inside well-known directory as always
1519 * non-hidden. Nomedia file in these directories will not be able to hide these directories.
1520 * i.e., some examples of directories that will be considered non-hidden are
1521 * <ul>
1522 * <li> /storage/emulated/0/Screenshots or
1523 * <li> /storage/emulated/0/DCIM/Screenshots or
1524 * <li> /storage/emulated/0/Pictures/Screenshots ...
1525 * </ul>
1526 * Some examples of directories that can be considered as hidden with nomedia are
1527 * <ul>
1528 * <li> /storage/emulated/0/foo/Screenshots or
1529 * <li> /storage/emulated/0/DCIM/Foo/Screenshots or
1530 * <li> /storage/emulated/0/Pictures/foo/bar/Screenshots ...
1531 * </ul>
1532 */
1533 private static boolean isScreenshotsDirNonHidden(@NonNull String[] relativePath,
1534 @NonNull String name) {
1535 if (name.equalsIgnoreCase(Environment.DIRECTORY_SCREENSHOTS)) {
1536 return (relativePath.length == 1 &&
1537 (TextUtils.isEmpty(relativePath[0]) || isDefaultDirectoryName(relativePath[0])));
1538 }
1539 return false;
1540 }
1541
1542 /**
Martijn Coenen070bce12020-06-08 21:18:24 +02001543 * Test if this given file should be considered hidden.
1544 */
1545 @VisibleForTesting
1546 public static boolean isFileHidden(@NonNull File file) {
1547 final String name = file.getName();
1548
1549 // Handle well-known file names that are pending or trashed; they
1550 // normally appear hidden, but we give them special treatment
1551 if (PATTERN_EXPIRES_FILE.matcher(name).matches()) {
1552 return false;
1553 }
1554
1555 // Otherwise fall back to file name
1556 if (name.startsWith(".")) {
1557 return true;
1558 }
1559 return false;
1560 }
1561
1562 /**
shafika202fdd2020-05-11 20:19:27 +01001563 * Clears all app's external cache directories, i.e. for each app we delete
1564 * /sdcard/Android/data/app/cache/* but we keep the directory itself.
1565 *
1566 * @return 0 in case of success, or {@link OsConstants#EIO} if any error occurs.
1567 *
1568 * <p>This method doesn't perform any checks, so make sure that the calling package is allowed
1569 * to clear cache directories first.
1570 *
1571 * <p>If this method returned {@link OsConstants#EIO}, then we can't guarantee whether all, none
1572 * or part of the directories were cleared.
1573 */
1574 public static int clearAppCacheDirectories() {
1575 int status = 0;
1576 Log.i(TAG, "Clearing cache for all apps");
1577 final File rootDataDir = buildPath(Environment.getExternalStorageDirectory(),
1578 "Android", "data");
1579 for (File appDataDir : rootDataDir.listFiles()) {
1580 try {
1581 final File appCacheDir = new File(appDataDir, "cache");
1582 if (appCacheDir.isDirectory()) {
1583 FileUtils.deleteContents(appCacheDir);
1584 }
1585 } catch (Exception e) {
1586 // We want to avoid crashing MediaProvider at all costs, so we handle all "generic"
1587 // exceptions here, and just report to the caller that an IO exception has occurred.
1588 // We still try to clear the rest of the directories.
1589 Log.e(TAG, "Couldn't delete all app cache dirs!", e);
1590 status = OsConstants.EIO;
1591 }
1592 }
1593 return status;
1594 }
Zim7e730f32020-09-30 11:38:50 +01001595
1596 /**
1597 * @return {@code true} if {@code dir} is dirty and should be scanned, {@code false} otherwise.
1598 */
1599 public static boolean isDirectoryDirty(File dir) {
1600 File nomedia = new File(dir, ".nomedia");
1601 if (nomedia.exists()) {
1602 try {
1603 Optional<String> expectedPath = readString(nomedia);
1604 // Returns true If .nomedia file is empty or content doesn't match |dir|
1605 // Returns false otherwise
1606 return !expectedPath.isPresent()
1607 || !expectedPath.get().equals(dir.getPath());
1608 } catch (IOException e) {
1609 Log.w(TAG, "Failed to read directory dirty" + dir);
1610 }
1611 }
1612 return true;
1613 }
1614
1615 /**
1616 * {@code isDirty} == {@code true} will force {@code dir} scanning even if it's hidden
1617 * {@code isDirty} == {@code false} will skip {@code dir} scanning on next scan.
1618 */
1619 public static void setDirectoryDirty(File dir, boolean isDirty) {
1620 File nomedia = new File(dir, ".nomedia");
1621 if (nomedia.exists()) {
1622 try {
1623 writeString(nomedia, isDirty ? Optional.of("") : Optional.of(dir.getPath()));
1624 } catch (IOException e) {
1625 Log.w(TAG, "Failed to change directory dirty: " + dir + ". isDirty: " + isDirty);
1626 }
1627 }
1628 }
1629
1630 /**
1631 * @return the folder containing the top-most .nomedia in {@code file} hierarchy.
1632 * E.g input as /sdcard/foo/bar/ will return /sdcard/foo
1633 * even if foo and bar contain .nomedia files.
1634 *
1635 * Returns {@code null} if there's no .nomedia in hierarchy
1636 */
1637 public static File getTopLevelNoMedia(@NonNull File file) {
Sahana Rao7abc52c2020-12-07 16:02:03 +00001638 File topNoMediaDir = null;
Zim7e730f32020-09-30 11:38:50 +01001639
1640 File parent = file;
1641 while (parent != null) {
1642 File nomedia = new File(parent, ".nomedia");
1643 if (nomedia.exists()) {
Sahana Rao7abc52c2020-12-07 16:02:03 +00001644 topNoMediaDir = parent;
Zim7e730f32020-09-30 11:38:50 +01001645 }
1646 parent = parent.getParentFile();
1647 }
1648
Sahana Rao7abc52c2020-12-07 16:02:03 +00001649 return topNoMediaDir;
Zim7e730f32020-09-30 11:38:50 +01001650 }
Ivan Chiang447b3162021-03-29 10:52:05 +08001651
1652 /**
1653 * Generate the extended absolute path from the expired file path
1654 * E.g. the input expiredFilePath is /storage/emulated/0/DCIM/.trashed-1621147340-test.jpg
1655 * The returned result is /storage/emulated/0/DCIM/.trashed-1888888888-test.jpg
1656 *
1657 * @hide
1658 */
1659 @Nullable
1660 public static String getAbsoluteExtendedPath(@NonNull String expiredFilePath,
1661 long extendedTime) {
1662 final String displayName = extractDisplayName(expiredFilePath);
1663
1664 final Matcher matcher = PATTERN_EXPIRES_FILE.matcher(displayName);
1665 if (matcher.matches()) {
1666 final String newDisplayName = String.format(Locale.US, ".%s-%d-%s", matcher.group(1),
1667 extendedTime, matcher.group(3));
1668 final int lastSlash = expiredFilePath.lastIndexOf('/');
1669 final String newPath = expiredFilePath.substring(0, lastSlash + 1).concat(
1670 newDisplayName);
1671 return newPath;
1672 }
1673
1674 return null;
1675 }
Abhijeet Kaur946c5fc2022-11-23 08:47:27 +00001676
Sergey Nikolaienkov6923b722023-03-28 12:22:31 +02001677 /**
1678 * Returns the canonical {@link File} for the provided abstract pathname.
1679 *
1680 * @return The canonical pathname string denoting the same file or directory as this abstract
1681 * pathname
1682 * @see File#getCanonicalFile()
1683 */
1684 @NonNull
1685 public static File getCanonicalFile(@NonNull String path) throws IOException {
1686 Objects.requireNonNull(path);
1687 return new File(path).getCanonicalFile();
1688 }
Abhijeet Kaur946c5fc2022-11-23 08:47:27 +00001689
Sergey Nikolaienkov6923b722023-03-28 12:22:31 +02001690 /**
1691 * Returns the canonical pathname string of the provided abstract pathname.
1692 *
1693 * @return The canonical pathname string denoting the same file or directory as this abstract
1694 * pathname.
1695 * @see File#getCanonicalPath()
1696 */
1697 @NonNull
1698 public static String getCanonicalPath(@NonNull String path) throws IOException {
1699 Objects.requireNonNull(path);
1700 return new File(path).getCanonicalPath();
Abhijeet Kaur946c5fc2022-11-23 08:47:27 +00001701 }
Jeff Sharkey7ea24f22019-08-22 10:14:18 -06001702}