blob: 6c2695c7803d6a1c12baead1dab24ef23cc79c0f [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 Bhardwaj9b7b8bd2022-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
Martijn Coenendb782022021-12-09 11:25:45 +0100737 if (StringUtils.equalIgnoreCase(mimeType, mimeTypeFromExt)
738 || StringUtils.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 Kaur1e78acf2021-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 Kaur1e78acf2021-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 Kaur4f212512022-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 Kaur1e78acf2021-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 Kaur4f212512022-06-15 17:01:29 +0100982 + "Android/(?:data|obb)/?$");
Abhijeet Kaur1e78acf2021-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
Hyoungho Choi76a913a2021-07-06 16:29:10 +0900997 private static final Pattern PATTERN_VISIBLE = Pattern.compile(
998 "(?i)^/storage/[^/]+(?:/[0-9]+)?$");
999
1000 private static final Pattern PATTERN_INVISIBLE = Pattern.compile(
1001 "(?i)^/storage/[^/]+(?:/[0-9]+)?/"
1002 + "(?:(?:Android/(?:data|obb|sandbox)$)|"
1003 + "(?:\\.transforms$)|"
1004 + "(?:(?:Movies|Music|Pictures)/.thumbnails$))");
1005
Ivan Chiangce81e9b2021-05-10 15:50:21 +08001006 /**
1007 * The recordings directory. This is used for R OS. For S OS or later,
1008 * we use {@link Environment#DIRECTORY_RECORDINGS} directly.
1009 */
1010 public static final String DIRECTORY_RECORDINGS = "Recordings";
1011
Sahana Raoe44080f2020-09-25 11:13:55 +01001012 @VisibleForTesting
Ivan Chiang7fce4a52021-01-25 16:53:54 +08001013 public static final String[] DEFAULT_FOLDER_NAMES;
1014 static {
1015 if (SdkLevel.isAtLeastS()) {
1016 DEFAULT_FOLDER_NAMES = new String[]{
1017 Environment.DIRECTORY_MUSIC,
1018 Environment.DIRECTORY_PODCASTS,
1019 Environment.DIRECTORY_RINGTONES,
1020 Environment.DIRECTORY_ALARMS,
1021 Environment.DIRECTORY_NOTIFICATIONS,
1022 Environment.DIRECTORY_PICTURES,
1023 Environment.DIRECTORY_MOVIES,
1024 Environment.DIRECTORY_DOWNLOADS,
1025 Environment.DIRECTORY_DCIM,
1026 Environment.DIRECTORY_DOCUMENTS,
1027 Environment.DIRECTORY_AUDIOBOOKS,
1028 Environment.DIRECTORY_RECORDINGS,
1029 };
1030 } else {
1031 DEFAULT_FOLDER_NAMES = new String[]{
1032 Environment.DIRECTORY_MUSIC,
1033 Environment.DIRECTORY_PODCASTS,
1034 Environment.DIRECTORY_RINGTONES,
1035 Environment.DIRECTORY_ALARMS,
1036 Environment.DIRECTORY_NOTIFICATIONS,
1037 Environment.DIRECTORY_PICTURES,
1038 Environment.DIRECTORY_MOVIES,
1039 Environment.DIRECTORY_DOWNLOADS,
1040 Environment.DIRECTORY_DCIM,
1041 Environment.DIRECTORY_DOCUMENTS,
1042 Environment.DIRECTORY_AUDIOBOOKS,
Ivan Chiangce81e9b2021-05-10 15:50:21 +08001043 DIRECTORY_RECORDINGS,
Ivan Chiang7fce4a52021-01-25 16:53:54 +08001044 };
1045 }
1046 }
Sahana Raoe44080f2020-09-25 11:13:55 +01001047
Ricky Waifeb9d9b2020-04-06 19:14:46 +01001048 /**
Zim78ffeeb2020-09-22 20:15:25 +01001049 * Regex that matches paths for {@link MediaColumns#RELATIVE_PATH}
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001050 */
1051 private static final Pattern PATTERN_RELATIVE_PATH = Pattern.compile(
Zim78ffeeb2020-09-22 20:15:25 +01001052 "(?i)^/storage/(?:emulated/[0-9]+/|[^/]+/)");
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001053
1054 /**
1055 * Regex that matches paths under well-known storage paths.
1056 */
1057 private static final Pattern PATTERN_VOLUME_NAME = Pattern.compile(
1058 "(?i)^/storage/([^/]+)");
1059
Saumya Pathakb60b33b2021-04-12 13:54:52 +00001060 /**
1061 * Regex that matches user-ids under well-known storage paths.
1062 */
1063 private static final Pattern PATTERN_USER_ID = Pattern.compile(
Martijn Coenend0b39792021-05-18 08:57:43 +02001064 "(?i)^/storage/emulated/([0-9]+)");
Saumya Pathakb60b33b2021-04-12 13:54:52 +00001065
Sahana Raoe44080f2020-09-25 11:13:55 +01001066 private static final String CAMERA_RELATIVE_PATH =
1067 String.format("%s/%s/", Environment.DIRECTORY_DCIM, "Camera");
1068
Hyoungho Choidb8f9382021-07-22 20:37:58 +09001069 public static boolean isCrossUserEnabled() {
1070 return PROP_CROSS_USER_ALLOWED || SdkLevel.isAtLeastS();
1071 }
1072
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001073 private static @Nullable String normalizeUuid(@Nullable String fsUuid) {
Jeff Sharkeyc4a5f812020-05-03 21:07:14 -06001074 return fsUuid != null ? fsUuid.toLowerCase(Locale.ROOT) : null;
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001075 }
1076
Saumya Pathakb60b33b2021-04-12 13:54:52 +00001077 public static int extractUserId(@Nullable String data) {
1078 if (data == null) return -1;
1079 final Matcher matcher = PATTERN_USER_ID.matcher(data);
1080 if (matcher.find()) {
1081 return Integer.parseInt(matcher.group(1));
1082 }
1083
1084 return -1;
1085 }
1086
Jeff Sharkey89149b62020-03-29 22:03:44 -06001087 public static @Nullable String extractVolumePath(@Nullable String data) {
1088 if (data == null) return null;
1089 final Matcher matcher = PATTERN_RELATIVE_PATH.matcher(data);
1090 if (matcher.find()) {
1091 return data.substring(0, matcher.end());
1092 } else {
1093 return null;
1094 }
1095 }
1096
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001097 public static @Nullable String extractVolumeName(@Nullable String data) {
1098 if (data == null) return null;
1099 final Matcher matcher = PATTERN_VOLUME_NAME.matcher(data);
1100 if (matcher.find()) {
1101 final String volumeName = matcher.group(1);
1102 if (volumeName.equals("emulated")) {
1103 return MediaStore.VOLUME_EXTERNAL_PRIMARY;
1104 } else {
1105 return normalizeUuid(volumeName);
1106 }
1107 } else {
1108 return MediaStore.VOLUME_INTERNAL;
1109 }
1110 }
1111
1112 public static @Nullable String extractRelativePath(@Nullable String data) {
1113 if (data == null) return null;
Abhijeet Kaurc6282182022-11-23 08:47:27 +00001114
Sergey Nikolaienkov52e601d2023-03-28 12:22:31 +02001115 final String path;
1116 try {
1117 path = getCanonicalPath(data);
1118 } catch (IOException e) {
1119 Log.d(TAG, "Unable to get canonical path from invalid data path: " + data, e);
1120 return null;
1121 }
1122
1123 final Matcher matcher = PATTERN_RELATIVE_PATH.matcher(path);
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001124 if (matcher.find()) {
Sergey Nikolaienkov52e601d2023-03-28 12:22:31 +02001125 final int lastSlash = path.lastIndexOf('/');
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001126 if (lastSlash == -1 || lastSlash < matcher.end()) {
1127 // This is a file in the top-level directory, so relative path is "/"
1128 // which is different than null, which means unknown path
1129 return "/";
1130 } else {
Sergey Nikolaienkov52e601d2023-03-28 12:22:31 +02001131 return path.substring(matcher.end(), lastSlash + 1);
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001132 }
1133 } else {
1134 return null;
1135 }
1136 }
1137
1138 /**
Abhijeet Kaur1e78acf2021-11-17 08:40:34 +00001139 * Returns relative path with display name.
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001140 */
1141 @VisibleForTesting
Abhijeet Kaur1e78acf2021-11-17 08:40:34 +00001142 public static @Nullable String extractRelativePathWithDisplayName(@Nullable String path) {
1143 if (path == null) return null;
Sahana Rao54dafa32020-06-22 08:42:37 +01001144
Abhijeet Kaur1e78acf2021-11-17 08:40:34 +00001145 if (path.equals("/storage/emulated") || path.equals("/storage/emulated/")) {
Sahana Rao54dafa32020-06-22 08:42:37 +01001146 // This path is not reachable for MediaProvider.
1147 return null;
1148 }
1149
1150 // We are extracting relative path for the directory itself, we add "/" so that we can use
1151 // same PATTERN_RELATIVE_PATH to match relative path for directory. For example, relative
1152 // path of '/storage/<volume_name>' is null where as relative path for directory is "/", for
1153 // PATTERN_RELATIVE_PATH to match '/storage/<volume_name>', it should end with "/".
Abhijeet Kaur1e78acf2021-11-17 08:40:34 +00001154 if (!path.endsWith("/")) {
Sahana Rao54dafa32020-06-22 08:42:37 +01001155 // Relative path for directory should end with "/".
Abhijeet Kaur1e78acf2021-11-17 08:40:34 +00001156 path += "/";
Sahana Rao54dafa32020-06-22 08:42:37 +01001157 }
1158
Abhijeet Kaur1e78acf2021-11-17 08:40:34 +00001159 final Matcher matcher = PATTERN_RELATIVE_PATH.matcher(path);
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001160 if (matcher.find()) {
Abhijeet Kaur1e78acf2021-11-17 08:40:34 +00001161 if (matcher.end() == path.length()) {
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001162 // This is the top-level directory, so relative path is "/"
1163 return "/";
1164 }
Abhijeet Kaur1e78acf2021-11-17 08:40:34 +00001165 return path.substring(matcher.end());
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001166 }
1167 return null;
1168 }
1169
1170 public static @Nullable String extractPathOwnerPackageName(@Nullable String path) {
1171 if (path == null) return null;
1172 final Matcher m = PATTERN_OWNED_PATH.matcher(path);
1173 if (m.matches()) {
1174 return m.group(1);
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001175 }
Abhijeet Kaur1e78acf2021-11-17 08:40:34 +00001176 return null;
1177 }
1178
1179 public static @Nullable String extractOwnerPackageNameFromRelativePath(@Nullable String path) {
1180 if (path == null) return null;
1181 final Matcher m = PATTERN_OWNED_RELATIVE_PATH.matcher(path);
1182 if (m.matches()) {
1183 return m.group(1);
1184 }
1185 return null;
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001186 }
1187
Hyoungho Choidb8f9382021-07-22 20:37:58 +09001188 public static boolean isExternalMediaDirectory(@NonNull String path) {
1189 return isExternalMediaDirectory(path, PROP_CROSS_USER_ROOT);
1190 }
1191
1192 @VisibleForTesting
1193 static boolean isExternalMediaDirectory(@NonNull String path, String crossUserRoot) {
1194 final String relativePath = extractRelativePath(path);
Hyoungho Choia6b18702021-12-23 21:21:43 +09001195 if (relativePath == null) {
1196 return false;
Hyoungho Choidb8f9382021-07-22 20:37:58 +09001197 }
Hyoungho Choia6b18702021-12-23 21:21:43 +09001198
1199 if (StringUtils.startsWithIgnoreCase(relativePath, "Android/media")) {
1200 return true;
1201 }
1202 if (!TextUtils.isEmpty(crossUserRoot)) {
1203 return StringUtils.startsWithIgnoreCase(relativePath, crossUserRoot + "/Android/media");
1204 }
1205
Hyoungho Choidb8f9382021-07-22 20:37:58 +09001206 return false;
1207 }
1208
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001209 /**
Abhijeet Kaur1e78acf2021-11-17 08:40:34 +00001210 * Returns true if path is Android/data or Android/obb path.
Ricky Waifeb9d9b2020-04-06 19:14:46 +01001211 */
Abhijeet Kaur1e78acf2021-11-17 08:40:34 +00001212 public static boolean isDataOrObbPath(@Nullable String path) {
Ricky Waifeb9d9b2020-04-06 19:14:46 +01001213 if (path == null) return false;
1214 final Matcher m = PATTERN_DATA_OR_OBB_PATH.matcher(path);
1215 return m.matches();
1216 }
1217
1218 /**
Abhijeet Kaur1e78acf2021-11-17 08:40:34 +00001219 * Returns true if relative path is Android/data or Android/obb path.
1220 */
1221 public static boolean isDataOrObbRelativePath(@Nullable String path) {
1222 if (path == null) return false;
1223 final Matcher m = PATTERN_DATA_OR_OBB_RELATIVE_PATH.matcher(path);
1224 return m.matches();
1225 }
1226
1227 /**
Abhijeet Kaurb3ac2802020-10-07 16:42:42 +01001228 * Returns true if relative path is Android/obb path.
1229 */
Abhijeet Kaur1e78acf2021-11-17 08:40:34 +00001230 public static boolean isObbOrChildRelativePath(@Nullable String path) {
Abhijeet Kaurb3ac2802020-10-07 16:42:42 +01001231 if (path == null) return false;
Abhijeet Kaur1e78acf2021-11-17 08:40:34 +00001232 final Matcher m = PATTERN_OBB_OR_CHILD_RELATIVE_PATH.matcher(path);
Abhijeet Kaurb3ac2802020-10-07 16:42:42 +01001233 return m.matches();
1234 }
1235
Hyoungho Choi76a913a2021-07-06 16:29:10 +09001236 public static boolean shouldBeVisible(@Nullable String path) {
1237 if (path == null) return false;
1238 final Matcher m = PATTERN_VISIBLE.matcher(path);
1239 return m.matches();
1240 }
1241
1242 public static boolean shouldBeInvisible(@Nullable String path) {
1243 if (path == null) return false;
1244 final Matcher m = PATTERN_INVISIBLE.matcher(path);
1245 return m.matches();
1246 }
1247
Abhijeet Kaurb3ac2802020-10-07 16:42:42 +01001248 /**
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001249 * Returns the name of the top level directory, or null if the path doesn't go through the
1250 * external storage directory.
1251 */
1252 @Nullable
1253 public static String extractTopLevelDir(String path) {
Sahana Rao920f6662020-04-30 15:13:42 +01001254 final String relativePath = extractRelativePath(path);
1255 if (relativePath == null) {
1256 return null;
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001257 }
Hyoungho Choidb8f9382021-07-22 20:37:58 +09001258
1259 return extractTopLevelDir(relativePath.split("/"));
1260 }
1261
1262 @Nullable
1263 public static String extractTopLevelDir(String[] relativePathSegments) {
1264 return extractTopLevelDir(relativePathSegments, PROP_CROSS_USER_ROOT);
1265 }
1266
1267 @VisibleForTesting
1268 @Nullable
1269 static String extractTopLevelDir(String[] relativePathSegments, String crossUserRoot) {
1270 if (relativePathSegments == null) return null;
1271
1272 final String topLevelDir = relativePathSegments.length > 0 ? relativePathSegments[0] : null;
1273 if (crossUserRoot != null && crossUserRoot.equals(topLevelDir)) {
1274 return relativePathSegments.length > 1 ? relativePathSegments[1] : null;
1275 }
1276
1277 return topLevelDir;
Jeff Sharkey1f6ad1a2019-12-20 14:26:34 -07001278 }
Jeff Sharkeyc55994b2019-12-20 19:43:59 -07001279
Sahana Raoe44080f2020-09-25 11:13:55 +01001280 public static boolean isDefaultDirectoryName(@Nullable String dirName) {
1281 for (String defaultDirName : DEFAULT_FOLDER_NAMES) {
1282 if (defaultDirName.equalsIgnoreCase(dirName)) {
1283 return true;
1284 }
1285 }
1286 return false;
1287 }
1288
Jeff Sharkey89149b62020-03-29 22:03:44 -06001289 /**
Jeff Sharkey05c3a032020-04-09 16:57:04 -06001290 * Compute the value of {@link MediaColumns#DATE_EXPIRES} based on other
1291 * columns being modified by this operation.
1292 */
1293 public static void computeDateExpires(@NonNull ContentValues values) {
1294 // External apps have no ability to change this field
1295 values.remove(MediaColumns.DATE_EXPIRES);
1296
1297 // Only define the field when this modification is actually adjusting
1298 // one of the flags that should influence the expiration
Jeff Sharkeya9473e92020-04-17 15:54:30 -06001299 final Object pending = values.get(MediaColumns.IS_PENDING);
Jeff Sharkey05c3a032020-04-09 16:57:04 -06001300 if (pending != null) {
Jeff Sharkeya9473e92020-04-17 15:54:30 -06001301 if (parseBoolean(pending, false)) {
Jeff Sharkey05c3a032020-04-09 16:57:04 -06001302 values.put(MediaColumns.DATE_EXPIRES,
1303 (System.currentTimeMillis() + DEFAULT_DURATION_PENDING) / 1000);
1304 } else {
1305 values.putNull(MediaColumns.DATE_EXPIRES);
1306 }
1307 }
Jeff Sharkeya9473e92020-04-17 15:54:30 -06001308 final Object trashed = values.get(MediaColumns.IS_TRASHED);
Jeff Sharkey05c3a032020-04-09 16:57:04 -06001309 if (trashed != null) {
Jeff Sharkeya9473e92020-04-17 15:54:30 -06001310 if (parseBoolean(trashed, false)) {
Jeff Sharkey05c3a032020-04-09 16:57:04 -06001311 values.put(MediaColumns.DATE_EXPIRES,
1312 (System.currentTimeMillis() + DEFAULT_DURATION_TRASHED) / 1000);
1313 } else {
1314 values.putNull(MediaColumns.DATE_EXPIRES);
1315 }
1316 }
1317 }
1318
1319 /**
Jeff Sharkey89149b62020-03-29 22:03:44 -06001320 * Compute several scattered {@link MediaColumns} values from
1321 * {@link MediaColumns#DATA}. This method performs no enforcement of
1322 * argument validity.
1323 */
Sahana Raoea587fc2020-06-03 15:56:23 +01001324 public static void computeValuesFromData(@NonNull ContentValues values, boolean isForFuse) {
Jeff Sharkeyc55994b2019-12-20 19:43:59 -07001325 // Worst case we have to assume no bucket details
Jeff Sharkey89149b62020-03-29 22:03:44 -06001326 values.remove(MediaColumns.VOLUME_NAME);
1327 values.remove(MediaColumns.RELATIVE_PATH);
Jeff Sharkey89149b62020-03-29 22:03:44 -06001328 values.remove(MediaColumns.IS_TRASHED);
1329 values.remove(MediaColumns.DATE_EXPIRES);
1330 values.remove(MediaColumns.DISPLAY_NAME);
1331 values.remove(MediaColumns.BUCKET_ID);
1332 values.remove(MediaColumns.BUCKET_DISPLAY_NAME);
Jeff Sharkeyc55994b2019-12-20 19:43:59 -07001333
Dipankar Bhardwaj07a58162023-07-06 10:01:20 +00001334 String data = values.getAsString(MediaColumns.DATA);
Jeff Sharkeyc55994b2019-12-20 19:43:59 -07001335 if (TextUtils.isEmpty(data)) return;
1336
Dipankar Bhardwaj07a58162023-07-06 10:01:20 +00001337 try {
1338 data = new File(data).getCanonicalPath();
1339 values.put(MediaColumns.DATA, data);
1340 } catch (IOException e) {
1341 throw new IllegalArgumentException(
1342 String.format(Locale.ROOT, "Invalid file path:%s in request.", data));
1343 }
1344
Jeff Sharkeyc55994b2019-12-20 19:43:59 -07001345 final File file = new File(data);
1346 final File fileLower = new File(data.toLowerCase(Locale.ROOT));
1347
Jeff Sharkey89149b62020-03-29 22:03:44 -06001348 values.put(MediaColumns.VOLUME_NAME, extractVolumeName(data));
1349 values.put(MediaColumns.RELATIVE_PATH, extractRelativePath(data));
Jeff Sharkey89149b62020-03-29 22:03:44 -06001350 final String displayName = extractDisplayName(data);
1351 final Matcher matcher = FileUtils.PATTERN_EXPIRES_FILE.matcher(displayName);
1352 if (matcher.matches()) {
1353 values.put(MediaColumns.IS_PENDING,
1354 matcher.group(1).equals(FileUtils.PREFIX_PENDING) ? 1 : 0);
1355 values.put(MediaColumns.IS_TRASHED,
1356 matcher.group(1).equals(FileUtils.PREFIX_TRASHED) ? 1 : 0);
1357 values.put(MediaColumns.DATE_EXPIRES, Long.parseLong(matcher.group(2)));
1358 values.put(MediaColumns.DISPLAY_NAME, matcher.group(3));
1359 } else {
Sahana Raoea587fc2020-06-03 15:56:23 +01001360 if (isForFuse) {
1361 // Allow Fuse thread to set IS_PENDING when using DATA column.
1362 // TODO(b/156867379) Unset IS_PENDING when Fuse thread doesn't explicitly specify
1363 // IS_PENDING. It can't be done now because we scan after create. Scan doesn't
1364 // explicitly specify the value of IS_PENDING.
1365 } else {
1366 values.put(MediaColumns.IS_PENDING, 0);
1367 }
Jeff Sharkey89149b62020-03-29 22:03:44 -06001368 values.put(MediaColumns.IS_TRASHED, 0);
1369 values.putNull(MediaColumns.DATE_EXPIRES);
1370 values.put(MediaColumns.DISPLAY_NAME, displayName);
1371 }
Jeff Sharkeyc55994b2019-12-20 19:43:59 -07001372
1373 // Buckets are the parent directory
1374 final String parent = fileLower.getParent();
1375 if (parent != null) {
Jeff Sharkey89149b62020-03-29 22:03:44 -06001376 values.put(MediaColumns.BUCKET_ID, parent.hashCode());
Jeff Sharkeyc55994b2019-12-20 19:43:59 -07001377 // The relative path for files in the top directory is "/"
Jeff Sharkey89149b62020-03-29 22:03:44 -06001378 if (!"/".equals(values.getAsString(MediaColumns.RELATIVE_PATH))) {
1379 values.put(MediaColumns.BUCKET_DISPLAY_NAME, file.getParentFile().getName());
Hyoungho Choi8a250222021-10-22 15:27:29 +09001380 } else {
1381 values.putNull(MediaColumns.BUCKET_DISPLAY_NAME);
Jeff Sharkeyc55994b2019-12-20 19:43:59 -07001382 }
1383 }
1384 }
Sahana Raof21671d2020-03-09 16:49:26 +00001385
Jeff Sharkey89149b62020-03-29 22:03:44 -06001386 /**
1387 * Compute {@link MediaColumns#DATA} from several scattered
1388 * {@link MediaColumns} values. This method performs no enforcement of
1389 * argument validity.
1390 */
1391 public static void computeDataFromValues(@NonNull ContentValues values,
Sahana Raoea587fc2020-06-03 15:56:23 +01001392 @NonNull File volumePath, boolean isForFuse) {
Jeff Sharkey89149b62020-03-29 22:03:44 -06001393 values.remove(MediaColumns.DATA);
1394
1395 final String displayName = values.getAsString(MediaColumns.DISPLAY_NAME);
1396 final String resolvedDisplayName;
Sahana Raoea587fc2020-06-03 15:56:23 +01001397 // Pending file path shouldn't be rewritten for files inserted via filepath.
1398 if (!isForFuse && getAsBoolean(values, MediaColumns.IS_PENDING, false)) {
Jeff Sharkey89149b62020-03-29 22:03:44 -06001399 final long dateExpires = getAsLong(values, MediaColumns.DATE_EXPIRES,
1400 (System.currentTimeMillis() + DEFAULT_DURATION_PENDING) / 1000);
Ivan Chiang9aa80952020-12-04 13:11:53 +08001401 final String combinedString = String.format(
Corina54fb8ba2020-11-27 13:38:09 +00001402 Locale.US, ".%s-%d-%s", FileUtils.PREFIX_PENDING, dateExpires, displayName);
Ivan Chiang9aa80952020-12-04 13:11:53 +08001403 // trim the file name to avoid ENAMETOOLONG error
1404 // after trim the file, if the user unpending the file,
1405 // the file name is not the original one
1406 resolvedDisplayName = trimFilename(combinedString, MAX_FILENAME_BYTES);
Jeff Sharkey89149b62020-03-29 22:03:44 -06001407 } else if (getAsBoolean(values, MediaColumns.IS_TRASHED, false)) {
1408 final long dateExpires = getAsLong(values, MediaColumns.DATE_EXPIRES,
1409 (System.currentTimeMillis() + DEFAULT_DURATION_TRASHED) / 1000);
Ivan Chiang9aa80952020-12-04 13:11:53 +08001410 final String combinedString = String.format(
Corina54fb8ba2020-11-27 13:38:09 +00001411 Locale.US, ".%s-%d-%s", FileUtils.PREFIX_TRASHED, dateExpires, displayName);
Ivan Chiang9aa80952020-12-04 13:11:53 +08001412 // trim the file name to avoid ENAMETOOLONG error
1413 // after trim the file, if the user untrashes the file,
1414 // the file name is not the original one
1415 resolvedDisplayName = trimFilename(combinedString, MAX_FILENAME_BYTES);
Jeff Sharkey89149b62020-03-29 22:03:44 -06001416 } else {
1417 resolvedDisplayName = displayName;
1418 }
1419
Dipankar Bhardwaj9b7b8bd2022-06-07 07:37:18 +00001420 String relativePath = values.getAsString(MediaColumns.RELATIVE_PATH);
1421 if (relativePath == null) {
1422 relativePath = "";
1423 }
1424 try {
1425 final File filePath = buildPath(volumePath, relativePath, resolvedDisplayName);
1426 values.put(MediaColumns.DATA, filePath.getCanonicalPath());
1427 } catch (IOException e) {
1428 throw new IllegalArgumentException(
1429 String.format("Failure in conversion to canonical file path. Failure path: %s.",
1430 relativePath.concat(resolvedDisplayName)), e);
1431 }
Jeff Sharkey89149b62020-03-29 22:03:44 -06001432 }
1433
Sahana Rao76b8b5b2020-04-17 20:21:59 +01001434 public static void sanitizeValues(@NonNull ContentValues values,
1435 boolean rewriteHiddenFileName) {
Jeff Sharkey89149b62020-03-29 22:03:44 -06001436 final String[] relativePath = values.getAsString(MediaColumns.RELATIVE_PATH).split("/");
1437 for (int i = 0; i < relativePath.length; i++) {
Sahana Rao76b8b5b2020-04-17 20:21:59 +01001438 relativePath[i] = sanitizeDisplayName(relativePath[i], rewriteHiddenFileName);
Jeff Sharkey89149b62020-03-29 22:03:44 -06001439 }
1440 values.put(MediaColumns.RELATIVE_PATH,
1441 String.join("/", relativePath) + "/");
Sahana Rao76b8b5b2020-04-17 20:21:59 +01001442
1443 final String displayName = values.getAsString(MediaColumns.DISPLAY_NAME);
Jeff Sharkey89149b62020-03-29 22:03:44 -06001444 values.put(MediaColumns.DISPLAY_NAME,
Sahana Rao76b8b5b2020-04-17 20:21:59 +01001445 sanitizeDisplayName(displayName, rewriteHiddenFileName));
Jeff Sharkey89149b62020-03-29 22:03:44 -06001446 }
1447
Sahana Raof21671d2020-03-09 16:49:26 +00001448 /** {@hide} **/
1449 @Nullable
1450 public static String getAbsoluteSanitizedPath(String path) {
1451 final String[] pathSegments = sanitizePath(path);
1452 if (pathSegments.length == 0) {
1453 return null;
1454 }
1455 return path = "/" + String.join("/",
1456 Arrays.copyOfRange(pathSegments, 1, pathSegments.length));
1457 }
1458
1459 /** {@hide} */
1460 public static @NonNull String[] sanitizePath(@Nullable String path) {
1461 if (path == null) {
1462 return new String[0];
1463 } else {
1464 final String[] segments = path.split("/");
1465 // If the path corresponds to the top level directory, then we return an empty path
1466 // which denotes the top level directory
1467 if (segments.length == 0) {
1468 return new String[] { "" };
1469 }
1470 for (int i = 0; i < segments.length; i++) {
1471 segments[i] = sanitizeDisplayName(segments[i]);
1472 }
1473 return segments;
1474 }
1475 }
1476
1477 /**
Sahana Rao76b8b5b2020-04-17 20:21:59 +01001478 * Sanitizes given name by mutating the file name to make it valid for a FAT filesystem.
Sahana Raof21671d2020-03-09 16:49:26 +00001479 * @hide
1480 */
1481 public static @Nullable String sanitizeDisplayName(@Nullable String name) {
Sahana Rao76b8b5b2020-04-17 20:21:59 +01001482 return sanitizeDisplayName(name, /*rewriteHiddenFileName*/ false);
1483 }
1484
1485 /**
1486 * Sanitizes given name by appending '_' to make it non-hidden and mutating the file name to
1487 * make it valid for a FAT filesystem.
1488 * @hide
1489 */
1490 public static @Nullable String sanitizeDisplayName(@Nullable String name,
1491 boolean rewriteHiddenFileName) {
Sahana Raof21671d2020-03-09 16:49:26 +00001492 if (name == null) {
1493 return null;
Sahana Rao76b8b5b2020-04-17 20:21:59 +01001494 } else if (rewriteHiddenFileName && name.startsWith(".")) {
Sahana Raof21671d2020-03-09 16:49:26 +00001495 // The resulting file must not be hidden.
Sahana Rao76b8b5b2020-04-17 20:21:59 +01001496 return "_" + name;
Sahana Raof21671d2020-03-09 16:49:26 +00001497 } else {
1498 return buildValidFatFilename(name);
1499 }
1500 }
shafika202fdd2020-05-11 20:19:27 +01001501
1502 /**
Hyoungho Choi438b9c02021-11-26 18:45:42 +09001503 * Returns true if the given File should be hidden (if it or any of its parents is hidden).
1504 * This can be called before the file is created, to check if it will be hidden once created.
1505 */
1506 @VisibleForTesting
1507 public static boolean shouldFileBeHidden(@NonNull File file) {
1508 if (isFileHidden(file)) {
1509 return true;
1510 }
1511
1512 File parent = file.getParentFile();
1513 while (parent != null) {
1514 if (isDirectoryHidden(parent)) {
1515 return true;
1516 }
1517 parent = parent.getParentFile();
1518 }
1519
1520 return false;
1521 }
1522
1523 /**
1524 * Returns true if the given dir should be hidden (if it or any of its parents is hidden).
1525 * This can be called before the file is created, to check if it will be hidden once created.
1526 */
1527 @VisibleForTesting
1528 public static boolean shouldDirBeHidden(@NonNull File file) {
1529 if (isDirectoryHidden(file)) {
1530 return true;
1531 }
1532
1533 File parent = file.getParentFile();
1534 while (parent != null) {
1535 if (isDirectoryHidden(parent)) {
1536 return true;
1537 }
1538 parent = parent.getParentFile();
1539 }
1540
1541 return false;
1542 }
1543
1544 /**
Martijn Coenen070bce12020-06-08 21:18:24 +02001545 * Test if this given directory should be considered hidden.
1546 */
1547 @VisibleForTesting
1548 public static boolean isDirectoryHidden(@NonNull File dir) {
1549 final String name = dir.getName();
1550 if (name.startsWith(".")) {
1551 return true;
1552 }
1553
1554 final File nomedia = new File(dir, ".nomedia");
Sahana Raoe44080f2020-09-25 11:13:55 +01001555
Martijn Coenen070bce12020-06-08 21:18:24 +02001556 // check for .nomedia presence
Sahana Raoe44080f2020-09-25 11:13:55 +01001557 if (!nomedia.exists()) {
1558 return false;
Martijn Coenen070bce12020-06-08 21:18:24 +02001559 }
Sahana Raoe44080f2020-09-25 11:13:55 +01001560
Hyoungho Choi76a913a2021-07-06 16:29:10 +09001561 if (shouldBeVisible(dir.getAbsolutePath())) {
1562 nomedia.delete();
1563 return false;
1564 }
1565
Sahana Raoe44080f2020-09-25 11:13:55 +01001566 // Handle top-level default directories. These directories should always be visible,
1567 // regardless of .nomedia presence.
1568 final String[] relativePath = sanitizePath(extractRelativePath(dir.getAbsolutePath()));
1569 final boolean isTopLevelDir =
1570 relativePath.length == 1 && TextUtils.isEmpty(relativePath[0]);
1571 if (isTopLevelDir && isDefaultDirectoryName(name)) {
1572 nomedia.delete();
1573 return false;
1574 }
1575
1576 // DCIM/Camera should always be visible regardless of .nomedia presence.
1577 if (CAMERA_RELATIVE_PATH.equalsIgnoreCase(
Abhijeet Kaur1e78acf2021-11-17 08:40:34 +00001578 extractRelativePathWithDisplayName(dir.getAbsolutePath()))) {
Sahana Raoe44080f2020-09-25 11:13:55 +01001579 nomedia.delete();
1580 return false;
1581 }
1582
Sahana Rao83c82ef2021-06-10 17:15:58 +01001583 if (isScreenshotsDirNonHidden(relativePath, name)) {
1584 nomedia.delete();
1585 return false;
1586 }
1587
Sahana Raoe44080f2020-09-25 11:13:55 +01001588 // .nomedia is present which makes this directory as hidden directory
1589 Logging.logPersistent("Observed non-standard " + nomedia);
1590 return true;
Martijn Coenen070bce12020-06-08 21:18:24 +02001591 }
1592
1593 /**
Sahana Rao83c82ef2021-06-10 17:15:58 +01001594 * Consider Screenshots directory in root directory or inside well-known directory as always
1595 * non-hidden. Nomedia file in these directories will not be able to hide these directories.
1596 * i.e., some examples of directories that will be considered non-hidden are
1597 * <ul>
1598 * <li> /storage/emulated/0/Screenshots or
1599 * <li> /storage/emulated/0/DCIM/Screenshots or
1600 * <li> /storage/emulated/0/Pictures/Screenshots ...
1601 * </ul>
1602 * Some examples of directories that can be considered as hidden with nomedia are
1603 * <ul>
1604 * <li> /storage/emulated/0/foo/Screenshots or
1605 * <li> /storage/emulated/0/DCIM/Foo/Screenshots or
1606 * <li> /storage/emulated/0/Pictures/foo/bar/Screenshots ...
1607 * </ul>
1608 */
1609 private static boolean isScreenshotsDirNonHidden(@NonNull String[] relativePath,
1610 @NonNull String name) {
1611 if (name.equalsIgnoreCase(Environment.DIRECTORY_SCREENSHOTS)) {
1612 return (relativePath.length == 1 &&
1613 (TextUtils.isEmpty(relativePath[0]) || isDefaultDirectoryName(relativePath[0])));
1614 }
1615 return false;
1616 }
1617
1618 /**
Martijn Coenen070bce12020-06-08 21:18:24 +02001619 * Test if this given file should be considered hidden.
1620 */
1621 @VisibleForTesting
1622 public static boolean isFileHidden(@NonNull File file) {
1623 final String name = file.getName();
1624
1625 // Handle well-known file names that are pending or trashed; they
1626 // normally appear hidden, but we give them special treatment
1627 if (PATTERN_EXPIRES_FILE.matcher(name).matches()) {
1628 return false;
1629 }
1630
1631 // Otherwise fall back to file name
1632 if (name.startsWith(".")) {
1633 return true;
1634 }
1635 return false;
1636 }
1637
1638 /**
shafika202fdd2020-05-11 20:19:27 +01001639 * Clears all app's external cache directories, i.e. for each app we delete
1640 * /sdcard/Android/data/app/cache/* but we keep the directory itself.
1641 *
1642 * @return 0 in case of success, or {@link OsConstants#EIO} if any error occurs.
1643 *
1644 * <p>This method doesn't perform any checks, so make sure that the calling package is allowed
1645 * to clear cache directories first.
1646 *
1647 * <p>If this method returned {@link OsConstants#EIO}, then we can't guarantee whether all, none
1648 * or part of the directories were cleared.
1649 */
1650 public static int clearAppCacheDirectories() {
1651 int status = 0;
1652 Log.i(TAG, "Clearing cache for all apps");
1653 final File rootDataDir = buildPath(Environment.getExternalStorageDirectory(),
1654 "Android", "data");
1655 for (File appDataDir : rootDataDir.listFiles()) {
1656 try {
1657 final File appCacheDir = new File(appDataDir, "cache");
1658 if (appCacheDir.isDirectory()) {
1659 FileUtils.deleteContents(appCacheDir);
1660 }
1661 } catch (Exception e) {
1662 // We want to avoid crashing MediaProvider at all costs, so we handle all "generic"
1663 // exceptions here, and just report to the caller that an IO exception has occurred.
1664 // We still try to clear the rest of the directories.
1665 Log.e(TAG, "Couldn't delete all app cache dirs!", e);
1666 status = OsConstants.EIO;
1667 }
1668 }
1669 return status;
1670 }
Zim7e730f32020-09-30 11:38:50 +01001671
1672 /**
Corina5deecbb2021-08-27 15:49:38 +01001673 * @return {@code true} if {@code dir} has nomedia and it is dirty directory, so it should be
1674 * scanned. Returns {@code false} otherwise.
Zim7e730f32020-09-30 11:38:50 +01001675 */
1676 public static boolean isDirectoryDirty(File dir) {
1677 File nomedia = new File(dir, ".nomedia");
Hyoungho Choi438b9c02021-11-26 18:45:42 +09001678
1679 // We return false for directories that don't have .nomedia
1680 if (!nomedia.exists()) {
1681 return false;
Zim7e730f32020-09-30 11:38:50 +01001682 }
Hyoungho Choi438b9c02021-11-26 18:45:42 +09001683
1684 // We don't write to ".nomedia" dirs, only to ".nomedia" files. If this ".nomedia" is not
1685 // a file, then don't try to read it.
1686 if (!nomedia.isFile()) {
1687 return true;
1688 }
1689
1690 try {
1691 Optional<String> expectedPath = readString(nomedia);
1692 // Returns true If .nomedia file is empty or content doesn't match |dir|
1693 // Returns false otherwise
1694 return !expectedPath.isPresent()
1695 || !expectedPath.get().equals(dir.getPath());
1696 } catch (IOException e) {
1697 Log.w(TAG, "Failed to read directory dirty" + dir);
1698 return true;
1699 }
Zim7e730f32020-09-30 11:38:50 +01001700 }
1701
1702 /**
1703 * {@code isDirty} == {@code true} will force {@code dir} scanning even if it's hidden
1704 * {@code isDirty} == {@code false} will skip {@code dir} scanning on next scan.
1705 */
1706 public static void setDirectoryDirty(File dir, boolean isDirty) {
1707 File nomedia = new File(dir, ".nomedia");
Hyoungho Choi438b9c02021-11-26 18:45:42 +09001708 if (nomedia.exists() && nomedia.isFile()) {
Zim7e730f32020-09-30 11:38:50 +01001709 try {
1710 writeString(nomedia, isDirty ? Optional.of("") : Optional.of(dir.getPath()));
1711 } catch (IOException e) {
1712 Log.w(TAG, "Failed to change directory dirty: " + dir + ". isDirty: " + isDirty);
1713 }
1714 }
1715 }
1716
1717 /**
1718 * @return the folder containing the top-most .nomedia in {@code file} hierarchy.
1719 * E.g input as /sdcard/foo/bar/ will return /sdcard/foo
1720 * even if foo and bar contain .nomedia files.
1721 *
1722 * Returns {@code null} if there's no .nomedia in hierarchy
1723 */
1724 public static File getTopLevelNoMedia(@NonNull File file) {
Sahana Rao7abc52c2020-12-07 16:02:03 +00001725 File topNoMediaDir = null;
Zim7e730f32020-09-30 11:38:50 +01001726
1727 File parent = file;
1728 while (parent != null) {
1729 File nomedia = new File(parent, ".nomedia");
1730 if (nomedia.exists()) {
Sahana Rao7abc52c2020-12-07 16:02:03 +00001731 topNoMediaDir = parent;
Zim7e730f32020-09-30 11:38:50 +01001732 }
1733 parent = parent.getParentFile();
1734 }
1735
Sahana Rao7abc52c2020-12-07 16:02:03 +00001736 return topNoMediaDir;
Zim7e730f32020-09-30 11:38:50 +01001737 }
Ivan Chiang447b3162021-03-29 10:52:05 +08001738
1739 /**
1740 * Generate the extended absolute path from the expired file path
1741 * E.g. the input expiredFilePath is /storage/emulated/0/DCIM/.trashed-1621147340-test.jpg
1742 * The returned result is /storage/emulated/0/DCIM/.trashed-1888888888-test.jpg
1743 *
1744 * @hide
1745 */
1746 @Nullable
1747 public static String getAbsoluteExtendedPath(@NonNull String expiredFilePath,
1748 long extendedTime) {
1749 final String displayName = extractDisplayName(expiredFilePath);
1750
1751 final Matcher matcher = PATTERN_EXPIRES_FILE.matcher(displayName);
1752 if (matcher.matches()) {
1753 final String newDisplayName = String.format(Locale.US, ".%s-%d-%s", matcher.group(1),
1754 extendedTime, matcher.group(3));
1755 final int lastSlash = expiredFilePath.lastIndexOf('/');
1756 final String newPath = expiredFilePath.substring(0, lastSlash + 1).concat(
1757 newDisplayName);
1758 return newPath;
1759 }
1760
1761 return null;
1762 }
Zimc1bbc5e2021-09-06 17:39:47 +01001763
1764 public static File buildPrimaryVolumeFile(int userId, String... segments) {
1765 return buildPath(new File("/storage/emulated/" + userId), segments);
1766 }
Hyoungho Choi48355432021-09-29 11:00:03 +09001767
1768 private static final String LOWER_FS_PREFIX = "/storage/";
1769 private static final String FUSE_FS_PREFIX = "/mnt/user/" + UserHandle.myUserId() + "/";
1770
1771 public static File toFuseFile(File file) {
1772 return new File(file.getPath().replaceFirst(LOWER_FS_PREFIX, FUSE_FS_PREFIX));
1773 }
1774
1775 public static File fromFuseFile(File file) {
1776 return new File(file.getPath().replaceFirst(FUSE_FS_PREFIX, LOWER_FS_PREFIX));
1777 }
Abhijeet Kaurc6282182022-11-23 08:47:27 +00001778
Sergey Nikolaienkov52e601d2023-03-28 12:22:31 +02001779 /**
1780 * Returns the canonical {@link File} for the provided abstract pathname.
1781 *
1782 * @return The canonical pathname string denoting the same file or directory as this abstract
1783 * pathname
1784 * @see File#getCanonicalFile()
1785 */
1786 @NonNull
1787 public static File getCanonicalFile(@NonNull String path) throws IOException {
1788 Objects.requireNonNull(path);
1789 return new File(path).getCanonicalFile();
1790 }
Abhijeet Kaurc6282182022-11-23 08:47:27 +00001791
Sergey Nikolaienkov52e601d2023-03-28 12:22:31 +02001792 /**
1793 * Returns the canonical pathname string of the provided abstract pathname.
1794 *
1795 * @return The canonical pathname string denoting the same file or directory as this abstract
1796 * pathname.
1797 * @see File#getCanonicalPath()
1798 */
1799 @NonNull
1800 public static String getCanonicalPath(@NonNull String path) throws IOException {
1801 Objects.requireNonNull(path);
1802 return new File(path).getCanonicalPath();
Abhijeet Kaurc6282182022-11-23 08:47:27 +00001803 }
Jeff Sharkey7ea24f22019-08-22 10:14:18 -06001804}