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