MediaResourceGetter.java revision 5f1c94371a64b3196d4be9466099bb892df9b88e
1// Copyright 2013 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5package org.chromium.content.browser;
6
7import android.content.Context;
8import android.content.pm.PackageManager;
9import android.media.MediaMetadataRetriever;
10import android.net.ConnectivityManager;
11import android.net.NetworkInfo;
12import android.os.ParcelFileDescriptor;
13import android.text.TextUtils;
14import android.util.Log;
15
16import com.google.common.annotations.VisibleForTesting;
17
18import org.chromium.base.CalledByNative;
19import org.chromium.base.JNINamespace;
20import org.chromium.base.PathUtils;
21
22import java.io.File;
23import java.io.IOException;
24import java.net.URI;
25import java.util.ArrayList;
26import java.util.HashMap;
27import java.util.List;
28import java.util.Map;
29
30/**
31 * Java counterpart of android MediaResourceGetter.
32 */
33@JNINamespace("content")
34class MediaResourceGetter {
35
36    private static final String TAG = "MediaResourceGetter";
37    private final MediaMetadata EMPTY_METADATA = new MediaMetadata(0,0,0,false);
38
39    private final MediaMetadataRetriever mRetriever = new MediaMetadataRetriever();
40
41    @VisibleForTesting
42    static class MediaMetadata {
43        private final int mDurationInMilliseconds;
44        private final int mWidth;
45        private final int mHeight;
46        private final boolean mSuccess;
47
48        MediaMetadata(int durationInMilliseconds, int width, int height, boolean success) {
49            mDurationInMilliseconds = durationInMilliseconds;
50            mWidth = width;
51            mHeight = height;
52            mSuccess = success;
53        }
54
55        // TODO(andrewhayden): according to the spec, if duration is unknown
56        // then we must return NaN. If it is unbounded, then positive infinity.
57        // http://www.w3.org/html/wg/drafts/html/master/embedded-content-0.html
58        @CalledByNative("MediaMetadata")
59        int getDurationInMilliseconds() { return mDurationInMilliseconds; }
60
61        @CalledByNative("MediaMetadata")
62        int getWidth() { return mWidth; }
63
64        @CalledByNative("MediaMetadata")
65        int getHeight() { return mHeight; }
66
67        @CalledByNative("MediaMetadata")
68        boolean isSuccess() { return mSuccess; }
69
70        @Override
71        public String toString() {
72            return "MediaMetadata["
73                    + "durationInMilliseconds=" + mDurationInMilliseconds
74                    + ", width=" + mWidth
75                    + ", height=" + mHeight
76                    + ", success=" + mSuccess
77                    + "]";
78        }
79
80        @Override
81        public int hashCode() {
82            final int prime = 31;
83            int result = 1;
84            result = prime * result + mDurationInMilliseconds;
85            result = prime * result + mHeight;
86            result = prime * result + (mSuccess ? 1231 : 1237);
87            result = prime * result + mWidth;
88            return result;
89        }
90
91        @Override
92        public boolean equals(Object obj) {
93            if (this == obj)
94                return true;
95            if (obj == null)
96                return false;
97            if (getClass() != obj.getClass())
98                return false;
99            MediaMetadata other = (MediaMetadata)obj;
100            if (mDurationInMilliseconds != other.mDurationInMilliseconds)
101                return false;
102            if (mHeight != other.mHeight)
103                return false;
104            if (mSuccess != other.mSuccess)
105                return false;
106            if (mWidth != other.mWidth)
107                return false;
108            return true;
109        }
110    }
111
112    @CalledByNative
113    private static MediaMetadata extractMediaMetadata(final Context context,
114                                                      final String url,
115                                                      final String cookies,
116                                                      final String userAgent) {
117        return new MediaResourceGetter().extract(
118                context, url, cookies, userAgent);
119    }
120
121    @CalledByNative
122    private static MediaMetadata extractMediaMetadataFromFd(int fd,
123                                                            long offset,
124                                                            long length) {
125        return new MediaResourceGetter().extract(fd, offset, length);
126    }
127
128    @VisibleForTesting
129    MediaMetadata extract(int fd, long offset, long length) {
130        if (!androidDeviceOk(android.os.Build.MODEL, android.os.Build.VERSION.SDK_INT)) {
131            return EMPTY_METADATA;
132        }
133
134        configure(fd, offset, length);
135        return doExtractMetadata();
136    }
137
138    @VisibleForTesting
139    MediaMetadata extract(final Context context, final String url,
140                          final String cookies, final String userAgent) {
141        if (!androidDeviceOk(android.os.Build.MODEL, android.os.Build.VERSION.SDK_INT)) {
142            return EMPTY_METADATA;
143        }
144
145        if (!configure(context, url, cookies, userAgent)) {
146            Log.e(TAG, "Unable to configure metadata extractor");
147            return EMPTY_METADATA;
148        }
149        return doExtractMetadata();
150    }
151
152    private MediaMetadata doExtractMetadata() {
153        try {
154            String durationString = extractMetadata(
155                    MediaMetadataRetriever.METADATA_KEY_DURATION);
156            if (durationString == null) {
157                Log.w(TAG, "missing duration metadata");
158                return EMPTY_METADATA;
159            }
160
161            int durationMillis = 0;
162            try {
163                durationMillis = Integer.parseInt(durationString);
164            } catch (NumberFormatException e) {
165                Log.w(TAG, "non-numeric duration: " + durationString);
166                return EMPTY_METADATA;
167            }
168
169            int width = 0;
170            int height = 0;
171            boolean hasVideo = "yes".equals(extractMetadata(
172                    MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO));
173            Log.d(TAG, (hasVideo ? "resource has video" : "resource doesn't have video"));
174            if (hasVideo) {
175                String widthString = extractMetadata(
176                        MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH);
177                if (widthString == null) {
178                    Log.w(TAG, "missing video width metadata");
179                    return EMPTY_METADATA;
180                }
181                try {
182                    width = Integer.parseInt(widthString);
183                } catch (NumberFormatException e) {
184                    Log.w(TAG, "non-numeric width: " + widthString);
185                    return EMPTY_METADATA;
186                }
187
188                String heightString = extractMetadata(
189                        MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT);
190                if (heightString == null) {
191                    Log.w(TAG, "missing video height metadata");
192                    return EMPTY_METADATA;
193                }
194                try {
195                    height = Integer.parseInt(heightString);
196                } catch (NumberFormatException e) {
197                    Log.w(TAG, "non-numeric height: " + heightString);
198                    return EMPTY_METADATA;
199                }
200            }
201            MediaMetadata result = new MediaMetadata(durationMillis, width, height, true);
202            Log.d(TAG, "extracted valid metadata: " + result.toString());
203            return result;
204        } catch (RuntimeException e) {
205            Log.e(TAG, "Unable to extract medata", e);
206            return EMPTY_METADATA;
207        }
208    }
209
210    @VisibleForTesting
211    boolean configure(Context context, String url, String cookies, String userAgent) {
212        URI uri;
213        try {
214            uri = URI.create(url);
215        } catch (IllegalArgumentException  e) {
216            Log.e(TAG, "Cannot parse uri.", e);
217            return false;
218        }
219        String scheme = uri.getScheme();
220        if (scheme == null || scheme.equals("file")) {
221            File file = uriToFile(uri.getPath());
222            if (!file.exists()) {
223                Log.e(TAG, "File does not exist.");
224                return false;
225            }
226            if (!filePathAcceptable(file)) {
227                Log.e(TAG, "Refusing to read from unsafe file location.");
228                return false;
229            }
230            try {
231                configure(file.getAbsolutePath());
232                return true;
233            } catch (RuntimeException e) {
234                Log.e(TAG, "Error configuring data source", e);
235                return false;
236            }
237        } else {
238            final String host = uri.getHost();
239            if (!isLoopbackAddress(host) && !isNetworkReliable(context)) {
240                Log.w(TAG, "non-file URI can't be read due to unsuitable network conditions");
241                return false;
242            }
243            Map<String, String> headersMap = new HashMap<String, String>();
244            if (!TextUtils.isEmpty(cookies)) {
245                headersMap.put("Cookie", cookies);
246            }
247            if (!TextUtils.isEmpty(userAgent)) {
248                headersMap.put("User-Agent", userAgent);
249            }
250            try {
251                configure(url, headersMap);
252                return true;
253            } catch (RuntimeException e) {
254                Log.e(TAG, "Error configuring data source", e);
255                return false;
256            }
257        }
258    }
259
260    /**
261     * @return true if the device is on an ethernet or wifi network.
262     * If anything goes wrong (e.g., permission denied while trying to access
263     * the network state), returns false.
264     */
265    @VisibleForTesting
266    boolean isNetworkReliable(Context context) {
267        if (context.checkCallingOrSelfPermission(
268                android.Manifest.permission.ACCESS_NETWORK_STATE) !=
269                PackageManager.PERMISSION_GRANTED) {
270            Log.w(TAG, "permission denied to access network state");
271            return false;
272        }
273
274        Integer networkType = getNetworkType(context);
275        if (networkType == null) {
276            return false;
277        }
278        switch (networkType.intValue()) {
279            case ConnectivityManager.TYPE_ETHERNET:
280            case ConnectivityManager.TYPE_WIFI:
281                Log.d(TAG, "ethernet/wifi connection detected");
282                return true;
283            case ConnectivityManager.TYPE_WIMAX:
284            case ConnectivityManager.TYPE_MOBILE:
285            default:
286                Log.d(TAG, "no ethernet/wifi connection detected");
287                return false;
288        }
289    }
290
291    // This method covers only typcial expressions for the loopback address
292    // to resolve the hostname without a DNS loopup.
293    private boolean isLoopbackAddress(String host) {
294        return host != null && (host.equalsIgnoreCase("localhost")  // typical hostname
295                || host.equals("127.0.0.1")  // typical IP v4 expression
296                || host.equals("[::1]"));  // typical IP v6 expression
297    }
298
299    /**
300     * @param file the file whose path should be checked
301     * @return true if and only if the file is in a location that we consider
302     * safe to read from, such as /mnt/sdcard.
303     */
304    @VisibleForTesting
305    boolean filePathAcceptable(File file) {
306        final String path;
307        try {
308            path = file.getCanonicalPath();
309        } catch (IOException e) {
310            // Canonicalization has failed. Assume malicious, give up.
311            Log.w(TAG, "canonicalization of file path failed");
312            return false;
313        }
314        // In order to properly match the roots we must also canonicalize the
315        // well-known paths we are matching against. If we don't, then we can
316        // get unusual results in testing systems or possibly on rooted devices.
317        // Note that canonicalized directory paths always end with '/'.
318        List<String> acceptablePaths = canonicalize(getRawAcceptableDirectories());
319        acceptablePaths.add(getExternalStorageDirectory());
320        Log.d(TAG, "canonicalized file path: " + path);
321        for (String acceptablePath : acceptablePaths) {
322            if (path.startsWith(acceptablePath)) {
323                return true;
324            }
325        }
326        return false;
327    }
328
329    /**
330     * Special case handling for device/OS combos that simply do not work.
331     * @param model the model of device being examined
332     * @param sdkVersion the version of the SDK installed on the device
333     * @return true if the device can be used correctly, otherwise false
334     */
335    @VisibleForTesting
336    static boolean androidDeviceOk(final String model, final int sdkVersion) {
337        return !("GT-I9100".contentEquals(model) &&
338                 sdkVersion < android.os.Build.VERSION_CODES.JELLY_BEAN);
339    }
340
341    // The methods below can be used by unit tests to fake functionality.
342    @VisibleForTesting
343    File uriToFile(String path) {
344        return new File(path);
345    }
346
347    @VisibleForTesting
348    Integer getNetworkType(Context context) {
349        // TODO(qinmin): use ConnectionTypeObserver to listen to the network type change.
350        ConnectivityManager mConnectivityManager =
351                (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
352        if (mConnectivityManager == null) {
353            Log.w(TAG, "no connectivity manager available");
354            return null;
355        }
356        NetworkInfo info = mConnectivityManager.getActiveNetworkInfo();
357        if (info == null) {
358            Log.d(TAG, "no active network");
359            return null;
360        }
361        return info.getType();
362    }
363
364    private List<String> getRawAcceptableDirectories() {
365        List<String> result = new ArrayList<String>();
366        result.add("/mnt/sdcard/");
367        result.add("/sdcard/");
368        return result;
369    }
370
371    private List<String> canonicalize(List<String> paths) {
372        List<String> result = new ArrayList<String>(paths.size());
373        try {
374            for (String path : paths) {
375                result.add(new File(path).getCanonicalPath());
376            }
377            return result;
378        } catch (IOException e) {
379            // Canonicalization has failed. Assume malicious, give up.
380            Log.w(TAG, "canonicalization of file path failed");
381        }
382        return result;
383    }
384
385    @VisibleForTesting
386    String getExternalStorageDirectory() {
387        return PathUtils.getExternalStorageDirectory();
388    }
389
390    @VisibleForTesting
391    void configure(int fd, long offset, long length) {
392        ParcelFileDescriptor parcelFd = ParcelFileDescriptor.adoptFd(fd);
393        try {
394            mRetriever.setDataSource(parcelFd.getFileDescriptor(),
395                    offset, length);
396        } finally {
397            try {
398                parcelFd.close();
399            } catch (IOException e) {
400                Log.e(TAG, "Failed to close file descriptor: " + e);
401            }
402        }
403    }
404
405    @VisibleForTesting
406    void configure(String url, Map<String,String> headers) {
407        mRetriever.setDataSource(url, headers);
408    }
409
410    @VisibleForTesting
411    void configure(String path) {
412        mRetriever.setDataSource(path);
413    }
414
415    @VisibleForTesting
416    String extractMetadata(int key) {
417        return mRetriever.extractMetadata(key);
418    }
419}
420