1 /* 2 * Copyright (C) 2025 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 android.content.ContentValues; 20 import android.content.Context; 21 import android.database.Cursor; 22 import android.database.sqlite.SQLiteDatabase; 23 import android.provider.MediaStore; 24 import android.util.Log; 25 26 import com.android.providers.media.R; 27 28 import java.io.BufferedReader; 29 import java.io.File; 30 import java.io.IOException; 31 import java.io.InputStream; 32 import java.io.InputStreamReader; 33 import java.util.ArrayList; 34 import java.util.HashMap; 35 import java.util.List; 36 import java.util.Locale; 37 import java.util.Map; 38 import java.util.Optional; 39 40 /** 41 * Utility class for handling MIME type mappings. 42 */ 43 public final class MimeTypeFixHandler { 44 45 private static final String TAG = "MimeTypeFixHandler"; 46 private static final Map<String, String> sExtToMimeType = new HashMap<>(); 47 private static final Map<String, String> sMimeTypeToExt = new HashMap<>(); 48 49 private static final Map<String, String> sCorruptedExtToMimeType = new HashMap<>(); 50 private static final Map<String, String> sCorruptedMimeTypeToExt = new HashMap<>(); 51 52 /** 53 * Loads MIME type mappings from the classpath resource if not already loaded. 54 * <p> 55 * This method initializes both the standard and corrupted MIME type maps. 56 * </p> 57 */ loadMimeTypes(Context context)58 public static void loadMimeTypes(Context context) { 59 if (context == null) { 60 return; 61 } 62 63 if (sExtToMimeType.isEmpty()) { 64 parseTypes(context, R.raw.mime_types, sExtToMimeType, sMimeTypeToExt); 65 // this will add or override the extension to mime type mapping 66 parseTypes(context, R.raw.android_mime_types, sExtToMimeType, sMimeTypeToExt); 67 Log.v(TAG, "MIME types loaded"); 68 } 69 if (sCorruptedExtToMimeType.isEmpty()) { 70 parseTypes(context, R.raw.corrupted_mime_types, sCorruptedExtToMimeType, 71 sCorruptedMimeTypeToExt); 72 Log.v(TAG, "Corrupted MIME types loaded"); 73 } 74 75 } 76 77 /** 78 * Parses the specified mime types file and populates the provided mapping with file extension 79 * to MIME type entries. 80 * 81 * @param resource the mime.type resource 82 * @param mapping the map to populate with file extension (key) to MIME type (value) mappings 83 */ parseTypes(Context context, int resource, Map<String, String> extToMimeType, Map<String, String> mimeTypeToExt)84 private static void parseTypes(Context context, int resource, Map<String, String> extToMimeType, 85 Map<String, String> mimeTypeToExt) { 86 try (InputStream inputStream = context.getResources().openRawResource(resource)) { 87 try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { 88 String line; 89 while ((line = reader.readLine()) != null) { 90 // Strip comments and normalize whitespace 91 line = line.replaceAll("#.*$", "").trim().replaceAll("\\s+", " "); 92 // Skip empty lines or lines without a space (i.e., no extension mapping) 93 if (line.isEmpty() || !line.contains(" ")) { 94 continue; 95 } 96 String[] tokens = line.split(" "); 97 if (tokens.length < 2) { 98 continue; 99 } 100 String mimeType = tokens[0].toLowerCase(Locale.ROOT); 101 String firstExt = tokens[1].toLowerCase(Locale.ROOT); 102 if (firstExt.startsWith("?")) { 103 firstExt = firstExt.substring(1); 104 if (firstExt.isEmpty()) { 105 continue; 106 } 107 } 108 109 // ?mime ext1 ?ext2 ext3 110 if (mimeType.toLowerCase(Locale.ROOT).startsWith("?")) { 111 mimeType = mimeType.substring(1); // Remove the "?" 112 if (mimeType.isEmpty()) { 113 continue; 114 } 115 mimeTypeToExt.putIfAbsent(mimeType, firstExt); 116 } else { 117 mimeTypeToExt.put(mimeType, firstExt); 118 } 119 120 for (int i = 1; i < tokens.length; i++) { 121 String extension = tokens[i].toLowerCase(Locale.ROOT); 122 boolean putIfAbsent = extension.startsWith("?"); 123 if (putIfAbsent) { 124 extension = extension.substring(1); // Remove the "?" 125 extToMimeType.putIfAbsent(extension, mimeType); 126 } else { 127 extToMimeType.put(extension, mimeType); 128 } 129 } 130 } 131 } 132 } catch (IOException | RuntimeException e) { 133 Log.e(TAG, "Exception raised while parsing mime.types", e); 134 } 135 } 136 137 /** 138 * Returns the MIME type for the given file extension from our internal mappings. 139 * 140 * @param extension The file extension to look up. 141 * @return The associated MIME type from the primary mapping if available, or 142 * {@link android.content.ClipDescription#MIMETYPE_UNKNOWN} if the extension is marked 143 * as corrupted 144 * Returns {@link Optional#empty()} if not found in either mapping. 145 */ getMimeType(String extension)146 static Optional<String> getMimeType(String extension) { 147 String lowerExt = extension.toLowerCase(Locale.ROOT); 148 if (sExtToMimeType.containsKey(lowerExt)) { 149 return Optional.of(sExtToMimeType.get(lowerExt)); 150 } 151 152 if (sCorruptedExtToMimeType.containsKey(lowerExt)) { 153 return Optional.of(android.content.ClipDescription.MIMETYPE_UNKNOWN); 154 } 155 156 return Optional.empty(); 157 } 158 159 /** 160 * Gets file extension from MIME type. 161 * 162 * @param mimeType The MIME type. 163 * @return Optional file extension, or empty. 164 */ getExtFromMimeType(String mimeType)165 static Optional<String> getExtFromMimeType(String mimeType) { 166 if (mimeType == null) { 167 return Optional.empty(); 168 } 169 170 mimeType = mimeType.toLowerCase(Locale.ROOT); 171 return Optional.ofNullable(sMimeTypeToExt.get(mimeType)); 172 } 173 174 /** 175 * Checks if a MIME type is corrupted. 176 * 177 * @param mimeType The MIME type. 178 * @return {@code true} if corrupted, {@code false} otherwise. 179 */ isCorruptedMimeType(String mimeType)180 static boolean isCorruptedMimeType(String mimeType) { 181 if (sMimeTypeToExt.containsKey(mimeType)) { 182 return false; 183 } 184 185 return sCorruptedMimeTypeToExt.containsKey(mimeType); 186 } 187 188 189 /** 190 * Scans the database for files with unsupported or mismatched MIME types and updates them. 191 * 192 * @param db The SQLiteDatabase to update. 193 * @return true if all intended updates were successfully applied (or if there were no files), 194 * false otherwise. 195 */ updateUnsupportedMimeTypes(SQLiteDatabase db)196 public static boolean updateUnsupportedMimeTypes(SQLiteDatabase db) { 197 class FileMimeTypeUpdate { 198 final long mFileId; 199 final String mNewMimeType; 200 201 FileMimeTypeUpdate(long fileId, String newMimeType) { 202 this.mFileId = fileId; 203 this.mNewMimeType = newMimeType; 204 } 205 } 206 207 List<FileMimeTypeUpdate> filesToUpdate = new ArrayList<>(); 208 String[] projections = new String[]{MediaStore.Files.FileColumns._ID, 209 MediaStore.Files.FileColumns.DATA, 210 MediaStore.Files.FileColumns.MIME_TYPE, 211 MediaStore.Files.FileColumns.DISPLAY_NAME 212 }; 213 try (Cursor cursor = db.query(MediaStore.Files.TABLE, projections, 214 null, null, null, null, null)) { 215 216 while (cursor != null && cursor.moveToNext()) { 217 long fileId = cursor.getLong(cursor.getColumnIndexOrThrow( 218 MediaStore.Files.FileColumns._ID)); 219 String data = cursor.getString(cursor.getColumnIndexOrThrow( 220 MediaStore.Files.FileColumns.DATA)); 221 String currentMimeType = cursor.getString( 222 cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MIME_TYPE)); 223 String displayName = cursor.getString(cursor.getColumnIndexOrThrow( 224 MediaStore.Files.FileColumns.DISPLAY_NAME)); 225 226 String extension = FileUtils.extractFileExtension(data); 227 if (extension == null) { 228 continue; 229 } 230 String newMimeType = MimeUtils.resolveMimeType(new File(displayName)); 231 if (!newMimeType.equalsIgnoreCase(currentMimeType)) { 232 filesToUpdate.add(new FileMimeTypeUpdate(fileId, newMimeType)); 233 } 234 } 235 } catch (Exception e) { 236 Log.e(TAG, "Failed to fetch files for MIME type check", e); 237 return false; 238 } 239 240 Log.v(TAG, "Identified " + filesToUpdate.size() + " files with incorrect MIME types."); 241 int updatedRows = 0; 242 for (FileMimeTypeUpdate fileUpdate : filesToUpdate) { 243 try { 244 ContentValues contentValues = new ContentValues(); 245 contentValues.put(MediaStore.Files.FileColumns.MIME_TYPE, fileUpdate.mNewMimeType); 246 contentValues.put(MediaStore.Files.FileColumns.MEDIA_TYPE, 247 MimeUtils.resolveMediaType(fileUpdate.mNewMimeType)); 248 249 String whereClause = MediaStore.Files.FileColumns._ID + " = ?"; 250 String[] whereArgs = new String[]{String.valueOf(fileUpdate.mFileId)}; 251 updatedRows += db.update(MediaStore.Files.TABLE, contentValues, whereClause, 252 whereArgs); 253 } catch (Exception e) { 254 Log.e(TAG, "Error updating file with id: " + fileUpdate.mFileId, e); 255 } 256 } 257 Log.v(TAG, "Updated MIME type and Media type for " + updatedRows + " rows"); 258 return updatedRows == filesToUpdate.size(); 259 } 260 } 261