blob: 893152478bc5f6bf08d3cbb0956a50d0928bc1d5 [file] [log] [blame]
/*
* 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 libcore.content.type;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Supplier;
import libcore.api.CorePlatformApi;
import libcore.util.NonNull;
import libcore.util.Nullable;
/**
* Maps from MIME types to file extensions and back.
*
* @hide
*/
@libcore.api.CorePlatformApi(status = CorePlatformApi.Status.STABLE)
public final class MimeMap {
/**
* Creates a MIME type map builder.
*
* @return builder
*
* @see MimeMap.Builder
*/
@CorePlatformApi(status = CorePlatformApi.Status.STABLE)
public static @NonNull Builder builder() {
return new Builder();
}
/**
* Creates a MIME type map builder with values based on {@code this} instance.
* This builder will contain all previously added MIMEs and extensions.
*
* @return builder
*
* @see MimeMap.Builder
*/
@CorePlatformApi(status = CorePlatformApi.Status.STABLE)
public @NonNull Builder buildUpon() {
return new Builder(mimeToExt, extToMime);
}
// Contain only lowercase, valid keys/values.
private final Map<String, String> mimeToExt;
private final Map<String, String> extToMime;
/**
* A basic implementation of MimeMap used if a new default isn't explicitly
* {@link MimeMap#setDefaultSupplier(Supplier) installed}. Hard-codes enough
* mappings to satisfy libcore tests. Android framework code is expected to
* replace this implementation during runtime initialization.
*/
private static volatile MemoizingSupplier<@NonNull MimeMap> instanceSupplier =
new MemoizingSupplier<>(
() -> builder()
.addMimeMapping("application/pdf", "pdf")
.addMimeMapping("image/jpeg", "jpg")
.addMimeMapping("image/x-ms-bmp", "bmp")
.addMimeMapping("text/html", Arrays.asList("htm", "html"))
.addMimeMapping("text/plain", Arrays.asList("text", "txt"))
.addMimeMapping("text/x-java", "java")
.build());
private MimeMap(Map<String, String> mimeToExt, Map<String, String> extToMime) {
this.mimeToExt = Objects.requireNonNull(mimeToExt);
this.extToMime = Objects.requireNonNull(extToMime);
for (Map.Entry<String, String> entry : this.mimeToExt.entrySet()) {
checkValidMimeType(entry.getKey());
checkValidExtension(entry.getValue());
}
for (Map.Entry<String, String> entry : this.extToMime.entrySet()) {
checkValidExtension(entry.getKey());
checkValidMimeType(entry.getValue());
}
}
/**
* Gets system's current default {@link MimeMap}
*
* @return The system's current default {@link MimeMap}.
*/
@CorePlatformApi(status = CorePlatformApi.Status.STABLE)
public static @NonNull MimeMap getDefault() {
return Objects.requireNonNull(instanceSupplier.get());
}
/**
* Sets the {@link Supplier} of the {@link #getDefault() default MimeMap
* instance} to be used from now on.
*
* {@code mimeMapSupplier.get()} will be invoked only the first time that
* {@link #getDefault()} is called after this method call; that
* {@link MimeMap} instance is memoized such that subsequent calls to
* {@link #getDefault()} without an intervening call to
* {@link #setDefaultSupplier(Supplier)} will return that same instance
* without consulting {@code mimeMapSupplier} a second time.
*/
@CorePlatformApi(status = CorePlatformApi.Status.STABLE)
public static void setDefaultSupplier(@NonNull Supplier<@NonNull MimeMap> mimeMapSupplier) {
instanceSupplier = new MemoizingSupplier<>(Objects.requireNonNull(mimeMapSupplier));
}
/**
* Returns whether the given case insensitive extension has a registered MIME type.
*
* @param extension A file extension without the leading '.'
* @return Whether a MIME type has been registered for the given case insensitive file
* extension.
*/
@CorePlatformApi(status = CorePlatformApi.Status.STABLE)
public final boolean hasExtension(@Nullable String extension) {
return guessMimeTypeFromExtension(extension) != null;
}
/**
* Returns the MIME type for the given case insensitive file extension, or null
* if the extension isn't mapped to any.
*
* @param extension A file extension without the leading '.'
* @return The lower-case MIME type registered for the given case insensitive file extension,
* or null if there is none.
*/
@CorePlatformApi(status = CorePlatformApi.Status.STABLE)
public final @Nullable String guessMimeTypeFromExtension(@Nullable String extension) {
if (extension == null) {
return null;
}
extension = toLowerCase(extension);
return extToMime.get(extension);
}
/**
* Returns whether given case insensetive MIME type is mapped to a file extension.
*
* @param mimeType A MIME type (i.e. {@code "text/plain")
* @return Whether the given case insensitive MIME type is
* {@link #guessMimeTypeFromExtension(String) mapped} to a file extension.
*/
@CorePlatformApi(status = CorePlatformApi.Status.STABLE)
public final boolean hasMimeType(@Nullable String mimeType) {
return guessExtensionFromMimeType(mimeType) != null;
}
/**
* Returns the registered extension for the given case insensitive MIME type. Note that some
* MIME types map to multiple extensions. This call will return the most
* common extension for the given MIME type.
* @param mimeType A MIME type (i.e. text/plain)
* @return The lower-case file extension (without the leading "." that has been registered for
* the given case insensitive MIME type, or null if there is none.
*/
@CorePlatformApi(status = CorePlatformApi.Status.STABLE)
public final @Nullable String guessExtensionFromMimeType(@Nullable String mimeType) {
if (mimeType == null) {
return null;
}
mimeType = toLowerCase(mimeType);
return mimeToExt.get(mimeType);
}
/**
* Returns the set of MIME types that this {@link MimeMap}
* {@link #hasMimeType(String) maps to some extension}. Note that the
* reverse mapping might not exist.
*
* @return unmodifiable {@link Set} of MIME types mapped to some extension
*
* @hide
*/
@CorePlatformApi(status = CorePlatformApi.Status.STABLE)
public @NonNull Set<String> mimeTypes() {
return Collections.unmodifiableSet(mimeToExt.keySet());
}
/**
* Returns the set of extensions that this {@link MimeMap}
* {@link #hasExtension(String) maps to some MIME type}. Note that the
* reverse mapping might not exist.
*
* @return unmodifiable {@link Set} of extensions that this {@link MimeMap}
* maps to some MIME type
*
* @hide
*/
@CorePlatformApi(status = CorePlatformApi.Status.STABLE)
public @NonNull Set<String> extensions() {
return Collections.unmodifiableSet(extToMime.keySet());
}
/**
* Returns the canonical (lowercase) form of the given extension or MIME type.
*/
private static @NonNull String toLowerCase(@NonNull String s) {
return s.toLowerCase(Locale.ROOT);
}
private volatile int hashCode = 0;
@Override
public int hashCode() {
if (hashCode == 0) { // potentially uninitialized
hashCode = mimeToExt.hashCode() + 31 * extToMime.hashCode();
}
return hashCode;
}
@Override
public boolean equals(Object obj) {
if (!(obj instanceof MimeMap)) {
return false;
}
MimeMap that = (MimeMap) obj;
if (hashCode() != that.hashCode()) {
return false;
}
return mimeToExt.equals(that.mimeToExt) && extToMime.equals(that.extToMime);
}
@Override
public String toString() {
return "MimeMap[" + mimeToExt + ", " + extToMime + "]";
}
/**
* A builder for mapping of MIME types to extensions and back.
* Use {@link #addMimeMapping(String, List)} and {@link #addMimeMapping(String, String)} to add
* mapping entries and build final {@link MimeMap} with {@link #build()}.
*
* @hide
*/
@CorePlatformApi(status = CorePlatformApi.Status.STABLE)
public static final class Builder {
private final Map<String, String> mimeToExt;
private final Map<String, String> extToMime;
/**
* Constructs a Builder that starts with an empty mapping.
*/
Builder() {
this.mimeToExt = new HashMap<>();
this.extToMime = new HashMap<>();
}
/**
* Constructs a Builder that starts with the given mapping.
* @param mimeToExt
* @param extToMime
*/
Builder(Map<String, String> mimeToExt, Map<String, String> extToMime) {
this.mimeToExt = new HashMap<>(mimeToExt);
this.extToMime = new HashMap<>(extToMime);
}
/**
* An element of a *mime.types file.
*/
static class Element {
final String mimeOrExt;
final boolean keepExisting;
/**
* @param spec A MIME type or an extension, with an optional
* prefix of "?" (if not overriding an earlier value).
* @param isMimeSpec whether this Element denotes a MIME type (as opposed to an
* extension).
*/
private Element(String spec, boolean isMimeSpec) {
if (spec.startsWith("?")) {
this.keepExisting = true;
this.mimeOrExt = toLowerCase(spec.substring(1));
} else {
this.keepExisting = false;
this.mimeOrExt = toLowerCase(spec);
}
if (isMimeSpec) {
checkValidMimeType(mimeOrExt);
} else {
checkValidExtension(mimeOrExt);
}
}
public static Element ofMimeSpec(String s) { return new Element(s, true); }
public static Element ofExtensionSpec(String s) { return new Element(s, false); }
}
private static String maybePut(Map<String, String> map, Element keyElement, String value) {
if (keyElement.keepExisting) {
return map.putIfAbsent(keyElement.mimeOrExt, value);
} else {
return map.put(keyElement.mimeOrExt, value);
}
}
/**
* Puts the mapping {@quote mimeType -> first extension}, and also the mappings
* {@quote extension -> mimeType} for each given extension.
*
* The values passed to this function are carry an optional prefix of {@quote "?"}
* which is stripped off in any case before any such key/value is added to a mapping.
* The prefix {@quote "?"} controls whether the mapping <i>from></i> the corresponding
* value is added via {@link Map#putIfAbsent} semantics ({@quote "?"}
* present) vs. {@link Map#put} semantics ({@quote "?" absent}),
*
* For example, {@code put("text/html", "?htm", "html")} would add the following
* mappings:
* <ol>
* <li>MIME type "text/html" -> extension "htm", overwriting any earlier mapping
* from MIME type "text/html" that might already have existed.</li>
* <li>extension "htm" -> MIME type "text/html", but only if no earlier mapping
* for extension "htm" existed.</li>
* <li>extension "html" -> MIME type "text/html", overwriting any earlier mapping
* from extension "html" that might already have existed.</li>
* </ol>
* {@code put("?text/html", "?htm", "html")} would have the same effect except
* that an earlier mapping from MIME type {@code "text/html"} would not be
* overwritten.
*
* @param mimeSpec A MIME type carrying an optional prefix of {@code "?"}. If present,
* the {@code "?"} is stripped off and mapping for the resulting MIME
* type is only added to the map if no mapping had yet existed for that
* type.
* @param extensionSpecs The extensions from which to add mappings back to
* the {@code "?"} is stripped off and mapping for the resulting extension
* is only added to the map if no mapping had yet existed for that
* extension.
* If {@code extensionSpecs} is empty, then calling this method has no
* effect on the mapping that is being constructed.
* @throws IllegalArgumentException if {@code mimeSpec} or any of the {@code extensionSpecs}
* are invalid (null, empty, contain ' ', or '?' after an initial '?' has
* been stripped off).
* @return This builder.
*/
@CorePlatformApi(status = CorePlatformApi.Status.STABLE)
public @NonNull Builder addMimeMapping(@NonNull String mimeSpec, @NonNull List<@NonNull String> extensionSpecs)
{
Element mimeElement = Element.ofMimeSpec(mimeSpec); // validate mimeSpec unconditionally
if (extensionSpecs.isEmpty()) {
return this;
}
Element firstExtensionElement = Element.ofExtensionSpec(extensionSpecs.get(0));
maybePut(mimeToExt, mimeElement, firstExtensionElement.mimeOrExt);
maybePut(extToMime, firstExtensionElement, mimeElement.mimeOrExt);
for (String spec : extensionSpecs.subList(1, extensionSpecs.size())) {
Element element = Element.ofExtensionSpec(spec);
maybePut(extToMime, element, mimeElement.mimeOrExt);
}
return this;
}
/**
* Convenience method.
*
* @hide
*/
public @NonNull Builder addMimeMapping(@NonNull String mimeSpec, @NonNull String extensionSpec) {
return addMimeMapping(mimeSpec, Collections.singletonList(extensionSpec));
}
/**
* Builds {@link MimeMap} containing all added MIME mappings.
*
* @return {@link MimeMap} containing previously added MIME mapping entries
*/
@CorePlatformApi(status = CorePlatformApi.Status.STABLE)
public @NonNull MimeMap build() {
return new MimeMap(mimeToExt, extToMime);
}
@Override
public String toString() {
return "MimeMap.Builder[" + mimeToExt + ", " + extToMime + "]";
}
}
private static boolean isValidMimeTypeOrExtension(String s) {
return s != null
&& !s.isEmpty()
&& s.indexOf('?') < 0
&& s.indexOf(' ') < 0
&& s.indexOf('\t') < 0
&& s.equals(toLowerCase(s));
}
static void checkValidMimeType(String s) {
if (!isValidMimeTypeOrExtension(s) || s.indexOf('/') < 0) {
throw new IllegalArgumentException("Invalid MIME type: " + s);
}
}
static void checkValidExtension(String s) {
if (!isValidMimeTypeOrExtension(s) || s.indexOf('/') >= 0) {
throw new IllegalArgumentException("Invalid extension: " + s);
}
}
private static final class MemoizingSupplier<T> implements Supplier<T> {
private volatile Supplier<T> mDelegate;
private volatile T mInstance;
private volatile boolean mInitialized = false;
public MemoizingSupplier(Supplier<T> delegate) {
this.mDelegate = delegate;
}
@Override
public T get() {
if (!mInitialized) {
synchronized (this) {
if (!mInitialized) {
mInstance = mDelegate.get();
mDelegate = null;
mInitialized = true;
}
}
}
return mInstance;
}
}
}