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.annotation.SuppressLint; 8import android.content.Context; 9import android.content.pm.PackageManager; 10import android.media.MediaMetadataRetriever; 11import android.net.ConnectivityManager; 12import android.net.NetworkInfo; 13import android.os.ParcelFileDescriptor; 14import android.text.TextUtils; 15import android.util.Log; 16 17import org.chromium.base.CalledByNative; 18import org.chromium.base.JNINamespace; 19import org.chromium.base.PathUtils; 20import org.chromium.base.VisibleForTesting; 21 22import java.io.File; 23import java.io.IOException; 24import java.net.URI; 25import java.util.ArrayList; 26import java.util.HashMap; 27import java.util.List; 28import java.util.Map; 29 30/** 31 * Java counterpart of android MediaResourceGetter. 32 */ 33@JNINamespace("content") 34class MediaResourceGetter { 35 36 private static final String TAG = "MediaResourceGetter"; 37 private static 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 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") 59 int getDurationInMilliseconds() { return mDurationInMilliseconds; } 60 61 @CalledByNative("MediaMetadata") 62 int getWidth() { return mWidth; } 63 64 @CalledByNative("MediaMetadata") 65 int getHeight() { return mHeight; } 66 67 @CalledByNative("MediaMetadata") 68 boolean isSuccess() { return mSuccess; } 69 70 @Override 71 public String toString() { 72 return "MediaMetadata[" 73 + "durationInMilliseconds=" + mDurationInMilliseconds 74 + ", width=" + mWidth 75 + ", height=" + mHeight 76 + ", success=" + mSuccess 77 + "]"; 78 } 79 80 @Override 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 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 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 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 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 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 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 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, context)) { 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 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. 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 305 boolean filePathAcceptable(File file, Context context) { 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(context)); 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 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 343 File uriToFile(String path) { 344 return new File(path); 345 } 346 347 @VisibleForTesting 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 364 @SuppressLint("SdCardPath") 365 private List<String> getRawAcceptableDirectories(Context context) { 366 List<String> result = new ArrayList<String>(); 367 result.add("/mnt/sdcard/"); 368 result.add("/sdcard/"); 369 result.add("/data/data/" + context.getPackageName() + "/cache/"); 370 return result; 371 } 372 373 private List<String> canonicalize(List<String> paths) { 374 List<String> result = new ArrayList<String>(paths.size()); 375 try { 376 for (String path : paths) { 377 result.add(new File(path).getCanonicalPath()); 378 } 379 return result; 380 } catch (IOException e) { 381 // Canonicalization has failed. Assume malicious, give up. 382 Log.w(TAG, "canonicalization of file path failed"); 383 } 384 return result; 385 } 386 387 @VisibleForTesting 388 String getExternalStorageDirectory() { 389 return PathUtils.getExternalStorageDirectory(); 390 } 391 392 @VisibleForTesting 393 void configure(int fd, long offset, long length) { 394 ParcelFileDescriptor parcelFd = ParcelFileDescriptor.adoptFd(fd); 395 try { 396 mRetriever.setDataSource(parcelFd.getFileDescriptor(), 397 offset, length); 398 } finally { 399 try { 400 parcelFd.close(); 401 } catch (IOException e) { 402 Log.e(TAG, "Failed to close file descriptor: " + e); 403 } 404 } 405 } 406 407 @VisibleForTesting 408 void configure(String url, Map<String, String> headers) { 409 mRetriever.setDataSource(url, headers); 410 } 411 412 @VisibleForTesting 413 void configure(String path) { 414 mRetriever.setDataSource(path); 415 } 416 417 @VisibleForTesting 418 String extractMetadata(int key) { 419 return mRetriever.extractMetadata(key); 420 } 421} 422