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 libcore.content.type; 18 19 import static android.annotation.SystemApi.Client.MODULE_LIBRARIES; 20 21 import android.annotation.SystemApi; 22 23 import java.util.Arrays; 24 import java.util.Collections; 25 import java.util.HashMap; 26 import java.util.List; 27 import java.util.Locale; 28 import java.util.Map; 29 import java.util.Objects; 30 import java.util.Set; 31 import java.util.function.Supplier; 32 import libcore.util.NonNull; 33 import libcore.util.Nullable; 34 35 /** 36 * Maps from MIME types to file extensions and back. 37 * 38 * @hide 39 */ 40 @SystemApi(client = MODULE_LIBRARIES) 41 public final class MimeMap { 42 43 /** 44 * Creates a MIME type map builder. 45 * 46 * @return builder 47 * 48 * @see MimeMap.Builder 49 * 50 * @hide 51 */ 52 @SystemApi(client = MODULE_LIBRARIES) builder()53 public static @NonNull Builder builder() { 54 return new Builder(); 55 } 56 57 /** 58 * Creates a MIME type map builder with values based on {@code this} instance. 59 * This builder will contain all previously added MIMEs and extensions. 60 * 61 * @return builder 62 * 63 * @see MimeMap.Builder 64 * 65 * @hide 66 */ 67 @SystemApi(client = MODULE_LIBRARIES) buildUpon()68 public @NonNull Builder buildUpon() { 69 return new Builder(mimeToExt, extToMime); 70 } 71 72 // Contain only lowercase, valid keys/values. 73 private final Map<String, String> mimeToExt; 74 private final Map<String, String> extToMime; 75 76 /** 77 * A basic implementation of MimeMap used if a new default isn't explicitly 78 * {@link MimeMap#setDefaultSupplier(Supplier) installed}. Hard-codes enough 79 * mappings to satisfy libcore tests. Android framework code is expected to 80 * replace this implementation during runtime initialization. 81 */ 82 private static volatile MemoizingSupplier<@NonNull MimeMap> instanceSupplier = 83 new MemoizingSupplier<>( 84 () -> builder() 85 .addMimeMapping("application/pdf", "pdf") 86 .addMimeMapping("image/jpeg", "jpg") 87 .addMimeMapping("image/x-ms-bmp", "bmp") 88 .addMimeMapping("text/html", Arrays.asList("htm", "html")) 89 .addMimeMapping("text/plain", Arrays.asList("text", "txt")) 90 .addMimeMapping("text/x-java", "java") 91 .build()); 92 MimeMap(Map<String, String> mimeToExt, Map<String, String> extToMime)93 private MimeMap(Map<String, String> mimeToExt, Map<String, String> extToMime) { 94 this.mimeToExt = Objects.requireNonNull(mimeToExt); 95 this.extToMime = Objects.requireNonNull(extToMime); 96 for (Map.Entry<String, String> entry : this.mimeToExt.entrySet()) { 97 checkValidMimeType(entry.getKey()); 98 checkValidExtension(entry.getValue()); 99 } 100 for (Map.Entry<String, String> entry : this.extToMime.entrySet()) { 101 checkValidExtension(entry.getKey()); 102 checkValidMimeType(entry.getValue()); 103 } 104 } 105 106 /** 107 * Gets system's current default {@link MimeMap} 108 * 109 * @return The system's current default {@link MimeMap}. 110 * 111 * @hide 112 */ 113 @SystemApi(client = MODULE_LIBRARIES) getDefault()114 public static @NonNull MimeMap getDefault() { 115 return Objects.requireNonNull(instanceSupplier.get()); 116 } 117 118 /** 119 * Sets the {@link Supplier} of the {@link #getDefault() default MimeMap 120 * instance} to be used from now on. 121 * 122 * {@code mimeMapSupplier.get()} will be invoked only the first time that 123 * {@link #getDefault()} is called after this method call; that 124 * {@link MimeMap} instance is memoized such that subsequent calls to 125 * {@link #getDefault()} without an intervening call to 126 * {@link #setDefaultSupplier(Supplier)} will return that same instance 127 * without consulting {@code mimeMapSupplier} a second time. 128 * 129 * @hide 130 */ 131 @SystemApi(client = MODULE_LIBRARIES) setDefaultSupplier(@onNull Supplier<@NonNull MimeMap> mimeMapSupplier)132 public static void setDefaultSupplier(@NonNull Supplier<@NonNull MimeMap> mimeMapSupplier) { 133 instanceSupplier = new MemoizingSupplier<>(Objects.requireNonNull(mimeMapSupplier)); 134 } 135 136 /** 137 * Returns whether the given case insensitive extension has a registered MIME type. 138 * 139 * @param extension A file extension without the leading '.' 140 * @return Whether a MIME type has been registered for the given case insensitive file 141 * extension. 142 * 143 * @hide 144 */ 145 @SystemApi(client = MODULE_LIBRARIES) hasExtension(@ullable String extension)146 public final boolean hasExtension(@Nullable String extension) { 147 return guessMimeTypeFromExtension(extension) != null; 148 } 149 150 /** 151 * Returns the MIME type for the given case insensitive file extension, or null 152 * if the extension isn't mapped to any. 153 * 154 * @param extension A file extension without the leading '.' 155 * @return The lower-case MIME type registered for the given case insensitive file extension, 156 * or null if there is none. 157 * 158 * @hide 159 */ 160 @SystemApi(client = MODULE_LIBRARIES) guessMimeTypeFromExtension(@ullable String extension)161 public final @Nullable String guessMimeTypeFromExtension(@Nullable String extension) { 162 if (extension == null) { 163 return null; 164 } 165 extension = toLowerCase(extension); 166 return extToMime.get(extension); 167 } 168 169 /** 170 * Returns whether given case insensetive MIME type is mapped to a file extension. 171 * 172 * @param mimeType A MIME type (i.e. {@code "text/plain") 173 * @return Whether the given case insensitive MIME type is 174 * {@link #guessMimeTypeFromExtension(String) mapped} to a file extension. 175 * 176 * @hide 177 */ 178 @SystemApi(client = MODULE_LIBRARIES) hasMimeType(@ullable String mimeType)179 public final boolean hasMimeType(@Nullable String mimeType) { 180 return guessExtensionFromMimeType(mimeType) != null; 181 } 182 183 /** 184 * Returns the registered extension for the given case insensitive MIME type. Note that some 185 * MIME types map to multiple extensions. This call will return the most 186 * common extension for the given MIME type. 187 * @param mimeType A MIME type (i.e. text/plain) 188 * @return The lower-case file extension (without the leading "." that has been registered for 189 * the given case insensitive MIME type, or null if there is none. 190 * 191 * @hide 192 */ 193 @SystemApi(client = MODULE_LIBRARIES) guessExtensionFromMimeType(@ullable String mimeType)194 public final @Nullable String guessExtensionFromMimeType(@Nullable String mimeType) { 195 if (mimeType == null) { 196 return null; 197 } 198 mimeType = toLowerCase(mimeType); 199 return mimeToExt.get(mimeType); 200 } 201 202 /** 203 * Returns the set of MIME types that this {@link MimeMap} 204 * {@link #hasMimeType(String) maps to some extension}. Note that the 205 * reverse mapping might not exist. 206 * 207 * @return unmodifiable {@link Set} of MIME types mapped to some extension 208 * 209 * @hide 210 */ 211 @SystemApi(client = MODULE_LIBRARIES) mimeTypes()212 public @NonNull Set<String> mimeTypes() { 213 return Collections.unmodifiableSet(mimeToExt.keySet()); 214 } 215 216 /** 217 * Returns the set of extensions that this {@link MimeMap} 218 * {@link #hasExtension(String) maps to some MIME type}. Note that the 219 * reverse mapping might not exist. 220 * 221 * @return unmodifiable {@link Set} of extensions that this {@link MimeMap} 222 * maps to some MIME type 223 * 224 * @hide 225 */ 226 @SystemApi(client = MODULE_LIBRARIES) extensions()227 public @NonNull Set<String> extensions() { 228 return Collections.unmodifiableSet(extToMime.keySet()); 229 } 230 231 /** 232 * Returns the canonical (lowercase) form of the given extension or MIME type. 233 */ toLowerCase(@onNull String s)234 private static @NonNull String toLowerCase(@NonNull String s) { 235 return s.toLowerCase(Locale.ROOT); 236 } 237 238 private volatile int hashCode = 0; 239 240 /** 241 * 242 * @hide 243 */ 244 @Override hashCode()245 public int hashCode() { 246 if (hashCode == 0) { // potentially uninitialized 247 hashCode = mimeToExt.hashCode() + 31 * extToMime.hashCode(); 248 } 249 return hashCode; 250 } 251 252 /** 253 * 254 * @hide 255 */ 256 @Override equals(Object obj)257 public boolean equals(Object obj) { 258 if (!(obj instanceof MimeMap)) { 259 return false; 260 } 261 MimeMap that = (MimeMap) obj; 262 if (hashCode() != that.hashCode()) { 263 return false; 264 } 265 return mimeToExt.equals(that.mimeToExt) && extToMime.equals(that.extToMime); 266 } 267 268 /** 269 * 270 * @hide 271 */ 272 @Override toString()273 public String toString() { 274 return "MimeMap[" + mimeToExt + ", " + extToMime + "]"; 275 } 276 277 /** 278 * A builder for mapping of MIME types to extensions and back. 279 * Use {@link #addMimeMapping(String, List)} and {@link #addMimeMapping(String, String)} to add 280 * mapping entries and build final {@link MimeMap} with {@link #build()}. 281 * 282 * @hide 283 */ 284 @SystemApi(client = MODULE_LIBRARIES) 285 public static final class Builder { 286 private final Map<String, String> mimeToExt; 287 private final Map<String, String> extToMime; 288 289 /** 290 * Constructs a Builder that starts with an empty mapping. 291 */ Builder()292 Builder() { 293 this.mimeToExt = new HashMap<>(); 294 this.extToMime = new HashMap<>(); 295 } 296 297 /** 298 * Constructs a Builder that starts with the given mapping. 299 * @param mimeToExt 300 * @param extToMime 301 */ Builder(Map<String, String> mimeToExt, Map<String, String> extToMime)302 Builder(Map<String, String> mimeToExt, Map<String, String> extToMime) { 303 this.mimeToExt = new HashMap<>(mimeToExt); 304 this.extToMime = new HashMap<>(extToMime); 305 } 306 307 /** 308 * An element of a *mime.types file. 309 */ 310 static class Element { 311 final String mimeOrExt; 312 final boolean keepExisting; 313 314 /** 315 * @param spec A MIME type or an extension, with an optional 316 * prefix of "?" (if not overriding an earlier value). 317 * @param isMimeSpec whether this Element denotes a MIME type (as opposed to an 318 * extension). 319 */ Element(String spec, boolean isMimeSpec)320 private Element(String spec, boolean isMimeSpec) { 321 if (spec.startsWith("?")) { 322 this.keepExisting = true; 323 this.mimeOrExt = toLowerCase(spec.substring(1)); 324 } else { 325 this.keepExisting = false; 326 this.mimeOrExt = toLowerCase(spec); 327 } 328 if (isMimeSpec) { 329 checkValidMimeType(mimeOrExt); 330 } else { 331 checkValidExtension(mimeOrExt); 332 } 333 } 334 ofMimeSpec(String s)335 public static Element ofMimeSpec(String s) { return new Element(s, true); } ofExtensionSpec(String s)336 public static Element ofExtensionSpec(String s) { return new Element(s, false); } 337 } 338 maybePut(Map<String, String> map, Element keyElement, String value)339 private static String maybePut(Map<String, String> map, Element keyElement, String value) { 340 if (keyElement.keepExisting) { 341 return map.putIfAbsent(keyElement.mimeOrExt, value); 342 } else { 343 return map.put(keyElement.mimeOrExt, value); 344 } 345 } 346 347 /** 348 * Puts the mapping {@quote mimeType -> first extension}, and also the mappings 349 * {@quote extension -> mimeType} for each given extension. 350 * 351 * The values passed to this function are carry an optional prefix of {@quote "?"} 352 * which is stripped off in any case before any such key/value is added to a mapping. 353 * The prefix {@quote "?"} controls whether the mapping <i>from></i> the corresponding 354 * value is added via {@link Map#putIfAbsent} semantics ({@quote "?"} 355 * present) vs. {@link Map#put} semantics ({@quote "?" absent}), 356 * 357 * For example, {@code put("text/html", "?htm", "html")} would add the following 358 * mappings: 359 * <ol> 360 * <li>MIME type "text/html" -> extension "htm", overwriting any earlier mapping 361 * from MIME type "text/html" that might already have existed.</li> 362 * <li>extension "htm" -> MIME type "text/html", but only if no earlier mapping 363 * for extension "htm" existed.</li> 364 * <li>extension "html" -> MIME type "text/html", overwriting any earlier mapping 365 * from extension "html" that might already have existed.</li> 366 * </ol> 367 * {@code put("?text/html", "?htm", "html")} would have the same effect except 368 * that an earlier mapping from MIME type {@code "text/html"} would not be 369 * overwritten. 370 * 371 * @param mimeSpec A MIME type carrying an optional prefix of {@code "?"}. If present, 372 * the {@code "?"} is stripped off and mapping for the resulting MIME 373 * type is only added to the map if no mapping had yet existed for that 374 * type. 375 * @param extensionSpecs The extensions from which to add mappings back to 376 * the {@code "?"} is stripped off and mapping for the resulting extension 377 * is only added to the map if no mapping had yet existed for that 378 * extension. 379 * If {@code extensionSpecs} is empty, then calling this method has no 380 * effect on the mapping that is being constructed. 381 * @throws IllegalArgumentException if {@code mimeSpec} or any of the {@code extensionSpecs} 382 * are invalid (null, empty, contain ' ', or '?' after an initial '?' has 383 * been stripped off). 384 * @return This builder. 385 * 386 * @hide 387 */ 388 @SystemApi(client = MODULE_LIBRARIES) addMimeMapping(@onNull String mimeSpec, @NonNull List<@NonNull String> extensionSpecs)389 public @NonNull Builder addMimeMapping(@NonNull String mimeSpec, @NonNull List<@NonNull String> extensionSpecs) 390 { 391 Element mimeElement = Element.ofMimeSpec(mimeSpec); // validate mimeSpec unconditionally 392 if (extensionSpecs.isEmpty()) { 393 return this; 394 } 395 Element firstExtensionElement = Element.ofExtensionSpec(extensionSpecs.get(0)); 396 maybePut(mimeToExt, mimeElement, firstExtensionElement.mimeOrExt); 397 maybePut(extToMime, firstExtensionElement, mimeElement.mimeOrExt); 398 for (String spec : extensionSpecs.subList(1, extensionSpecs.size())) { 399 Element element = Element.ofExtensionSpec(spec); 400 maybePut(extToMime, element, mimeElement.mimeOrExt); 401 } 402 return this; 403 } 404 405 /** 406 * Convenience method. 407 * 408 * @hide 409 */ addMimeMapping(@onNull String mimeSpec, @NonNull String extensionSpec)410 public @NonNull Builder addMimeMapping(@NonNull String mimeSpec, @NonNull String extensionSpec) { 411 return addMimeMapping(mimeSpec, Collections.singletonList(extensionSpec)); 412 } 413 414 /** 415 * Builds {@link MimeMap} containing all added MIME mappings. 416 * 417 * @return {@link MimeMap} containing previously added MIME mapping entries 418 * 419 * @hide 420 */ 421 @SystemApi(client = MODULE_LIBRARIES) build()422 public @NonNull MimeMap build() { 423 return new MimeMap(mimeToExt, extToMime); 424 } 425 426 /** 427 * 428 * @hide 429 */ 430 @Override toString()431 public String toString() { 432 return "MimeMap.Builder[" + mimeToExt + ", " + extToMime + "]"; 433 } 434 } 435 isValidMimeTypeOrExtension(String s)436 private static boolean isValidMimeTypeOrExtension(String s) { 437 return s != null 438 && !s.isEmpty() 439 && s.indexOf('?') < 0 440 && s.indexOf(' ') < 0 441 && s.indexOf('\t') < 0 442 && s.equals(toLowerCase(s)); 443 } 444 checkValidMimeType(String s)445 static void checkValidMimeType(String s) { 446 if (!isValidMimeTypeOrExtension(s) || s.indexOf('/') < 0) { 447 throw new IllegalArgumentException("Invalid MIME type: " + s); 448 } 449 } 450 checkValidExtension(String s)451 static void checkValidExtension(String s) { 452 if (!isValidMimeTypeOrExtension(s) || s.indexOf('/') >= 0) { 453 throw new IllegalArgumentException("Invalid extension: " + s); 454 } 455 } 456 457 private static final class MemoizingSupplier<T> implements Supplier<T> { 458 private volatile Supplier<T> mDelegate; 459 private volatile T mInstance; 460 private volatile boolean mInitialized = false; 461 MemoizingSupplier(Supplier<T> delegate)462 public MemoizingSupplier(Supplier<T> delegate) { 463 this.mDelegate = delegate; 464 } 465 466 @Override get()467 public T get() { 468 if (!mInitialized) { 469 synchronized (this) { 470 if (!mInitialized) { 471 mInstance = mDelegate.get(); 472 mDelegate = null; 473 mInitialized = true; 474 } 475 } 476 } 477 return mInstance; 478 } 479 } 480 } 481