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