1 /* 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 17 package com.android.providers.media.util; 18 19 import static com.android.providers.media.util.LegacyLogging.TAG; 20 21 import android.content.ContentValues; 22 import android.os.Environment; 23 import android.os.SystemProperties; 24 import android.provider.MediaStore; 25 import android.provider.MediaStore.Audio.AudioColumns; 26 import android.provider.MediaStore.MediaColumns; 27 import android.text.TextUtils; 28 import android.util.ArrayMap; 29 import android.util.Log; 30 31 import androidx.annotation.NonNull; 32 import androidx.annotation.Nullable; 33 import androidx.annotation.VisibleForTesting; 34 35 import com.android.modules.utils.build.SdkLevel; 36 37 import java.io.File; 38 import java.io.IOException; 39 import java.nio.file.FileVisitResult; 40 import java.nio.file.FileVisitor; 41 import java.nio.file.Files; 42 import java.nio.file.Path; 43 import java.nio.file.attribute.BasicFileAttributes; 44 import java.util.Locale; 45 import java.util.Objects; 46 import java.util.function.Consumer; 47 import java.util.regex.Matcher; 48 import java.util.regex.Pattern; 49 50 public class LegacyFileUtils { 51 52 /** 53 * Recursively walk the contents of the given {@link Path}, invoking the 54 * given {@link Consumer} for every file and directory encountered. This is 55 * typically used for recursively deleting a directory tree. 56 * <p> 57 * Gracefully attempts to process as much as possible in the face of any 58 * failures. 59 */ walkFileTreeContents(@onNull Path path, @NonNull Consumer<Path> operation)60 public static void walkFileTreeContents(@NonNull Path path, @NonNull Consumer<Path> operation) { 61 try { 62 Files.walkFileTree(path, new FileVisitor<Path>() { 63 @Override 64 public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) { 65 return FileVisitResult.CONTINUE; 66 } 67 68 @Override 69 public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { 70 if (!Objects.equals(path, file)) { 71 operation.accept(file); 72 } 73 return FileVisitResult.CONTINUE; 74 } 75 76 @Override 77 public FileVisitResult visitFileFailed(Path file, IOException e) { 78 Log.w(TAG, "Failed to visit " + file, e); 79 return FileVisitResult.CONTINUE; 80 } 81 82 @Override 83 public FileVisitResult postVisitDirectory(Path dir, IOException e) { 84 if (!Objects.equals(path, dir)) { 85 operation.accept(dir); 86 } 87 return FileVisitResult.CONTINUE; 88 } 89 }); 90 } catch (IOException e) { 91 Log.w(TAG, "Failed to walk " + path, e); 92 } 93 } 94 95 /** 96 * Recursively delete all contents inside the given directory. Gracefully 97 * attempts to delete as much as possible in the face of any failures. 98 * 99 * @deprecated if you're calling this from inside {@code MediaProvider}, you 100 * likely want to call {@link #forEach} with a separate 101 * invocation to invalidate FUSE entries. 102 */ 103 @Deprecated deleteContents(@onNull File dir)104 public static void deleteContents(@NonNull File dir) { 105 walkFileTreeContents(dir.toPath(), (path) -> { 106 path.toFile().delete(); 107 }); 108 } 109 extractDisplayName(@ullable String data)110 public static @Nullable String extractDisplayName(@Nullable String data) { 111 if (data == null) return null; 112 if (data.indexOf('/') == -1) { 113 return data; 114 } 115 if (data.endsWith("/")) { 116 data = data.substring(0, data.length() - 1); 117 } 118 return data.substring(data.lastIndexOf('/') + 1); 119 } 120 extractFileExtension(@ullable String data)121 public static @Nullable String extractFileExtension(@Nullable String data) { 122 if (data == null) return null; 123 data = extractDisplayName(data); 124 125 final int lastDot = data.lastIndexOf('.'); 126 if (lastDot == -1) { 127 return null; 128 } else { 129 return data.substring(lastDot + 1); 130 } 131 } 132 133 public static final Pattern PATTERN_DOWNLOADS_FILE = Pattern.compile( 134 "(?i)^/storage/[^/]+/(?:[0-9]+/)?Download/.+"); 135 public static final Pattern PATTERN_EXPIRES_FILE = Pattern.compile( 136 "(?i)^\\.(pending|trashed)-(\\d+)-([^/]+)$"); 137 138 /** 139 * File prefix indicating that the file {@link MediaColumns#IS_PENDING}. 140 */ 141 public static final String PREFIX_PENDING = "pending"; 142 143 /** 144 * File prefix indicating that the file {@link MediaColumns#IS_TRASHED}. 145 */ 146 public static final String PREFIX_TRASHED = "trashed"; 147 148 private static final boolean PROP_CROSS_USER_ALLOWED = 149 SystemProperties.getBoolean("external_storage.cross_user.enabled", false); 150 151 private static final String PROP_CROSS_USER_ROOT = isCrossUserEnabled() 152 ? SystemProperties.get("external_storage.cross_user.root", "") : ""; 153 154 private static final String PROP_CROSS_USER_ROOT_PATTERN = ((PROP_CROSS_USER_ROOT.isEmpty()) 155 ? "" : "(?:" + PROP_CROSS_USER_ROOT + "/)?"); 156 157 /** 158 * Regex that matches paths in all well-known package-specific directories, 159 * and which captures the package name as the first group. 160 */ 161 public static final Pattern PATTERN_OWNED_PATH = Pattern.compile( 162 "(?i)^/storage/[^/]+/(?:[0-9]+/)?" 163 + PROP_CROSS_USER_ROOT_PATTERN 164 + "Android/(?:data|media|obb)/([^/]+)(/?.*)?"); 165 166 /** 167 * The recordings directory. This is used for R OS. For S OS or later, 168 * we use {@link Environment#DIRECTORY_RECORDINGS} directly. 169 */ 170 public static final String DIRECTORY_RECORDINGS = "Recordings"; 171 172 /** 173 * Regex that matches paths for {@link MediaColumns#RELATIVE_PATH} 174 */ 175 private static final Pattern PATTERN_RELATIVE_PATH = Pattern.compile( 176 "(?i)^/storage/(?:emulated/[0-9]+/|[^/]+/)"); 177 178 /** 179 * Regex that matches paths under well-known storage paths. 180 */ 181 private static final Pattern PATTERN_VOLUME_NAME = Pattern.compile( 182 "(?i)^/storage/([^/]+)"); 183 isCrossUserEnabled()184 public static boolean isCrossUserEnabled() { 185 return PROP_CROSS_USER_ALLOWED || SdkLevel.isAtLeastS(); 186 } 187 normalizeUuid(@ullable String fsUuid)188 private static @Nullable String normalizeUuid(@Nullable String fsUuid) { 189 return fsUuid != null ? fsUuid.toLowerCase(Locale.ROOT) : null; 190 } 191 extractVolumeName(@ullable String data)192 public static @Nullable String extractVolumeName(@Nullable String data) { 193 if (data == null) return null; 194 final Matcher matcher = PATTERN_VOLUME_NAME.matcher(data); 195 if (matcher.find()) { 196 final String volumeName = matcher.group(1); 197 if (volumeName.equals("emulated")) { 198 return MediaStore.VOLUME_EXTERNAL_PRIMARY; 199 } else { 200 return normalizeUuid(volumeName); 201 } 202 } else { 203 return MediaStore.VOLUME_INTERNAL; 204 } 205 } 206 extractRelativePath(@ullable String data)207 public static @Nullable String extractRelativePath(@Nullable String data) { 208 if (data == null) return null; 209 210 final String path; 211 try { 212 path = getCanonicalPath(data); 213 } catch (IOException e) { 214 Log.d(TAG, "Unable to get canonical path from invalid data path: " + data, e); 215 return null; 216 } 217 218 final Matcher matcher = PATTERN_RELATIVE_PATH.matcher(path); 219 if (matcher.find()) { 220 final int lastSlash = path.lastIndexOf('/'); 221 if (lastSlash == -1 || lastSlash < matcher.end()) { 222 // This is a file in the top-level directory, so relative path is "/" 223 // which is different than null, which means unknown path 224 return "/"; 225 } else { 226 return path.substring(matcher.end(), lastSlash + 1); 227 } 228 } else { 229 return null; 230 } 231 } 232 233 /** 234 * Compute several scattered {@link MediaColumns} values from 235 * {@link MediaColumns#DATA}. This method performs no enforcement of 236 * argument validity. 237 */ computeValuesFromData(@onNull ContentValues values, boolean isForFuse)238 public static void computeValuesFromData(@NonNull ContentValues values, boolean isForFuse) { 239 // Worst case we have to assume no bucket details 240 values.remove(MediaColumns.VOLUME_NAME); 241 values.remove(MediaColumns.RELATIVE_PATH); 242 values.remove(MediaColumns.IS_TRASHED); 243 values.remove(MediaColumns.DATE_EXPIRES); 244 values.remove(MediaColumns.DISPLAY_NAME); 245 values.remove(MediaColumns.BUCKET_ID); 246 values.remove(MediaColumns.BUCKET_DISPLAY_NAME); 247 248 String data = values.getAsString(MediaColumns.DATA); 249 if (TextUtils.isEmpty(data)) return; 250 251 try { 252 data = new File(data).getCanonicalPath(); 253 values.put(MediaColumns.DATA, data); 254 } catch (IOException e) { 255 throw new IllegalArgumentException( 256 String.format(Locale.ROOT, "Invalid file path:%s in request.", data)); 257 } 258 259 final File file = new File(data); 260 final File fileLower = new File(data.toLowerCase(Locale.ROOT)); 261 262 values.put(MediaColumns.VOLUME_NAME, extractVolumeName(data)); 263 values.put(MediaColumns.RELATIVE_PATH, extractRelativePath(data)); 264 final String displayName = extractDisplayName(data); 265 final Matcher matcher = LegacyFileUtils.PATTERN_EXPIRES_FILE.matcher(displayName); 266 if (matcher.matches()) { 267 values.put(MediaColumns.IS_PENDING, 268 matcher.group(1).equals(LegacyFileUtils.PREFIX_PENDING) ? 1 : 0); 269 values.put(MediaColumns.IS_TRASHED, 270 matcher.group(1).equals(LegacyFileUtils.PREFIX_TRASHED) ? 1 : 0); 271 values.put(MediaColumns.DATE_EXPIRES, Long.parseLong(matcher.group(2))); 272 values.put(MediaColumns.DISPLAY_NAME, matcher.group(3)); 273 } else { 274 if (isForFuse) { 275 // Allow Fuse thread to set IS_PENDING when using DATA column. 276 // TODO(b/156867379) Unset IS_PENDING when Fuse thread doesn't explicitly specify 277 // IS_PENDING. It can't be done now because we scan after create. Scan doesn't 278 // explicitly specify the value of IS_PENDING. 279 } else { 280 values.put(MediaColumns.IS_PENDING, 0); 281 } 282 values.put(MediaColumns.IS_TRASHED, 0); 283 values.putNull(MediaColumns.DATE_EXPIRES); 284 values.put(MediaColumns.DISPLAY_NAME, displayName); 285 } 286 287 // Buckets are the parent directory 288 final String parent = fileLower.getParent(); 289 if (parent != null) { 290 values.put(MediaColumns.BUCKET_ID, parent.hashCode()); 291 // The relative path for files in the top directory is "/" 292 if (!"/".equals(values.getAsString(MediaColumns.RELATIVE_PATH))) { 293 values.put(MediaColumns.BUCKET_DISPLAY_NAME, file.getParentFile().getName()); 294 } else { 295 values.putNull(MediaColumns.BUCKET_DISPLAY_NAME); 296 } 297 } 298 } 299 300 @VisibleForTesting 301 static ArrayMap<String, String> sAudioTypes = new ArrayMap<>(); 302 303 static { sAudioTypes.put(Environment.DIRECTORY_RINGTONES, AudioColumns.IS_RINGTONE)304 sAudioTypes.put(Environment.DIRECTORY_RINGTONES, AudioColumns.IS_RINGTONE); sAudioTypes.put(Environment.DIRECTORY_NOTIFICATIONS, AudioColumns.IS_NOTIFICATION)305 sAudioTypes.put(Environment.DIRECTORY_NOTIFICATIONS, AudioColumns.IS_NOTIFICATION); sAudioTypes.put(Environment.DIRECTORY_ALARMS, AudioColumns.IS_ALARM)306 sAudioTypes.put(Environment.DIRECTORY_ALARMS, AudioColumns.IS_ALARM); sAudioTypes.put(Environment.DIRECTORY_PODCASTS, AudioColumns.IS_PODCAST)307 sAudioTypes.put(Environment.DIRECTORY_PODCASTS, AudioColumns.IS_PODCAST); sAudioTypes.put(Environment.DIRECTORY_AUDIOBOOKS, AudioColumns.IS_AUDIOBOOK)308 sAudioTypes.put(Environment.DIRECTORY_AUDIOBOOKS, AudioColumns.IS_AUDIOBOOK); sAudioTypes.put(Environment.DIRECTORY_MUSIC, AudioColumns.IS_MUSIC)309 sAudioTypes.put(Environment.DIRECTORY_MUSIC, AudioColumns.IS_MUSIC); 310 if (SdkLevel.isAtLeastS()) { sAudioTypes.put(Environment.DIRECTORY_RECORDINGS, AudioColumns.IS_RECORDING)311 sAudioTypes.put(Environment.DIRECTORY_RECORDINGS, AudioColumns.IS_RECORDING); 312 } else { sAudioTypes.put(LegacyFileUtils.DIRECTORY_RECORDINGS, AudioColumns.IS_RECORDING)313 sAudioTypes.put(LegacyFileUtils.DIRECTORY_RECORDINGS, AudioColumns.IS_RECORDING); 314 } 315 } 316 317 /** 318 * Returns the canonical pathname string of the provided abstract pathname. 319 * 320 * @return The canonical pathname string denoting the same file or directory as this abstract 321 * pathname. 322 * @see File#getCanonicalPath() 323 */ 324 @NonNull getCanonicalPath(@onNull String path)325 public static String getCanonicalPath(@NonNull String path) throws IOException { 326 Objects.requireNonNull(path); 327 return new File(path).getCanonicalPath(); 328 } 329 330 } 331