// 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.chrome.browser.media.remote;
import android.net.Uri;
import android.os.AsyncTask;
import android.text.TextUtils;
import org.chromium.base.Log;
import org.chromium.base.VisibleForTesting;
import org.chromium.base.metrics.RecordHistogram;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLStreamHandler;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
* Resolves the final URL if it's a redirect. Works asynchronously, uses HTTP
* HEAD request to determine if the URL is redirected.
*/
public class MediaUrlResolver extends AsyncTask<Void, Void, MediaUrlResolver.Result> {
// Cast.Sender.UrlResolveResult UMA histogram values; must match values of
// RemotePlaybackUrlResolveResult in histograms.xml. Do not change these values, as they are
// being used in UMA.
private static final int RESOLVE_RESULT_SUCCESS = 0;
private static final int RESOLVE_RESULT_MALFORMED_URL = 1;
private static final int RESOLVE_RESULT_NO_CORS = 2;
private static final int RESOLVE_RESULT_INCOMPATIBLE_CORS = 3;
private static final int RESOLVE_RESULT_SERVER_ERROR = 4;
private static final int RESOLVE_RESULT_NETWORK_ERROR = 5;
private static final int RESOLVE_RESULT_UNSUPPORTED_MEDIA = 6;
// Range of histogram.
private static final int HISTOGRAM_RESULT_COUNT = 7;
// Acceptal response codes for URL resolving request.
private static final Integer[] SUCCESS_RESPONSE_CODES = {
// Request succeeded.
HttpURLConnection.HTTP_OK,
HttpURLConnection.HTTP_PARTIAL,
// HttpURLConnection only follows up to 5 redirects, this response is unlikely but possible.
HttpURLConnection.HTTP_MOVED_PERM,
HttpURLConnection.HTTP_MOVED_TEMP,
};
/**
* The interface to get the initial URI with cookies from and pass the final
* URI to.
*/
public interface Delegate {
/**
* @return the original URL to resolve.
*/
Uri getUri();
/**
* @return the cookies to fetch the URL with.
*/
String getCookies();
/**
* Passes the resolved URL to the delegate.
*
* @param uri the resolved URL.
*/
void deliverResult(Uri uri, boolean palyable);
}
protected static final class Result {
private final Uri mUri;
private final boolean mPlayable;
public Result(Uri uri, boolean playable) {
mUri = uri;
mPlayable = playable;
}
public Uri getUri() {
return mUri;
}
public boolean isPlayable() {
return mPlayable;
}
}
private static final String TAG = "MediaFling";
private static final String COOKIES_HEADER_NAME = "Cookies";
private static final String USER_AGENT_HEADER_NAME = "User-Agent";
private static final String ORIGIN_HEADER_NAME = "Origin";
private static final String RANGE_HEADER_NAME = "Range";
private static final String CORS_HEADER_NAME = "Access-Control-Allow-Origin";
private static final String CHROMECAST_ORIGIN = "https://www.gstatic.com";
// Media types supported for cast, see
// media/base/container_names.h for the actual enum where these are defined.
// See https://developers.google.com/cast/docs/media#media-container-formats for the formats
// supported by Cast devices.
private static final int MEDIA_TYPE_UNKNOWN = 0;
private static final int MEDIA_TYPE_AAC = 1;
private static final int MEDIA_TYPE_HLS = 22;
private static final int MEDIA_TYPE_MP3 = 26;
private static final int MEDIA_TYPE_MPEG4 = 29;
private static final int MEDIA_TYPE_OGG = 30;
private static final int MEDIA_TYPE_WAV = 35;
private static final int MEDIA_TYPE_WEBM = 36;
private static final int MEDIA_TYPE_DASH = 38;
private static final int MEDIA_TYPE_SMOOTHSTREAM = 39;
// We don't want to necessarily fetch the whole video but we don't want to miss the CORS header.
// Assume that 64k should be more than enough to keep all the headers.
private static final String RANGE_HEADER_VALUE = "bytes=0-65536";
private final Delegate mDelegate;
private final String mUserAgent;
private final URLStreamHandler mStreamHandler;
/**
* The constructor
* @param delegate The customer for this URL resolver.
* @param userAgent The browser user agent
*/
public MediaUrlResolver(Delegate delegate, String userAgent) {
this(delegate, userAgent, null);
}
@VisibleForTesting
MediaUrlResolver(Delegate delegate, String userAgent, URLStreamHandler streamHandler) {
mDelegate = delegate;
mUserAgent = userAgent;
mStreamHandler = streamHandler;
}
@Override
protected MediaUrlResolver.Result doInBackground(Void... params) {
Uri uri = mDelegate.getUri();
if (uri == null || uri.equals(Uri.EMPTY)) {
return new MediaUrlResolver.Result(Uri.EMPTY, false);
}
String cookies = mDelegate.getCookies();
Map<String, List<String>> headers = null;
HttpURLConnection urlConnection = null;
try {
URL requestUrl = new URL(null, uri.toString(), mStreamHandler);
urlConnection = (HttpURLConnection) requestUrl.openConnection();
if (!TextUtils.isEmpty(cookies)) {
urlConnection.setRequestProperty(COOKIES_HEADER_NAME, cookies);
}
// Pretend that this is coming from the Chromecast.
urlConnection.setRequestProperty(ORIGIN_HEADER_NAME, CHROMECAST_ORIGIN);
urlConnection.setRequestProperty(USER_AGENT_HEADER_NAME, mUserAgent);
if (!isEnhancedMedia(uri)) {
// Manifest files are typically smaller than 64K so range request can fail.
urlConnection.setRequestProperty(RANGE_HEADER_NAME, RANGE_HEADER_VALUE);
}
// This triggers resolving the URL and receiving the headers.
headers = urlConnection.getHeaderFields();
uri = Uri.parse(urlConnection.getURL().toString());
// If server's response is not valid, don't try to fling the video.
int responseCode = urlConnection.getResponseCode();
if (!Arrays.asList(SUCCESS_RESPONSE_CODES).contains(responseCode)) {
recordResultHistogram(RESOLVE_RESULT_SERVER_ERROR);
Log.e(TAG, "Server response is not valid: %d", responseCode);
uri = Uri.EMPTY;
}
} catch (IOException e) {
recordResultHistogram(RESOLVE_RESULT_NETWORK_ERROR);
Log.e(TAG, "Failed to fetch the final url", e);
uri = Uri.EMPTY;
}
if (urlConnection != null) urlConnection.disconnect();
return new MediaUrlResolver.Result(uri, canPlayMedia(uri, headers));
}
@Override
protected void onPostExecute(MediaUrlResolver.Result result) {
mDelegate.deliverResult(result.getUri(), result.isPlayable());
}
private boolean canPlayMedia(Uri uri, Map<String, List<String>> headers) {
if (uri == null || uri.equals(Uri.EMPTY)) {
recordResultHistogram(RESOLVE_RESULT_MALFORMED_URL);
return false;
}
if (headers != null && headers.containsKey(CORS_HEADER_NAME)) {
// Check that the CORS data is valid for Chromecast
List<String> corsData = headers.get(CORS_HEADER_NAME);
if (corsData.isEmpty() || (!corsData.get(0).equals("*")
&& !corsData.get(0).equals(CHROMECAST_ORIGIN))) {
recordResultHistogram(RESOLVE_RESULT_INCOMPATIBLE_CORS);
return false;
}
} else if (isEnhancedMedia(uri)) {
// HLS media requires CORS headers.
// TODO(avayvod): it actually requires CORS on the final video URLs vs the manifest.
// Clank assumes that if CORS is set for the manifest it's set for everything but
// it not necessary always true. See b/19138712
Log.d(TAG, "HLS stream without CORS header: %s", uri);
recordResultHistogram(RESOLVE_RESULT_NO_CORS);
return false;
}
if (getMediaType(uri) == MEDIA_TYPE_UNKNOWN) {
Log.d(TAG, "Unsupported media container format: %s", uri);
recordResultHistogram(RESOLVE_RESULT_UNSUPPORTED_MEDIA);
return false;
}
recordResultHistogram(RESOLVE_RESULT_SUCCESS);
return true;
}
private boolean isEnhancedMedia(Uri uri) {
int mediaType = getMediaType(uri);
return mediaType == MEDIA_TYPE_HLS
|| mediaType == MEDIA_TYPE_DASH
|| mediaType == MEDIA_TYPE_SMOOTHSTREAM;
}
@VisibleForTesting
void recordResultHistogram(int result) {
RecordHistogram.recordEnumeratedHistogram("Cast.Sender.UrlResolveResult", result,
HISTOGRAM_RESULT_COUNT);
}
static int getMediaType(Uri uri) {
String path = uri.getPath().toLowerCase(Locale.US);
if (path.endsWith(".m3u8")) return MEDIA_TYPE_HLS;
if (path.endsWith(".mp4")) return MEDIA_TYPE_MPEG4;
if (path.endsWith(".mpd")) return MEDIA_TYPE_DASH;
if (path.endsWith(".ism")) return MEDIA_TYPE_SMOOTHSTREAM;
if (path.endsWith(".m4a") || path.endsWith(".aac")) return MEDIA_TYPE_AAC;
if (path.endsWith(".mp3")) return MEDIA_TYPE_MP3;
if (path.endsWith(".wav")) return MEDIA_TYPE_WAV;
if (path.endsWith(".webm")) return MEDIA_TYPE_WEBM;
if (path.endsWith(".ogg")) return MEDIA_TYPE_OGG;
return MEDIA_TYPE_UNKNOWN;
}
}