diff options
author | 2025-02-11 18:52:32 +0000 | |
---|---|---|
committer | 2025-02-12 14:00:22 +0000 | |
commit | f04dc74d97bafee01a71b4ce7c497366880b3d4f (patch) | |
tree | 278b551e63b62081d9b469c42b028360ec34f9a9 | |
parent | eb4be252b7fac8003a25ce42d501a24b9ea369c7 (diff) |
Keep separate files for Legacy MediaProvider
Move files to legacy package which are necessary to build legacy MediaProvider. This change isolates legacy functionality so that modifications to the upstream files won't impact the legacy MediaProvider build. Additionally, remove the playlist and dao package since these are not required
Flag: EXEMPT, code refactor
Test: m MediaProviderLegacy
Bug: 395901424
Change-Id: I652a7e397e1de65a10cb742b6f18552b6b3e79dd
9 files changed, 1112 insertions, 23 deletions
diff --git a/Android.bp b/Android.bp index 9cb665dbe..78ec5fd6e 100644 --- a/Android.bp +++ b/Android.bp @@ -124,14 +124,12 @@ filegroup { java_library { name: "mediaprovider-database", srcs: [ - "src/com/android/providers/media/util/DatabaseUtils.java", - "src/com/android/providers/media/util/FileUtils.java", - "src/com/android/providers/media/util/ForegroundThread.java", - "src/com/android/providers/media/util/Logging.java", - "src/com/android/providers/media/util/MimeUtils.java", - "src/com/android/providers/media/util/StringUtils.java", - "src/com/android/providers/media/playlist/*.java", - "src/com/android/providers/media/dao/*.java", + "legacy/src/com/android/providers/media/util/LegacyDatabaseUtils.java", + "legacy/src/com/android/providers/media/util/LegacyFileUtils.java", + "legacy/src/com/android/providers/media/util/LegacyForegroundThread.java", + "legacy/src/com/android/providers/media/util/LegacyLogging.java", + "legacy/src/com/android/providers/media/util/LegacyMimeUtils.java", + "legacy/src/com/android/providers/media/util/LegacyStringUtils.java", ], sdk_version: "module_current", min_sdk_version: "30", diff --git a/legacy/src/com/android/providers/media/LegacyDatabaseHelper.java b/legacy/src/com/android/providers/media/LegacyDatabaseHelper.java index e13a39466..7bad81805 100644 --- a/legacy/src/com/android/providers/media/LegacyDatabaseHelper.java +++ b/legacy/src/com/android/providers/media/LegacyDatabaseHelper.java @@ -44,10 +44,10 @@ import androidx.annotation.NonNull; import androidx.annotation.VisibleForTesting; import com.android.modules.utils.BackgroundThread; -import com.android.providers.media.util.FileUtils; -import com.android.providers.media.util.ForegroundThread; -import com.android.providers.media.util.Logging; -import com.android.providers.media.util.MimeUtils; +import com.android.providers.media.util.LegacyFileUtils; +import com.android.providers.media.util.LegacyForegroundThread; +import com.android.providers.media.util.LegacyLogging; +import com.android.providers.media.util.LegacyMimeUtils; import java.io.File; import java.io.FilenameFilter; @@ -287,7 +287,7 @@ public class LegacyDatabaseHelper extends SQLiteOpenHelper implements AutoClosea // completely finish dispatching all change notifications before we // process background tasks, to ensure that the background work // doesn't steal resources from the more important foreground work - ForegroundThread.getExecutor().execute(() -> { + LegacyForegroundThread.getExecutor().execute(() -> { // Now that we've finished with all our important work, we can // finally kick off any internal background tasks for (int i = 0; i < state.backgroundTasks.size(); i++) { @@ -562,11 +562,11 @@ public class LegacyDatabaseHelper extends SQLiteOpenHelper implements AutoClosea // Derive new column value based on well-known paths try (Cursor c = db.query("files", new String[]{FileColumns._ID, FileColumns.DATA}, - FileColumns.DATA + " REGEXP '" + FileUtils.PATTERN_OWNED_PATH.pattern() + "'", + FileColumns.DATA + " REGEXP '" + LegacyFileUtils.PATTERN_OWNED_PATH.pattern() + "'", null, null, null, null, null)) { Log.d(TAG, "Updating " + c.getCount() + " entries with well-known owners"); - final Matcher m = FileUtils.PATTERN_OWNED_PATH.matcher(""); + final Matcher m = LegacyFileUtils.PATTERN_OWNED_PATH.matcher(""); final ContentValues values = new ContentValues(); while (c.moveToNext()) { @@ -621,7 +621,7 @@ public class LegacyDatabaseHelper extends SQLiteOpenHelper implements AutoClosea private static void updateSetIsDownload(SQLiteDatabase db) { db.execSQL("UPDATE files SET is_download=1 WHERE _data REGEXP '" - + FileUtils.PATTERN_DOWNLOADS_FILE + "'"); + + LegacyFileUtils.PATTERN_DOWNLOADS_FILE + "'"); } private static void updateAddExpiresAndTrashed(SQLiteDatabase db) { @@ -734,7 +734,7 @@ public class LegacyDatabaseHelper extends SQLiteOpenHelper implements AutoClosea while (c.moveToNext()) { final String time = c.getString(0); final String message = c.getString(1); - Logging.logPersistent("Historical log " + time + " " + message); + LegacyLogging.logPersistent("Historical log " + time + " " + message); } } db.execSQL("DELETE FROM log;"); @@ -787,7 +787,7 @@ public class LegacyDatabaseHelper extends SQLiteOpenHelper implements AutoClosea final long id = c.getLong(0); final String data = c.getString(1); values.put(FileColumns.DATA, data); - FileUtils.computeValuesFromData(values, /*isForFuse*/ false); + LegacyFileUtils.computeValuesFromData(values, /*isForFuse*/ false); values.remove(FileColumns.DATA); if (!values.isEmpty()) { db.update("files", values, "_id=" + id, null); @@ -811,9 +811,9 @@ public class LegacyDatabaseHelper extends SQLiteOpenHelper implements AutoClosea final long id = c.getLong(0); final String mimeType = c.getString(1); // Only update Document and Subtitle media type - if (MimeUtils.isSubtitleMimeType(mimeType)) { + if (LegacyMimeUtils.isSubtitleMimeType(mimeType)) { newMediaTypes.put(id, FileColumns.MEDIA_TYPE_SUBTITLE); - } else if (MimeUtils.isDocumentMimeType(mimeType)) { + } else if (LegacyMimeUtils.isDocumentMimeType(mimeType)) { newMediaTypes.put(id, FileColumns.MEDIA_TYPE_DOCUMENT); } } diff --git a/legacy/src/com/android/providers/media/LegacyMediaProvider.java b/legacy/src/com/android/providers/media/LegacyMediaProvider.java index 8b2224282..710602e80 100644 --- a/legacy/src/com/android/providers/media/LegacyMediaProvider.java +++ b/legacy/src/com/android/providers/media/LegacyMediaProvider.java @@ -37,7 +37,7 @@ import android.util.ArraySet; import androidx.annotation.NonNull; -import com.android.providers.media.util.Logging; +import com.android.providers.media.util.LegacyLogging; import java.io.File; import java.io.FileDescriptor; @@ -77,7 +77,7 @@ public class LegacyMediaProvider extends ContentProvider { final Context context = getContext(); final File persistentDir = context.getDir("logs", Context.MODE_PRIVATE); - Logging.initPersistent(persistentDir); + LegacyLogging.initPersistent(persistentDir); mInternalDatabase = new LegacyDatabaseHelper(context, INTERNAL_DATABASE_NAME, true); mExternalDatabase = new LegacyDatabaseHelper(context, EXTERNAL_DATABASE_NAME, true); @@ -239,6 +239,6 @@ public class LegacyMediaProvider extends ContentProvider { @Override public void dump(FileDescriptor fd, PrintWriter writer, String[] args) { - Logging.dumpPersistent(writer); + LegacyLogging.dumpPersistent(writer); } } diff --git a/legacy/src/com/android/providers/media/util/LegacyDatabaseUtils.java b/legacy/src/com/android/providers/media/util/LegacyDatabaseUtils.java new file mode 100644 index 000000000..fd6144a29 --- /dev/null +++ b/legacy/src/com/android/providers/media/util/LegacyDatabaseUtils.java @@ -0,0 +1,386 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.providers.media.util; + +import static android.content.ContentResolver.QUERY_ARG_GROUP_COLUMNS; +import static android.content.ContentResolver.QUERY_ARG_LIMIT; +import static android.content.ContentResolver.QUERY_ARG_OFFSET; +import static android.content.ContentResolver.QUERY_ARG_SORT_COLLATION; +import static android.content.ContentResolver.QUERY_ARG_SORT_COLUMNS; +import static android.content.ContentResolver.QUERY_ARG_SORT_DIRECTION; +import static android.content.ContentResolver.QUERY_ARG_SORT_LOCALE; +import static android.content.ContentResolver.QUERY_ARG_SQL_GROUP_BY; +import static android.content.ContentResolver.QUERY_ARG_SQL_LIMIT; +import static android.content.ContentResolver.QUERY_ARG_SQL_SORT_ORDER; +import static android.content.ContentResolver.QUERY_SORT_DIRECTION_ASCENDING; +import static android.content.ContentResolver.QUERY_SORT_DIRECTION_DESCENDING; + +import static com.android.providers.media.util.LegacyLogging.TAG; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteStatement; +import android.os.Bundle; +import android.text.TextUtils; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Locale; +import java.util.function.Consumer; +import java.util.function.Function; + +public class LegacyDatabaseUtils { + /** + * Bind the given selection with the given selection arguments. + * <p> + * Internally assumes that '?' is only ever used for arguments, and doesn't + * appear as a literal or escaped value. + * <p> + * This method is typically useful for trusted code that needs to cook up a + * fully-bound selection. + * + * @hide + */ + public static @Nullable String bindSelection(@Nullable String selection, + @Nullable Object... selectionArgs) { + if (selection == null) return null; + // If no arguments provided, so we can't bind anything + if ((selectionArgs == null) || (selectionArgs.length == 0)) return selection; + // If no bindings requested, so we can shortcut + if (selection.indexOf('?') == -1) return selection; + + // Track the chars immediately before and after each bind request, to + // decide if it needs additional whitespace added + char before = ' '; + char after = ' '; + + int argIndex = 0; + final int len = selection.length(); + final StringBuilder res = new StringBuilder(len); + for (int i = 0; i < len; ) { + char c = selection.charAt(i++); + if (c == '?') { + // Assume this bind request is guarded until we find a specific + // trailing character below + after = ' '; + + // Sniff forward to see if the selection is requesting a + // specific argument index + int start = i; + for (; i < len; i++) { + c = selection.charAt(i); + if (c < '0' || c > '9') { + after = c; + break; + } + } + if (start != i) { + argIndex = Integer.parseInt(selection.substring(start, i)) - 1; + } + + // Manually bind the argument into the selection, adding + // whitespace when needed for clarity + final Object arg = selectionArgs[argIndex++]; + if (before != ' ' && before != '=') res.append(' '); + switch (LegacyDatabaseUtils.getTypeOfObject(arg)) { + case Cursor.FIELD_TYPE_NULL: + res.append("NULL"); + break; + case Cursor.FIELD_TYPE_INTEGER: + res.append(((Number) arg).longValue()); + break; + case Cursor.FIELD_TYPE_FLOAT: + res.append(((Number) arg).doubleValue()); + break; + case Cursor.FIELD_TYPE_BLOB: + throw new IllegalArgumentException("Blobs not supported"); + case Cursor.FIELD_TYPE_STRING: + default: + if (arg instanceof Boolean) { + // Provide compatibility with legacy applications which may pass + // Boolean values in bind args. + res.append(((Boolean) arg).booleanValue() ? 1 : 0); + } else { + res.append('\''); + // Escape single quote character while appending the string and reject + // invalid unicode. + res.append(escapeSingleQuoteAndRejectInvalidUnicode(arg.toString())); + res.append('\''); + } + break; + } + if (after != ' ') res.append(' '); + } else { + res.append(c); + before = c; + } + } + return res.toString(); + } + + private static String escapeSingleQuoteAndRejectInvalidUnicode(@NonNull String target) { + final int len = target.length(); + final StringBuilder res = new StringBuilder(len); + boolean lastHigh = false; + + for (int i = 0; i < len; ) { + final char c = target.charAt(i++); + + if (lastHigh != Character.isLowSurrogate(c)) { + Log.e(TAG, "Invalid surrogate in string " + target); + throw new IllegalArgumentException("Invalid surrogate in string " + target); + } + + lastHigh = Character.isHighSurrogate(c); + + // Escape the single quotes by duplicating them + if (c == '\'') { + res.append(c); + } + + res.append(c); + } + + if (lastHigh) { + Log.e(TAG, "Invalid surrogate in string " + target); + throw new IllegalArgumentException("Invalid surrogate in string " + target); + } + + return res.toString(); + } + + /** + * Returns data type of the given object's value. + *<p> + * Returned values are + * <ul> + * <li>{@link Cursor#FIELD_TYPE_NULL}</li> + * <li>{@link Cursor#FIELD_TYPE_INTEGER}</li> + * <li>{@link Cursor#FIELD_TYPE_FLOAT}</li> + * <li>{@link Cursor#FIELD_TYPE_STRING}</li> + * <li>{@link Cursor#FIELD_TYPE_BLOB}</li> + *</ul> + *</p> + * + * @param obj the object whose value type is to be returned + * @return object value type + * @hide + */ + public static int getTypeOfObject(Object obj) { + if (obj == null) { + return Cursor.FIELD_TYPE_NULL; + } else if (obj instanceof byte[]) { + return Cursor.FIELD_TYPE_BLOB; + } else if (obj instanceof Float || obj instanceof Double) { + return Cursor.FIELD_TYPE_FLOAT; + } else if (obj instanceof Long || obj instanceof Integer + || obj instanceof Short || obj instanceof Byte) { + return Cursor.FIELD_TYPE_INTEGER; + } else { + return Cursor.FIELD_TYPE_STRING; + } + } + + /** + * Simple attempt to balance the given SQL expression by adding parenthesis + * when needed. + * <p> + * Since this is only used for recovering from abusive apps, we're not + * interested in trying to build a fully valid SQL parser up in Java. It'll + * give up when it encounters complex SQL, such as string literals. + */ + public static @Nullable String maybeBalance(@Nullable String sql) { + if (sql == null) return null; + + int count = 0; + char literal = '\0'; + for (int i = 0; i < sql.length(); i++) { + final char c = sql.charAt(i); + + if (c == '\'' || c == '"') { + if (literal == '\0') { + // Start literal + literal = c; + } else if (literal == c) { + // End literal + literal = '\0'; + } + } + + if (literal == '\0') { + if (c == '(') { + count++; + } else if (c == ')') { + count--; + } + } + } + while (count > 0) { + sql = sql + ")"; + count--; + } + while (count < 0) { + sql = "(" + sql; + count++; + } + return sql; + } + + private static void resolveGroupBy(@NonNull Bundle queryArgs, + @NonNull Consumer<String> honored) { + final String[] columns = queryArgs.getStringArray(QUERY_ARG_GROUP_COLUMNS); + if (columns != null && columns.length != 0) { + String groupBy = TextUtils.join(", ", columns); + honored.accept(QUERY_ARG_GROUP_COLUMNS); + + queryArgs.putString(QUERY_ARG_SQL_GROUP_BY, groupBy); + } else { + honored.accept(QUERY_ARG_SQL_GROUP_BY); + } + } + + private static void resolveSortOrder(@NonNull Bundle queryArgs, + @NonNull Consumer<String> honored, + @NonNull Function<String, String> collatorFactory) { + final String[] columns = queryArgs.getStringArray(QUERY_ARG_SORT_COLUMNS); + if (columns != null && columns.length != 0) { + String sortOrder = TextUtils.join(", ", columns); + honored.accept(QUERY_ARG_SORT_COLUMNS); + + if (queryArgs.containsKey(QUERY_ARG_SORT_LOCALE)) { + final String collatorName = collatorFactory.apply( + queryArgs.getString(QUERY_ARG_SORT_LOCALE)); + sortOrder += " COLLATE " + collatorName; + honored.accept(QUERY_ARG_SORT_LOCALE); + } else { + // Interpret PRIMARY and SECONDARY collation strength as no-case collation based + // on their javadoc descriptions. + final int collation = queryArgs.getInt( + QUERY_ARG_SORT_COLLATION, java.text.Collator.IDENTICAL); + switch (collation) { + case java.text.Collator.IDENTICAL: + honored.accept(QUERY_ARG_SORT_COLLATION); + break; + case java.text.Collator.PRIMARY: + case java.text.Collator.SECONDARY: + sortOrder += " COLLATE NOCASE"; + honored.accept(QUERY_ARG_SORT_COLLATION); + break; + } + } + + final int sortDir = queryArgs.getInt(QUERY_ARG_SORT_DIRECTION, Integer.MIN_VALUE); + switch (sortDir) { + case QUERY_SORT_DIRECTION_ASCENDING: + sortOrder += " ASC"; + honored.accept(QUERY_ARG_SORT_DIRECTION); + break; + case QUERY_SORT_DIRECTION_DESCENDING: + sortOrder += " DESC"; + honored.accept(QUERY_ARG_SORT_DIRECTION); + break; + } + + queryArgs.putString(QUERY_ARG_SQL_SORT_ORDER, sortOrder); + } else { + honored.accept(QUERY_ARG_SQL_SORT_ORDER); + } + } + + private static void resolveLimit(@NonNull Bundle queryArgs, + @NonNull Consumer<String> honored) { + final int limit = queryArgs.getInt(QUERY_ARG_LIMIT, Integer.MIN_VALUE); + if (limit != Integer.MIN_VALUE) { + String limitString = Integer.toString(limit); + honored.accept(QUERY_ARG_LIMIT); + + final int offset = queryArgs.getInt(QUERY_ARG_OFFSET, Integer.MIN_VALUE); + if (offset != Integer.MIN_VALUE) { + limitString += " OFFSET " + offset; + honored.accept(QUERY_ARG_OFFSET); + } + + queryArgs.putString(QUERY_ARG_SQL_LIMIT, limitString); + } else { + honored.accept(QUERY_ARG_SQL_LIMIT); + } + } + + private static void bindArgs(@NonNull SQLiteStatement st, @Nullable Object[] bindArgs) { + if (bindArgs == null) return; + + for (int i = 0; i < bindArgs.length; i++) { + final Object bindArg = bindArgs[i]; + switch (getTypeOfObject(bindArg)) { + case Cursor.FIELD_TYPE_NULL: + st.bindNull(i + 1); + break; + case Cursor.FIELD_TYPE_INTEGER: + st.bindLong(i + 1, ((Number) bindArg).longValue()); + break; + case Cursor.FIELD_TYPE_FLOAT: + st.bindDouble(i + 1, ((Number) bindArg).doubleValue()); + break; + case Cursor.FIELD_TYPE_BLOB: + st.bindBlob(i + 1, (byte[]) bindArg); + break; + case Cursor.FIELD_TYPE_STRING: + default: + if (bindArg instanceof Boolean) { + // Provide compatibility with legacy + // applications which may pass Boolean values in + // bind args. + st.bindLong(i + 1, ((Boolean) bindArg).booleanValue() ? 1 : 0); + } else { + st.bindString(i + 1, bindArg.toString()); + } + break; + } + } + } + + public static boolean parseBoolean(@Nullable Object value, boolean def) { + if (value instanceof Boolean) { + return (Boolean) value; + } else if (value instanceof Number) { + return ((Number) value).intValue() != 0; + } else if (value instanceof String) { + final String stringValue = ((String) value).toLowerCase(Locale.ROOT); + return (!"false".equals(stringValue) && !"0".equals(stringValue)); + } else { + return def; + } + } + + public static boolean getAsBoolean(@NonNull Bundle extras, + @NonNull String key, boolean def) { + return parseBoolean(extras.get(key), def); + } + + public static boolean getAsBoolean(@NonNull ContentValues values, + @NonNull String key, boolean def) { + return parseBoolean(values.get(key), def); + } + + public static long getAsLong(@NonNull ContentValues values, + @NonNull String key, long def) { + final Long value = values.getAsLong(key); + return (value != null) ? value : def; + } +} diff --git a/legacy/src/com/android/providers/media/util/LegacyFileUtils.java b/legacy/src/com/android/providers/media/util/LegacyFileUtils.java new file mode 100644 index 000000000..3e2b9f0b4 --- /dev/null +++ b/legacy/src/com/android/providers/media/util/LegacyFileUtils.java @@ -0,0 +1,330 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.providers.media.util; + +import static com.android.providers.media.util.LegacyLogging.TAG; + +import android.content.ContentValues; +import android.os.Environment; +import android.os.SystemProperties; +import android.provider.MediaStore; +import android.provider.MediaStore.Audio.AudioColumns; +import android.provider.MediaStore.MediaColumns; +import android.text.TextUtils; +import android.util.ArrayMap; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +import com.android.modules.utils.build.SdkLevel; + +import java.io.File; +import java.io.IOException; +import java.nio.file.FileVisitResult; +import java.nio.file.FileVisitor; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.Locale; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class LegacyFileUtils { + + /** + * Recursively walk the contents of the given {@link Path}, invoking the + * given {@link Consumer} for every file and directory encountered. This is + * typically used for recursively deleting a directory tree. + * <p> + * Gracefully attempts to process as much as possible in the face of any + * failures. + */ + public static void walkFileTreeContents(@NonNull Path path, @NonNull Consumer<Path> operation) { + try { + Files.walkFileTree(path, new FileVisitor<Path>() { + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) { + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { + if (!Objects.equals(path, file)) { + operation.accept(file); + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFileFailed(Path file, IOException e) { + Log.w(TAG, "Failed to visit " + file, e); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException e) { + if (!Objects.equals(path, dir)) { + operation.accept(dir); + } + return FileVisitResult.CONTINUE; + } + }); + } catch (IOException e) { + Log.w(TAG, "Failed to walk " + path, e); + } + } + + /** + * Recursively delete all contents inside the given directory. Gracefully + * attempts to delete as much as possible in the face of any failures. + * + * @deprecated if you're calling this from inside {@code MediaProvider}, you + * likely want to call {@link #forEach} with a separate + * invocation to invalidate FUSE entries. + */ + @Deprecated + public static void deleteContents(@NonNull File dir) { + walkFileTreeContents(dir.toPath(), (path) -> { + path.toFile().delete(); + }); + } + + public static @Nullable String extractDisplayName(@Nullable String data) { + if (data == null) return null; + if (data.indexOf('/') == -1) { + return data; + } + if (data.endsWith("/")) { + data = data.substring(0, data.length() - 1); + } + return data.substring(data.lastIndexOf('/') + 1); + } + + public static @Nullable String extractFileExtension(@Nullable String data) { + if (data == null) return null; + data = extractDisplayName(data); + + final int lastDot = data.lastIndexOf('.'); + if (lastDot == -1) { + return null; + } else { + return data.substring(lastDot + 1); + } + } + + public static final Pattern PATTERN_DOWNLOADS_FILE = Pattern.compile( + "(?i)^/storage/[^/]+/(?:[0-9]+/)?Download/.+"); + public static final Pattern PATTERN_EXPIRES_FILE = Pattern.compile( + "(?i)^\\.(pending|trashed)-(\\d+)-([^/]+)$"); + + /** + * File prefix indicating that the file {@link MediaColumns#IS_PENDING}. + */ + public static final String PREFIX_PENDING = "pending"; + + /** + * File prefix indicating that the file {@link MediaColumns#IS_TRASHED}. + */ + public static final String PREFIX_TRASHED = "trashed"; + + private static final boolean PROP_CROSS_USER_ALLOWED = + SystemProperties.getBoolean("external_storage.cross_user.enabled", false); + + private static final String PROP_CROSS_USER_ROOT = isCrossUserEnabled() + ? SystemProperties.get("external_storage.cross_user.root", "") : ""; + + private static final String PROP_CROSS_USER_ROOT_PATTERN = ((PROP_CROSS_USER_ROOT.isEmpty()) + ? "" : "(?:" + PROP_CROSS_USER_ROOT + "/)?"); + + /** + * Regex that matches paths in all well-known package-specific directories, + * and which captures the package name as the first group. + */ + public static final Pattern PATTERN_OWNED_PATH = Pattern.compile( + "(?i)^/storage/[^/]+/(?:[0-9]+/)?" + + PROP_CROSS_USER_ROOT_PATTERN + + "Android/(?:data|media|obb)/([^/]+)(/?.*)?"); + + /** + * The recordings directory. This is used for R OS. For S OS or later, + * we use {@link Environment#DIRECTORY_RECORDINGS} directly. + */ + public static final String DIRECTORY_RECORDINGS = "Recordings"; + + /** + * Regex that matches paths for {@link MediaColumns#RELATIVE_PATH} + */ + private static final Pattern PATTERN_RELATIVE_PATH = Pattern.compile( + "(?i)^/storage/(?:emulated/[0-9]+/|[^/]+/)"); + + /** + * Regex that matches paths under well-known storage paths. + */ + private static final Pattern PATTERN_VOLUME_NAME = Pattern.compile( + "(?i)^/storage/([^/]+)"); + + public static boolean isCrossUserEnabled() { + return PROP_CROSS_USER_ALLOWED || SdkLevel.isAtLeastS(); + } + + private static @Nullable String normalizeUuid(@Nullable String fsUuid) { + return fsUuid != null ? fsUuid.toLowerCase(Locale.ROOT) : null; + } + + public static @Nullable String extractVolumeName(@Nullable String data) { + if (data == null) return null; + final Matcher matcher = PATTERN_VOLUME_NAME.matcher(data); + if (matcher.find()) { + final String volumeName = matcher.group(1); + if (volumeName.equals("emulated")) { + return MediaStore.VOLUME_EXTERNAL_PRIMARY; + } else { + return normalizeUuid(volumeName); + } + } else { + return MediaStore.VOLUME_INTERNAL; + } + } + + public static @Nullable String extractRelativePath(@Nullable String data) { + if (data == null) return null; + + final String path; + try { + path = getCanonicalPath(data); + } catch (IOException e) { + Log.d(TAG, "Unable to get canonical path from invalid data path: " + data, e); + return null; + } + + final Matcher matcher = PATTERN_RELATIVE_PATH.matcher(path); + if (matcher.find()) { + final int lastSlash = path.lastIndexOf('/'); + if (lastSlash == -1 || lastSlash < matcher.end()) { + // This is a file in the top-level directory, so relative path is "/" + // which is different than null, which means unknown path + return "/"; + } else { + return path.substring(matcher.end(), lastSlash + 1); + } + } else { + return null; + } + } + + /** + * Compute several scattered {@link MediaColumns} values from + * {@link MediaColumns#DATA}. This method performs no enforcement of + * argument validity. + */ + public static void computeValuesFromData(@NonNull ContentValues values, boolean isForFuse) { + // Worst case we have to assume no bucket details + values.remove(MediaColumns.VOLUME_NAME); + values.remove(MediaColumns.RELATIVE_PATH); + values.remove(MediaColumns.IS_TRASHED); + values.remove(MediaColumns.DATE_EXPIRES); + values.remove(MediaColumns.DISPLAY_NAME); + values.remove(MediaColumns.BUCKET_ID); + values.remove(MediaColumns.BUCKET_DISPLAY_NAME); + + String data = values.getAsString(MediaColumns.DATA); + if (TextUtils.isEmpty(data)) return; + + try { + data = new File(data).getCanonicalPath(); + values.put(MediaColumns.DATA, data); + } catch (IOException e) { + throw new IllegalArgumentException( + String.format(Locale.ROOT, "Invalid file path:%s in request.", data)); + } + + final File file = new File(data); + final File fileLower = new File(data.toLowerCase(Locale.ROOT)); + + values.put(MediaColumns.VOLUME_NAME, extractVolumeName(data)); + values.put(MediaColumns.RELATIVE_PATH, extractRelativePath(data)); + final String displayName = extractDisplayName(data); + final Matcher matcher = LegacyFileUtils.PATTERN_EXPIRES_FILE.matcher(displayName); + if (matcher.matches()) { + values.put(MediaColumns.IS_PENDING, + matcher.group(1).equals(LegacyFileUtils.PREFIX_PENDING) ? 1 : 0); + values.put(MediaColumns.IS_TRASHED, + matcher.group(1).equals(LegacyFileUtils.PREFIX_TRASHED) ? 1 : 0); + values.put(MediaColumns.DATE_EXPIRES, Long.parseLong(matcher.group(2))); + values.put(MediaColumns.DISPLAY_NAME, matcher.group(3)); + } else { + if (isForFuse) { + // Allow Fuse thread to set IS_PENDING when using DATA column. + // TODO(b/156867379) Unset IS_PENDING when Fuse thread doesn't explicitly specify + // IS_PENDING. It can't be done now because we scan after create. Scan doesn't + // explicitly specify the value of IS_PENDING. + } else { + values.put(MediaColumns.IS_PENDING, 0); + } + values.put(MediaColumns.IS_TRASHED, 0); + values.putNull(MediaColumns.DATE_EXPIRES); + values.put(MediaColumns.DISPLAY_NAME, displayName); + } + + // Buckets are the parent directory + final String parent = fileLower.getParent(); + if (parent != null) { + values.put(MediaColumns.BUCKET_ID, parent.hashCode()); + // The relative path for files in the top directory is "/" + if (!"/".equals(values.getAsString(MediaColumns.RELATIVE_PATH))) { + values.put(MediaColumns.BUCKET_DISPLAY_NAME, file.getParentFile().getName()); + } else { + values.putNull(MediaColumns.BUCKET_DISPLAY_NAME); + } + } + } + + @VisibleForTesting + static ArrayMap<String, String> sAudioTypes = new ArrayMap<>(); + + static { + sAudioTypes.put(Environment.DIRECTORY_RINGTONES, AudioColumns.IS_RINGTONE); + sAudioTypes.put(Environment.DIRECTORY_NOTIFICATIONS, AudioColumns.IS_NOTIFICATION); + sAudioTypes.put(Environment.DIRECTORY_ALARMS, AudioColumns.IS_ALARM); + sAudioTypes.put(Environment.DIRECTORY_PODCASTS, AudioColumns.IS_PODCAST); + sAudioTypes.put(Environment.DIRECTORY_AUDIOBOOKS, AudioColumns.IS_AUDIOBOOK); + sAudioTypes.put(Environment.DIRECTORY_MUSIC, AudioColumns.IS_MUSIC); + if (SdkLevel.isAtLeastS()) { + sAudioTypes.put(Environment.DIRECTORY_RECORDINGS, AudioColumns.IS_RECORDING); + } else { + sAudioTypes.put(LegacyFileUtils.DIRECTORY_RECORDINGS, AudioColumns.IS_RECORDING); + } + } + + /** + * Returns the canonical pathname string of the provided abstract pathname. + * + * @return The canonical pathname string denoting the same file or directory as this abstract + * pathname. + * @see File#getCanonicalPath() + */ + @NonNull + public static String getCanonicalPath(@NonNull String path) throws IOException { + Objects.requireNonNull(path); + return new File(path).getCanonicalPath(); + } + +} diff --git a/legacy/src/com/android/providers/media/util/LegacyForegroundThread.java b/legacy/src/com/android/providers/media/util/LegacyForegroundThread.java new file mode 100644 index 000000000..0dcef6fbb --- /dev/null +++ b/legacy/src/com/android/providers/media/util/LegacyForegroundThread.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.providers.media.util; + +import android.os.Handler; +import android.os.HandlerThread; + +import com.android.modules.utils.HandlerExecutor; + +import java.util.concurrent.Executor; + +/** + * Thread for asynchronous event processing. This thread is configured as + * {@link android.os.Process#THREAD_PRIORITY_FOREGROUND}, which means more CPU + * resources will be dedicated to it, and it will be treated like "a user + * interface that the user is interacting with." + * <p> + * This thread is best suited for tasks that the user is actively waiting for, + * or for tasks that the user expects to be executed immediately. + * + * @see BackgroundThread + */ +public final class LegacyForegroundThread extends HandlerThread { + private static LegacyForegroundThread sInstance; + private static Handler sHandler; + private static HandlerExecutor sHandlerExecutor; + + private LegacyForegroundThread() { + super("fg", android.os.Process.THREAD_PRIORITY_FOREGROUND); + } + + private static void ensureThreadLocked() { + if (sInstance == null) { + sInstance = new LegacyForegroundThread(); + sInstance.start(); + sHandler = new Handler(sInstance.getLooper()); + sHandlerExecutor = new HandlerExecutor(sHandler); + } + } + + public static Executor getExecutor() { + synchronized (LegacyForegroundThread.class) { + ensureThreadLocked(); + return sHandlerExecutor; + } + } + +} diff --git a/legacy/src/com/android/providers/media/util/LegacyLogging.java b/legacy/src/com/android/providers/media/util/LegacyLogging.java new file mode 100644 index 000000000..a85b3b807 --- /dev/null +++ b/legacy/src/com/android/providers/media/util/LegacyLogging.java @@ -0,0 +1,157 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.providers.media.util; + +import static java.nio.file.StandardOpenOption.APPEND; +import static java.nio.file.StandardOpenOption.CREATE; + +import android.util.Log; + +import androidx.annotation.GuardedBy; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.File; +import java.io.IOException; +import java.io.PrintWriter; +import java.io.Writer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.stream.Stream; + +public class LegacyLogging { + public static final String TAG = "LegacyMediaProvider"; + + /** Size limit of each persistent log file, in bytes */ + private static final int PERSISTENT_SIZE = 32 * 1024; + private static final SimpleDateFormat DATE_FORMAT = + new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.ROOT); + private static final Object LOCK = new Object(); + + @GuardedBy("LOCK") + private static Path sPersistentDir; + @GuardedBy("LOCK") + private static Path sPersistentFile; + @GuardedBy("LOCK") + private static Writer sWriter; + + /** + * Initialize persistent logging which is then available through + * {@link #logPersistent(String)} and {@link #dumpPersistent(PrintWriter)}. + */ + public static void initPersistent(@NonNull File persistentDir) { + synchronized (LOCK) { + sPersistentDir = persistentDir.toPath(); + closeWriterAndUpdatePathLocked(null); + } + } + + /** + * Write the given message to persistent logs. + */ + public static void logPersistent(@NonNull String format, @Nullable Object ... args) { + final String msg = (args == null || args.length == 0) + ? format : String.format(Locale.ROOT, format, args); + + Log.i(TAG, msg); + + synchronized (LOCK) { + if (sPersistentDir == null) return; + + try { + Path path = resolveCurrentPersistentFileLocked(); + if (!path.equals(sPersistentFile)) { + closeWriterAndUpdatePathLocked(path); + } + + if (sWriter == null) { + sWriter = Files.newBufferedWriter(path, CREATE, APPEND); + } + + sWriter.write(DATE_FORMAT.format(new Date()) + " " + msg + "\n"); + // Flush to guarantee that all our writes have been sent to the filesystem + sWriter.flush(); + } catch (IOException e) { + closeWriterAndUpdatePathLocked(null); + Log.w(TAG, "Failed to write: " + sPersistentFile, e); + } + } + } + + @GuardedBy("LOCK") + private static void closeWriterAndUpdatePathLocked(@Nullable Path newPath) { + if (sWriter != null) { + try { + sWriter.close(); + } catch (IOException ignored) { + Log.w(TAG, "Failed to close: " + sPersistentFile, ignored); + } + sWriter = null; + } + sPersistentFile = newPath; + } + + /** + * Dump any persistent logs. + */ + public static void dumpPersistent(@NonNull PrintWriter pw) { + Path persistentDir = null; + synchronized (LOCK) { + if (sPersistentDir == null) return; + persistentDir = sPersistentDir; + } + + try (Stream<Path> stream = Files.list(persistentDir)) { + stream.sorted().forEach((path) -> { + dumpPersistentFile(path, pw); + }); + } catch (IOException e) { + pw.println(e.getMessage()); + pw.println(); + } + } + + private static void dumpPersistentFile(@NonNull Path path, @NonNull PrintWriter pw) { + pw.println("Persistent logs in " + path + ":"); + try (Stream<String> stream = Files.lines(path)) { + stream.forEach((line) -> { + pw.println(" " + line); + }); + pw.println(); + } catch (IOException e) { + pw.println(" " + e.getMessage()); + pw.println(); + } + } + + /** + * Resolve the current log file to write new entries into. Automatically + * starts new files when the current file is larger than + * {@link #PERSISTENT_SIZE}. + */ + @GuardedBy("LOCK") + private static @NonNull Path resolveCurrentPersistentFileLocked() throws IOException { + if (sPersistentFile != null && sPersistentFile.toFile().length() < PERSISTENT_SIZE) { + return sPersistentFile; + } + + return sPersistentDir.resolve(String.valueOf(System.currentTimeMillis())); + } +} diff --git a/legacy/src/com/android/providers/media/util/LegacyMimeUtils.java b/legacy/src/com/android/providers/media/util/LegacyMimeUtils.java new file mode 100644 index 000000000..6f7a82b88 --- /dev/null +++ b/legacy/src/com/android/providers/media/util/LegacyMimeUtils.java @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.providers.media.util; + +import androidx.annotation.Nullable; + +import java.util.Locale; + +public class LegacyMimeUtils { + + /** + * Returns true if MIME type represents a subtitle + * + * @param mimeType MIME type string + * @return true if mimeType matches a subtitle type + */ + public static boolean isSubtitleMimeType(@Nullable String mimeType) { + if (mimeType == null) return false; + switch (mimeType.toLowerCase(Locale.ROOT)) { + case "application/lrc": + case "application/smil+xml": + case "application/ttml+xml": + case "application/x-extension-cap": + case "application/x-extension-srt": + case "application/x-extension-sub": + case "application/x-extension-vtt": + case "application/x-subrip": + case "text/vtt": + return true; + default: + return false; + } + } + + /** + * Returns true if MIME type represents a document + * + * @param mimeType MIME type string + * @return true if mimeType is text or a known document format + */ + public static boolean isDocumentMimeType(@Nullable String mimeType) { + if (mimeType == null) return false; + + if (LegacyStringUtils.startsWithIgnoreCase(mimeType, "text/")) return true; + + switch (mimeType.toLowerCase(Locale.ROOT)) { + case "application/epub+zip": + case "application/msword": + case "application/pdf": + case "application/rtf": + case "application/vnd.ms-excel": + case "application/vnd.ms-excel.addin.macroenabled.12": + case "application/vnd.ms-excel.sheet.binary.macroenabled.12": + case "application/vnd.ms-excel.sheet.macroenabled.12": + case "application/vnd.ms-excel.template.macroenabled.12": + case "application/vnd.ms-powerpoint": + case "application/vnd.ms-powerpoint.addin.macroenabled.12": + case "application/vnd.ms-powerpoint.presentation.macroenabled.12": + case "application/vnd.ms-powerpoint.slideshow.macroenabled.12": + case "application/vnd.ms-powerpoint.template.macroenabled.12": + case "application/vnd.ms-word.document.macroenabled.12": + case "application/vnd.ms-word.template.macroenabled.12": + case "application/vnd.oasis.opendocument.chart": + case "application/vnd.oasis.opendocument.database": + case "application/vnd.oasis.opendocument.formula": + case "application/vnd.oasis.opendocument.graphics": + case "application/vnd.oasis.opendocument.graphics-template": + case "application/vnd.oasis.opendocument.presentation": + case "application/vnd.oasis.opendocument.presentation-template": + case "application/vnd.oasis.opendocument.spreadsheet": + case "application/vnd.oasis.opendocument.spreadsheet-template": + case "application/vnd.oasis.opendocument.text": + case "application/vnd.oasis.opendocument.text-master": + case "application/vnd.oasis.opendocument.text-template": + case "application/vnd.oasis.opendocument.text-web": + case "application/vnd.openxmlformats-officedocument.presentationml.presentation": + case "application/vnd.openxmlformats-officedocument.presentationml.slideshow": + case "application/vnd.openxmlformats-officedocument.presentationml.template": + case "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": + case "application/vnd.openxmlformats-officedocument.spreadsheetml.template": + case "application/vnd.openxmlformats-officedocument.wordprocessingml.document": + case "application/vnd.openxmlformats-officedocument.wordprocessingml.template": + case "application/vnd.stardivision.calc": + case "application/vnd.stardivision.chart": + case "application/vnd.stardivision.draw": + case "application/vnd.stardivision.impress": + case "application/vnd.stardivision.impress-packed": + case "application/vnd.stardivision.mail": + case "application/vnd.stardivision.math": + case "application/vnd.stardivision.writer": + case "application/vnd.stardivision.writer-global": + case "application/vnd.sun.xml.calc": + case "application/vnd.sun.xml.calc.template": + case "application/vnd.sun.xml.draw": + case "application/vnd.sun.xml.draw.template": + case "application/vnd.sun.xml.impress": + case "application/vnd.sun.xml.impress.template": + case "application/vnd.sun.xml.math": + case "application/vnd.sun.xml.writer": + case "application/vnd.sun.xml.writer.global": + case "application/vnd.sun.xml.writer.template": + case "application/x-mspublisher": + return true; + default: + return false; + } + } + +} diff --git a/legacy/src/com/android/providers/media/util/LegacyStringUtils.java b/legacy/src/com/android/providers/media/util/LegacyStringUtils.java new file mode 100644 index 000000000..d92fd5808 --- /dev/null +++ b/legacy/src/com/android/providers/media/util/LegacyStringUtils.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.providers.media.util; + +import androidx.annotation.Nullable; + +public class LegacyStringUtils { + + /** + * Variant of {@link String#startsWith(String)} but which tests with + * case-insensitivity. + */ + public static boolean startsWithIgnoreCase(@Nullable String target, @Nullable String other) { + if (target == null || other == null) return false; + if (other.length() > target.length()) return false; + return target.regionMatches(true, 0, other, 0, other.length()); + } + +} |