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