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