MediaResourceGetter.java revision cedac228d2dd51db4b79ea1e72c7f249408ee061
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.text.TextUtils;
13import android.util.Log;
14
15import com.google.common.annotations.VisibleForTesting;
16
17import org.chromium.base.CalledByNative;
18import org.chromium.base.JNINamespace;
19import org.chromium.base.PathUtils;
20
21import java.io.File;
22import java.io.IOException;
23import java.net.URI;
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;
192        try {
193            uri = URI.create(url);
194        } catch (IllegalArgumentException  e) {
195            Log.e(TAG, "Cannot parse uri.", e);
196            return false;
197        }
198        String scheme = uri.getScheme();
199        if (scheme == null || scheme.equals("file")) {
200            File file = uriToFile(uri.getPath());
201            if (!file.exists()) {
202                Log.e(TAG, "File does not exist.");
203                return false;
204            }
205            if (!filePathAcceptable(file)) {
206                Log.e(TAG, "Refusing to read from unsafe file location.");
207                return false;
208            }
209            try {
210                configure(file.getAbsolutePath());
211                return true;
212            } catch (RuntimeException e) {
213                Log.e(TAG, "Error configuring data source", e);
214                return false;
215            }
216        } else {
217            final String host = uri.getHost();
218            if (!isLoopbackAddress(host) && !isNetworkReliable(context)) {
219                Log.w(TAG, "non-file URI can't be read due to unsuitable network conditions");
220                return false;
221            }
222            Map<String, String> headersMap = new HashMap<String, String>();
223            if (!TextUtils.isEmpty(cookies)) {
224                headersMap.put("Cookie", cookies);
225            }
226            if (!TextUtils.isEmpty(userAgent)) {
227                headersMap.put("User-Agent", userAgent);
228            }
229            try {
230                configure(url, headersMap);
231                return true;
232            } catch (RuntimeException e) {
233                Log.e(TAG, "Error configuring data source", e);
234                return false;
235            }
236        }
237    }
238
239    /**
240     * @return true if the device is on an ethernet or wifi network.
241     * If anything goes wrong (e.g., permission denied while trying to access
242     * the network state), returns false.
243     */
244    @VisibleForTesting
245    boolean isNetworkReliable(Context context) {
246        if (context.checkCallingOrSelfPermission(
247                android.Manifest.permission.ACCESS_NETWORK_STATE) !=
248                PackageManager.PERMISSION_GRANTED) {
249            Log.w(TAG, "permission denied to access network state");
250            return false;
251        }
252
253        Integer networkType = getNetworkType(context);
254        if (networkType == null) {
255            return false;
256        }
257        switch (networkType.intValue()) {
258            case ConnectivityManager.TYPE_ETHERNET:
259            case ConnectivityManager.TYPE_WIFI:
260                Log.d(TAG, "ethernet/wifi connection detected");
261                return true;
262            case ConnectivityManager.TYPE_WIMAX:
263            case ConnectivityManager.TYPE_MOBILE:
264            default:
265                Log.d(TAG, "no ethernet/wifi connection detected");
266                return false;
267        }
268    }
269
270    // This method covers only typcial expressions for the loopback address
271    // to resolve the hostname without a DNS loopup.
272    private boolean isLoopbackAddress(String host) {
273        return host != null && (host.equalsIgnoreCase("localhost")  // typical hostname
274                || host.equals("127.0.0.1")  // typical IP v4 expression
275                || host.equals("[::1]"));  // typical IP v6 expression
276    }
277
278    /**
279     * @param file the file whose path should be checked
280     * @return true if and only if the file is in a location that we consider
281     * safe to read from, such as /mnt/sdcard.
282     */
283    @VisibleForTesting
284    boolean filePathAcceptable(File file) {
285        final String path;
286        try {
287            path = file.getCanonicalPath();
288        } catch (IOException e) {
289            // Canonicalization has failed. Assume malicious, give up.
290            Log.w(TAG, "canonicalization of file path failed");
291            return false;
292        }
293        // In order to properly match the roots we must also canonicalize the
294        // well-known paths we are matching against. If we don't, then we can
295        // get unusual results in testing systems or possibly on rooted devices.
296        // Note that canonicalized directory paths always end with '/'.
297        List<String> acceptablePaths = canonicalize(getRawAcceptableDirectories());
298        acceptablePaths.add(getExternalStorageDirectory());
299        Log.d(TAG, "canonicalized file path: " + path);
300        for (String acceptablePath : acceptablePaths) {
301            if (path.startsWith(acceptablePath)) {
302                return true;
303            }
304        }
305        return false;
306    }
307
308    /**
309     * Special case handling for device/OS combos that simply do not work.
310     * @param model the model of device being examined
311     * @param sdkVersion the version of the SDK installed on the device
312     * @return true if the device can be used correctly, otherwise false
313     */
314    @VisibleForTesting
315    static boolean androidDeviceOk(final String model, final int sdkVersion) {
316        return !("GT-I9100".contentEquals(model) &&
317                 sdkVersion < android.os.Build.VERSION_CODES.JELLY_BEAN);
318    }
319
320    // The methods below can be used by unit tests to fake functionality.
321    @VisibleForTesting
322    File uriToFile(String path) {
323        return new File(path);
324    }
325
326    @VisibleForTesting
327    Integer getNetworkType(Context context) {
328        // TODO(qinmin): use ConnectionTypeObserver to listen to the network type change.
329        ConnectivityManager mConnectivityManager =
330                (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
331        if (mConnectivityManager == null) {
332            Log.w(TAG, "no connectivity manager available");
333            return null;
334        }
335        NetworkInfo info = mConnectivityManager.getActiveNetworkInfo();
336        if (info == null) {
337            Log.d(TAG, "no active network");
338            return null;
339        }
340        return info.getType();
341    }
342
343    private List<String> getRawAcceptableDirectories() {
344        List<String> result = new ArrayList<String>();
345        result.add("/mnt/sdcard/");
346        result.add("/sdcard/");
347        return result;
348    }
349
350    private List<String> canonicalize(List<String> paths) {
351        List<String> result = new ArrayList<String>(paths.size());
352        try {
353            for (String path : paths) {
354                result.add(new File(path).getCanonicalPath());
355            }
356            return result;
357        } catch (IOException e) {
358            // Canonicalization has failed. Assume malicious, give up.
359            Log.w(TAG, "canonicalization of file path failed");
360        }
361        return result;
362    }
363
364    @VisibleForTesting
365    String getExternalStorageDirectory() {
366        return PathUtils.getExternalStorageDirectory();
367    }
368
369    @VisibleForTesting
370    void configure(String url, Map<String,String> headers) {
371        mRetriever.setDataSource(url, headers);
372    }
373
374    @VisibleForTesting
375    void configure(String path) {
376        mRetriever.setDataSource(path);
377    }
378
379    @VisibleForTesting
380    String extractMetadata(int key) {
381        return mRetriever.extractMetadata(key);
382    }
383}
384