blob: 29ef9c076641746854a21c43224568dfb3305f10 [file] [log] [blame]
// Copyright 2013 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.content.browser;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.pm.PackageManager;
import android.media.MediaMetadataRetriever;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.text.TextUtils;
import org.chromium.base.Log;
import org.chromium.base.PathUtils;
import org.chromium.base.VisibleForTesting;
import org.chromium.base.annotations.CalledByNative;
import org.chromium.base.annotations.JNINamespace;
import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Java counterpart of android MediaResourceGetter.
*/
@JNINamespace("content")
class MediaResourceGetter {
private static final String TAG = "cr_MediaResource";
private static final MediaMetadata EMPTY_METADATA = new MediaMetadata(0, 0, 0, false);
private final MediaMetadataRetriever mRetriever = new MediaMetadataRetriever();
@VisibleForTesting
static class MediaMetadata {
private final int mDurationInMilliseconds;
private final int mWidth;
private final int mHeight;
private final boolean mSuccess;
MediaMetadata(int durationInMilliseconds, int width, int height, boolean success) {
mDurationInMilliseconds = durationInMilliseconds;
mWidth = width;
mHeight = height;
mSuccess = success;
}
// TODO(andrewhayden): according to the spec, if duration is unknown
// then we must return NaN. If it is unbounded, then positive infinity.
// http://www.w3.org/html/wg/drafts/html/master/embedded-content-0.html
@CalledByNative("MediaMetadata")
int getDurationInMilliseconds() {
return mDurationInMilliseconds;
}
@CalledByNative("MediaMetadata")
int getWidth() {
return mWidth;
}
@CalledByNative("MediaMetadata")
int getHeight() {
return mHeight;
}
@CalledByNative("MediaMetadata")
boolean isSuccess() {
return mSuccess;
}
@Override
public String toString() {
return "MediaMetadata["
+ "durationInMilliseconds=" + mDurationInMilliseconds
+ ", width=" + mWidth
+ ", height=" + mHeight
+ ", success=" + mSuccess
+ "]";
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + mDurationInMilliseconds;
result = prime * result + mHeight;
result = prime * result + (mSuccess ? 1231 : 1237);
result = prime * result + mWidth;
return result;
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null) return false;
if (getClass() != obj.getClass()) return false;
MediaMetadata other = (MediaMetadata) obj;
if (mDurationInMilliseconds != other.mDurationInMilliseconds) return false;
if (mHeight != other.mHeight) return false;
if (mSuccess != other.mSuccess) return false;
if (mWidth != other.mWidth) return false;
return true;
}
}
@CalledByNative
private static MediaMetadata extractMediaMetadata(final Context context,
final String url,
final String cookies,
final String userAgent) {
return new MediaResourceGetter().extract(
context, url, cookies, userAgent);
}
@CalledByNative
private static MediaMetadata extractMediaMetadataFromFd(int fd,
long offset,
long length) {
return new MediaResourceGetter().extract(fd, offset, length);
}
@VisibleForTesting
MediaMetadata extract(int fd, long offset, long length) {
configure(fd, offset, length);
return doExtractMetadata();
}
@VisibleForTesting
MediaMetadata extract(final Context context, final String url,
final String cookies, final String userAgent) {
if (!configure(context, url, cookies, userAgent)) {
Log.e(TAG, "Unable to configure metadata extractor");
return EMPTY_METADATA;
}
return doExtractMetadata();
}
private MediaMetadata doExtractMetadata() {
try {
String durationString = extractMetadata(
MediaMetadataRetriever.METADATA_KEY_DURATION);
if (durationString == null) {
Log.w(TAG, "missing duration metadata");
return EMPTY_METADATA;
}
int durationMillis = 0;
try {
durationMillis = Integer.parseInt(durationString);
} catch (NumberFormatException e) {
Log.w(TAG, "non-numeric duration: %s", durationString);
return EMPTY_METADATA;
}
int width = 0;
int height = 0;
boolean hasVideo = "yes".equals(extractMetadata(
MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO));
Log.d(TAG, (hasVideo ? "resource has video" : "resource doesn't have video"));
if (hasVideo) {
String widthString = extractMetadata(
MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH);
if (widthString == null) {
Log.w(TAG, "missing video width metadata");
return EMPTY_METADATA;
}
try {
width = Integer.parseInt(widthString);
} catch (NumberFormatException e) {
Log.w(TAG, "non-numeric width: %s", widthString);
return EMPTY_METADATA;
}
String heightString = extractMetadata(
MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT);
if (heightString == null) {
Log.w(TAG, "missing video height metadata");
return EMPTY_METADATA;
}
try {
height = Integer.parseInt(heightString);
} catch (NumberFormatException e) {
Log.w(TAG, "non-numeric height: %s", heightString);
return EMPTY_METADATA;
}
}
MediaMetadata result = new MediaMetadata(durationMillis, width, height, true);
Log.d(TAG, "extracted valid metadata: %s", result);
return result;
} catch (RuntimeException e) {
Log.e(TAG, "Unable to extract metadata: %s", e);
return EMPTY_METADATA;
}
}
@VisibleForTesting
boolean configure(Context context, String url, String cookies, String userAgent) {
URI uri;
try {
uri = URI.create(url);
} catch (IllegalArgumentException e) {
Log.e(TAG, "Cannot parse uri: %s", e);
return false;
}
String scheme = uri.getScheme();
if (scheme == null || scheme.equals("file")) {
File file = uriToFile(uri.getPath());
if (!file.exists()) {
Log.e(TAG, "File does not exist.");
return false;
}
if (!filePathAcceptable(file, context)) {
Log.e(TAG, "Refusing to read from unsafe file location.");
return false;
}
try {
configure(file.getAbsolutePath());
return true;
} catch (RuntimeException e) {
Log.e(TAG, "Error configuring data source: %s", e);
return false;
}
}
if (scheme.equals("content")) {
try {
configure(context, Uri.parse(uri.toString()));
return true;
} catch (RuntimeException e) {
Log.e(TAG, "Error configuring data source: %s", e);
return false;
}
}
if (uri.getPath() != null && uri.getPath().endsWith(".m3u8")) {
// MediaMetadataRetriever does not work with HLS correctly.
return false;
}
final String host = uri.getHost();
if (!isLoopbackAddress(host) && !isNetworkReliable(context)) {
Log.w(TAG, "non-file URI can't be read due to unsuitable network conditions");
return false;
}
Map<String, String> headersMap = new HashMap<String, String>();
if (!TextUtils.isEmpty(cookies)) {
headersMap.put("Cookie", cookies);
}
if (!TextUtils.isEmpty(userAgent)) {
headersMap.put("User-Agent", userAgent);
}
try {
configure(url, headersMap);
return true;
} catch (RuntimeException e) {
Log.e(TAG, "Error configuring data source: %s", e);
return false;
}
}
/**
* @return true if the device is on an ethernet or wifi network.
* If anything goes wrong (e.g., permission denied while trying to access
* the network state), returns false.
*/
@VisibleForTesting
boolean isNetworkReliable(Context context) {
if (context.checkCallingOrSelfPermission(android.Manifest.permission.ACCESS_NETWORK_STATE)
!= PackageManager.PERMISSION_GRANTED) {
Log.w(TAG, "permission denied to access network state");
return false;
}
Integer networkType = getNetworkType(context);
if (networkType == null) {
return false;
}
switch (networkType.intValue()) {
case ConnectivityManager.TYPE_ETHERNET:
case ConnectivityManager.TYPE_WIFI:
Log.d(TAG, "ethernet/wifi connection detected");
return true;
case ConnectivityManager.TYPE_WIMAX:
case ConnectivityManager.TYPE_MOBILE:
default:
Log.d(TAG, "no ethernet/wifi connection detected");
return false;
}
}
// This method covers only typcial expressions for the loopback address
// to resolve the hostname without a DNS loopup.
private boolean isLoopbackAddress(String host) {
return host != null && (host.equalsIgnoreCase("localhost") // typical hostname
|| host.equals("127.0.0.1") // typical IP v4 expression
|| host.equals("[::1]")); // typical IP v6 expression
}
/**
* @param file the file whose path should be checked
* @return true if and only if the file is in a location that we consider
* safe to read from, such as /mnt/sdcard.
*/
@VisibleForTesting
boolean filePathAcceptable(File file, Context context) {
final String path;
try {
path = file.getCanonicalPath();
} catch (IOException e) {
// Canonicalization has failed. Assume malicious, give up.
Log.w(TAG, "canonicalization of file path failed");
return false;
}
// In order to properly match the roots we must also canonicalize the
// well-known paths we are matching against. If we don't, then we can
// get unusual results in testing systems or possibly on rooted devices.
// Note that canonicalized directory paths always end with '/'.
List<String> acceptablePaths = canonicalize(getRawAcceptableDirectories(context));
acceptablePaths.add(getExternalStorageDirectory());
Log.d(TAG, "canonicalized file path: %s", path);
for (String acceptablePath : acceptablePaths) {
if (path.startsWith(acceptablePath)) {
return true;
}
}
return false;
}
// The methods below can be used by unit tests to fake functionality.
@VisibleForTesting
File uriToFile(String path) {
return new File(path);
}
@VisibleForTesting
Integer getNetworkType(Context context) {
// TODO(qinmin): use ConnectionTypeObserver to listen to the network type change.
ConnectivityManager mConnectivityManager =
(ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
if (mConnectivityManager == null) {
Log.w(TAG, "no connectivity manager available");
return null;
}
NetworkInfo info = mConnectivityManager.getActiveNetworkInfo();
if (info == null) {
Log.d(TAG, "no active network");
return null;
}
return info.getType();
}
@SuppressLint("SdCardPath")
private List<String> getRawAcceptableDirectories(Context context) {
List<String> result = new ArrayList<String>();
result.add("/mnt/sdcard/");
result.add("/sdcard/");
result.add("/data/data/" + context.getPackageName() + "/cache/");
return result;
}
private List<String> canonicalize(List<String> paths) {
List<String> result = new ArrayList<String>(paths.size());
try {
for (String path : paths) {
result.add(new File(path).getCanonicalPath());
}
return result;
} catch (IOException e) {
// Canonicalization has failed. Assume malicious, give up.
Log.w(TAG, "canonicalization of file path failed");
}
return result;
}
@VisibleForTesting
String getExternalStorageDirectory() {
return PathUtils.getExternalStorageDirectory();
}
@VisibleForTesting
void configure(int fd, long offset, long length) {
ParcelFileDescriptor parcelFd = ParcelFileDescriptor.adoptFd(fd);
try {
mRetriever.setDataSource(parcelFd.getFileDescriptor(),
offset, length);
} finally {
try {
parcelFd.close();
} catch (IOException e) {
Log.e(TAG, "Failed to close file descriptor: %s", e);
}
}
}
@VisibleForTesting
void configure(String url, Map<String, String> headers) {
mRetriever.setDataSource(url, headers);
}
@VisibleForTesting
void configure(String path) {
mRetriever.setDataSource(path);
}
@VisibleForTesting
void configure(Context context, Uri uri) {
mRetriever.setDataSource(context, uri);
}
@VisibleForTesting
String extractMetadata(int key) {
return mRetriever.extractMetadata(key);
}
}