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