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