MediaHTTPConnection.seek() was creating new connections in a while
loop without checking whether another thread was busy concurrently
disconnecting.
When a new connection was created between the time the disconnect()ing
thread had disconnected the old one and acquired the synchronized
block, the new connection wouldn't be disconnected and therefore
seek() would not encounter an IOException; therefore, seek() would
not return quickly, leaving the disconnecting thread waiting to
acquire the synchronized block for a long time.
This CL fixes this by making seek() throw IOException quickly if
it discovers that another thread is trying to disconnect. This
is checked shortly before and after the new connection is created,
to avoid a race based on the order between the new connection
being created and the disconnecting thread reading the old
connection value. Note that this still doesn't stop a new
connection being created shortly after the previous one was torn
down - it only stops the disconnecting thread waiting for a long
time to acquire the synchronized lock.
Fixes: 131894499
Test: The following command hang 3/3 times before this CL, but
succeeded 3/3 times afterwards:
atest android.media.cts.NativeDecoderTest#testAMediaDataSourceClose
Change-Id: I3862a4367d0e46c64c0cbf7bcaa369aca5692871
494 lines
17 KiB
Java
494 lines
17 KiB
Java
/*
|
|
* Copyright (C) 2013 The Android Open Source Project
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
package android.media;
|
|
|
|
import static android.media.MediaPlayer.MEDIA_ERROR_UNSUPPORTED;
|
|
|
|
import android.annotation.UnsupportedAppUsage;
|
|
import android.net.NetworkUtils;
|
|
import android.os.IBinder;
|
|
import android.os.StrictMode;
|
|
import android.util.Log;
|
|
|
|
import com.android.internal.annotations.GuardedBy;
|
|
import java.io.BufferedInputStream;
|
|
import java.io.IOException;
|
|
import java.io.InputStream;
|
|
import java.net.CookieHandler;
|
|
import java.net.HttpURLConnection;
|
|
import java.net.MalformedURLException;
|
|
import java.net.NoRouteToHostException;
|
|
import java.net.ProtocolException;
|
|
import java.net.Proxy;
|
|
import java.net.URL;
|
|
import java.net.UnknownServiceException;
|
|
import java.util.HashMap;
|
|
import java.util.Map;
|
|
import java.util.concurrent.atomic.AtomicInteger;
|
|
|
|
/** @hide */
|
|
public class MediaHTTPConnection extends IMediaHTTPConnection.Stub {
|
|
private static final String TAG = "MediaHTTPConnection";
|
|
private static final boolean VERBOSE = false;
|
|
|
|
// connection timeout - 30 sec
|
|
private static final int CONNECT_TIMEOUT_MS = 30 * 1000;
|
|
|
|
@GuardedBy("this")
|
|
@UnsupportedAppUsage
|
|
private long mCurrentOffset = -1;
|
|
|
|
@GuardedBy("this")
|
|
@UnsupportedAppUsage
|
|
private URL mURL = null;
|
|
|
|
@GuardedBy("this")
|
|
@UnsupportedAppUsage
|
|
private Map<String, String> mHeaders = null;
|
|
|
|
// volatile so that disconnect() can be called without acquiring a lock.
|
|
// All other access is @GuardedBy("this").
|
|
@UnsupportedAppUsage
|
|
private volatile HttpURLConnection mConnection = null;
|
|
|
|
@GuardedBy("this")
|
|
@UnsupportedAppUsage
|
|
private long mTotalSize = -1;
|
|
|
|
@GuardedBy("this")
|
|
private InputStream mInputStream = null;
|
|
|
|
@GuardedBy("this")
|
|
@UnsupportedAppUsage
|
|
private boolean mAllowCrossDomainRedirect = true;
|
|
|
|
@GuardedBy("this")
|
|
@UnsupportedAppUsage
|
|
private boolean mAllowCrossProtocolRedirect = true;
|
|
|
|
// from com.squareup.okhttp.internal.http
|
|
private final static int HTTP_TEMP_REDIRECT = 307;
|
|
private final static int MAX_REDIRECTS = 20;
|
|
|
|
// The number of threads that are currently running disconnect() (possibly
|
|
// not yet holding the synchronized lock).
|
|
private final AtomicInteger mNumDisconnectingThreads = new AtomicInteger(0);
|
|
|
|
@UnsupportedAppUsage
|
|
public MediaHTTPConnection() {
|
|
CookieHandler cookieHandler = CookieHandler.getDefault();
|
|
if (cookieHandler == null) {
|
|
Log.w(TAG, "MediaHTTPConnection: Unexpected. No CookieHandler found.");
|
|
}
|
|
|
|
native_setup();
|
|
}
|
|
|
|
@Override
|
|
@UnsupportedAppUsage
|
|
public synchronized IBinder connect(String uri, String headers) {
|
|
if (VERBOSE) {
|
|
Log.d(TAG, "connect: uri=" + uri + ", headers=" + headers);
|
|
}
|
|
|
|
try {
|
|
disconnect();
|
|
mAllowCrossDomainRedirect = true;
|
|
mURL = new URL(uri);
|
|
mHeaders = convertHeaderStringToMap(headers);
|
|
} catch (MalformedURLException e) {
|
|
return null;
|
|
}
|
|
|
|
return native_getIMemory();
|
|
}
|
|
|
|
private static boolean parseBoolean(String val) {
|
|
try {
|
|
return Long.parseLong(val) != 0;
|
|
} catch (NumberFormatException e) {
|
|
return "true".equalsIgnoreCase(val) ||
|
|
"yes".equalsIgnoreCase(val);
|
|
}
|
|
}
|
|
|
|
/* returns true iff header is internal */
|
|
private synchronized boolean filterOutInternalHeaders(String key, String val) {
|
|
if ("android-allow-cross-domain-redirect".equalsIgnoreCase(key)) {
|
|
mAllowCrossDomainRedirect = parseBoolean(val);
|
|
// cross-protocol redirects are also controlled by this flag
|
|
mAllowCrossProtocolRedirect = mAllowCrossDomainRedirect;
|
|
} else {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private synchronized Map<String, String> convertHeaderStringToMap(String headers) {
|
|
HashMap<String, String> map = new HashMap<String, String>();
|
|
|
|
String[] pairs = headers.split("\r\n");
|
|
for (String pair : pairs) {
|
|
int colonPos = pair.indexOf(":");
|
|
if (colonPos >= 0) {
|
|
String key = pair.substring(0, colonPos);
|
|
String val = pair.substring(colonPos + 1);
|
|
|
|
if (!filterOutInternalHeaders(key, val)) {
|
|
map.put(key, val);
|
|
}
|
|
}
|
|
}
|
|
|
|
return map;
|
|
}
|
|
|
|
@Override
|
|
@UnsupportedAppUsage
|
|
public void disconnect() {
|
|
mNumDisconnectingThreads.incrementAndGet();
|
|
try {
|
|
HttpURLConnection connectionToDisconnect = mConnection;
|
|
// Call disconnect() before blocking for the lock in order to ensure that any
|
|
// other thread that is blocked in readAt() will return quickly.
|
|
if (connectionToDisconnect != null) {
|
|
connectionToDisconnect.disconnect();
|
|
}
|
|
synchronized (this) {
|
|
// It's possible that while we were waiting to acquire the lock, another thread
|
|
// concurrently started a new connection; if so, we're disconnecting that one
|
|
// here, too.
|
|
teardownConnection();
|
|
mHeaders = null;
|
|
mURL = null;
|
|
}
|
|
} finally {
|
|
mNumDisconnectingThreads.decrementAndGet();
|
|
}
|
|
}
|
|
|
|
private synchronized void teardownConnection() {
|
|
if (mConnection != null) {
|
|
if (mInputStream != null) {
|
|
try {
|
|
mInputStream.close();
|
|
} catch (IOException e) {
|
|
}
|
|
mInputStream = null;
|
|
}
|
|
|
|
mConnection.disconnect();
|
|
mConnection = null;
|
|
|
|
mCurrentOffset = -1;
|
|
}
|
|
}
|
|
|
|
private static final boolean isLocalHost(URL url) {
|
|
if (url == null) {
|
|
return false;
|
|
}
|
|
|
|
String host = url.getHost();
|
|
|
|
if (host == null) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
if (host.equalsIgnoreCase("localhost")) {
|
|
return true;
|
|
}
|
|
if (NetworkUtils.numericToInetAddress(host).isLoopbackAddress()) {
|
|
return true;
|
|
}
|
|
} catch (IllegalArgumentException iex) {
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private synchronized void seekTo(long offset) throws IOException {
|
|
teardownConnection();
|
|
|
|
try {
|
|
int response;
|
|
int redirectCount = 0;
|
|
|
|
URL url = mURL;
|
|
|
|
// do not use any proxy for localhost (127.0.0.1)
|
|
boolean noProxy = isLocalHost(url);
|
|
|
|
while (true) {
|
|
// If another thread is concurrently disconnect()ing, there's a race
|
|
// between them and us. Therefore, we check mNumDisconnectingThreads shortly
|
|
// (not atomically) before & after writing mConnection. This guarantees that
|
|
// we won't "lose" a disconnect by creating a new connection that might
|
|
// miss the disconnect.
|
|
//
|
|
// Note that throwing an instanceof IOException is also what this thread
|
|
// would have done if another thread disconnect()ed the connection while
|
|
// this thread was blocked reading from that connection further down in this
|
|
// loop.
|
|
if (mNumDisconnectingThreads.get() > 0) {
|
|
throw new IOException("concurrently disconnecting");
|
|
}
|
|
if (noProxy) {
|
|
mConnection = (HttpURLConnection)url.openConnection(Proxy.NO_PROXY);
|
|
} else {
|
|
mConnection = (HttpURLConnection)url.openConnection();
|
|
}
|
|
// If another thread is concurrently disconnecting, throwing IOException will
|
|
// cause us to release the lock, giving the other thread a chance to acquire
|
|
// it. It also ensures that the catch block will run, which will tear down
|
|
// the connection even if the other thread happens to already be on its way
|
|
// out of disconnect().
|
|
if (mNumDisconnectingThreads.get() > 0) {
|
|
throw new IOException("concurrently disconnecting");
|
|
}
|
|
// If we get here without having thrown, we know that other threads
|
|
// will see our write to mConnection. Any disconnect() on that mConnection
|
|
// instance will cause our read from/write to that connection instance below
|
|
// to encounter an instanceof IOException.
|
|
mConnection.setConnectTimeout(CONNECT_TIMEOUT_MS);
|
|
|
|
// handle redirects ourselves if we do not allow cross-domain redirect
|
|
mConnection.setInstanceFollowRedirects(mAllowCrossDomainRedirect);
|
|
|
|
if (mHeaders != null) {
|
|
for (Map.Entry<String, String> entry : mHeaders.entrySet()) {
|
|
mConnection.setRequestProperty(
|
|
entry.getKey(), entry.getValue());
|
|
}
|
|
}
|
|
|
|
if (offset > 0) {
|
|
mConnection.setRequestProperty(
|
|
"Range", "bytes=" + offset + "-");
|
|
}
|
|
|
|
response = mConnection.getResponseCode();
|
|
if (response != HttpURLConnection.HTTP_MULT_CHOICE &&
|
|
response != HttpURLConnection.HTTP_MOVED_PERM &&
|
|
response != HttpURLConnection.HTTP_MOVED_TEMP &&
|
|
response != HttpURLConnection.HTTP_SEE_OTHER &&
|
|
response != HTTP_TEMP_REDIRECT) {
|
|
// not a redirect, or redirect handled by HttpURLConnection
|
|
break;
|
|
}
|
|
|
|
if (++redirectCount > MAX_REDIRECTS) {
|
|
throw new NoRouteToHostException("Too many redirects: " + redirectCount);
|
|
}
|
|
|
|
String method = mConnection.getRequestMethod();
|
|
if (response == HTTP_TEMP_REDIRECT &&
|
|
!method.equals("GET") && !method.equals("HEAD")) {
|
|
// "If the 307 status code is received in response to a
|
|
// request other than GET or HEAD, the user agent MUST NOT
|
|
// automatically redirect the request"
|
|
throw new NoRouteToHostException("Invalid redirect");
|
|
}
|
|
String location = mConnection.getHeaderField("Location");
|
|
if (location == null) {
|
|
throw new NoRouteToHostException("Invalid redirect");
|
|
}
|
|
url = new URL(mURL /* TRICKY: don't use url! */, location);
|
|
if (!url.getProtocol().equals("https") &&
|
|
!url.getProtocol().equals("http")) {
|
|
throw new NoRouteToHostException("Unsupported protocol redirect");
|
|
}
|
|
boolean sameProtocol = mURL.getProtocol().equals(url.getProtocol());
|
|
if (!mAllowCrossProtocolRedirect && !sameProtocol) {
|
|
throw new NoRouteToHostException("Cross-protocol redirects are disallowed");
|
|
}
|
|
boolean sameHost = mURL.getHost().equals(url.getHost());
|
|
if (!mAllowCrossDomainRedirect && !sameHost) {
|
|
throw new NoRouteToHostException("Cross-domain redirects are disallowed");
|
|
}
|
|
|
|
if (response != HTTP_TEMP_REDIRECT) {
|
|
// update effective URL, unless it is a Temporary Redirect
|
|
mURL = url;
|
|
}
|
|
}
|
|
|
|
if (mAllowCrossDomainRedirect) {
|
|
// remember the current, potentially redirected URL if redirects
|
|
// were handled by HttpURLConnection
|
|
mURL = mConnection.getURL();
|
|
}
|
|
|
|
if (response == HttpURLConnection.HTTP_PARTIAL) {
|
|
// Partial content, we cannot just use getContentLength
|
|
// because what we want is not just the length of the range
|
|
// returned but the size of the full content if available.
|
|
|
|
String contentRange =
|
|
mConnection.getHeaderField("Content-Range");
|
|
|
|
mTotalSize = -1;
|
|
if (contentRange != null) {
|
|
// format is "bytes xxx-yyy/zzz
|
|
// where "zzz" is the total number of bytes of the
|
|
// content or '*' if unknown.
|
|
|
|
int lastSlashPos = contentRange.lastIndexOf('/');
|
|
if (lastSlashPos >= 0) {
|
|
String total =
|
|
contentRange.substring(lastSlashPos + 1);
|
|
|
|
try {
|
|
mTotalSize = Long.parseLong(total);
|
|
} catch (NumberFormatException e) {
|
|
}
|
|
}
|
|
}
|
|
} else if (response != HttpURLConnection.HTTP_OK) {
|
|
throw new IOException();
|
|
} else {
|
|
mTotalSize = mConnection.getContentLength();
|
|
}
|
|
|
|
if (offset > 0 && response != HttpURLConnection.HTTP_PARTIAL) {
|
|
// Some servers simply ignore "Range" requests and serve
|
|
// data from the start of the content.
|
|
throw new ProtocolException();
|
|
}
|
|
|
|
mInputStream =
|
|
new BufferedInputStream(mConnection.getInputStream());
|
|
|
|
mCurrentOffset = offset;
|
|
} catch (IOException e) {
|
|
mTotalSize = -1;
|
|
teardownConnection();
|
|
mCurrentOffset = -1;
|
|
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
@Override
|
|
@UnsupportedAppUsage
|
|
public synchronized int readAt(long offset, int size) {
|
|
return native_readAt(offset, size);
|
|
}
|
|
|
|
private synchronized int readAt(long offset, byte[] data, int size) {
|
|
StrictMode.ThreadPolicy policy =
|
|
new StrictMode.ThreadPolicy.Builder().permitAll().build();
|
|
|
|
StrictMode.setThreadPolicy(policy);
|
|
|
|
try {
|
|
if (offset != mCurrentOffset) {
|
|
seekTo(offset);
|
|
}
|
|
|
|
int n = mInputStream.read(data, 0, size);
|
|
|
|
if (n == -1) {
|
|
// InputStream signals EOS using a -1 result, our semantics
|
|
// are to return a 0-length read.
|
|
n = 0;
|
|
}
|
|
|
|
mCurrentOffset += n;
|
|
|
|
if (VERBOSE) {
|
|
Log.d(TAG, "readAt " + offset + " / " + size + " => " + n);
|
|
}
|
|
|
|
return n;
|
|
} catch (ProtocolException e) {
|
|
Log.w(TAG, "readAt " + offset + " / " + size + " => " + e);
|
|
return MEDIA_ERROR_UNSUPPORTED;
|
|
} catch (NoRouteToHostException e) {
|
|
Log.w(TAG, "readAt " + offset + " / " + size + " => " + e);
|
|
return MEDIA_ERROR_UNSUPPORTED;
|
|
} catch (UnknownServiceException e) {
|
|
Log.w(TAG, "readAt " + offset + " / " + size + " => " + e);
|
|
return MEDIA_ERROR_UNSUPPORTED;
|
|
} catch (IOException e) {
|
|
if (VERBOSE) {
|
|
Log.d(TAG, "readAt " + offset + " / " + size + " => -1");
|
|
}
|
|
return -1;
|
|
} catch (Exception e) {
|
|
if (VERBOSE) {
|
|
Log.d(TAG, "unknown exception " + e);
|
|
Log.d(TAG, "readAt " + offset + " / " + size + " => -1");
|
|
}
|
|
return -1;
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public synchronized long getSize() {
|
|
if (mConnection == null) {
|
|
try {
|
|
seekTo(0);
|
|
} catch (IOException e) {
|
|
return -1;
|
|
}
|
|
}
|
|
|
|
return mTotalSize;
|
|
}
|
|
|
|
@Override
|
|
@UnsupportedAppUsage
|
|
public synchronized String getMIMEType() {
|
|
if (mConnection == null) {
|
|
try {
|
|
seekTo(0);
|
|
} catch (IOException e) {
|
|
return "application/octet-stream";
|
|
}
|
|
}
|
|
|
|
return mConnection.getContentType();
|
|
}
|
|
|
|
@Override
|
|
@UnsupportedAppUsage
|
|
public synchronized String getUri() {
|
|
return mURL.toString();
|
|
}
|
|
|
|
@Override
|
|
protected void finalize() {
|
|
native_finalize();
|
|
}
|
|
|
|
private static native final void native_init();
|
|
private native final void native_setup();
|
|
private native final void native_finalize();
|
|
|
|
private native final IBinder native_getIMemory();
|
|
private native final int native_readAt(long offset, int size);
|
|
|
|
static {
|
|
System.loadLibrary("media_jni");
|
|
native_init();
|
|
}
|
|
|
|
private long mNativeContext;
|
|
|
|
}
|