MediaHTTPConnection's public methods are called from multiple Binder
threads. Since both HttpURLConnection and access to the various
connection related fields is not thread safe, this CL guards most
methods by a single lock. This means that the methods can now block
when called, although this should be rare:
- there are two processes that call these methods. One process
only calls getSize(), and the other process calls methods
from a single thread (ie. at not overlapping clock times).
- should lock contention unexpectedly increase in future, then
that would be bad (because Binder thread pool threads would
be blocked/unavailable), but it would not be easy to detect.
It would be easy to detect if we could stop getSize() being
called at overlapping clock times, since we could then use
ReentrantLock.tryLock() to assert that the lock is never contended
outside of disconnect().
Because it's a requirement for disconnect() to quickly stop another
thread that is blocked in readAt(), disconnect() is the only method
that doesn't acquire the lock immediately; the mConnection field
is marked volatile so that disconnect() has a high chance of reading
that field and calling disconnect() on it without waiting for
another thread (there's a small risk that another thread might
acquire the lock and start a new connection while disconnect()
is waiting for the lock; in that case, after acquiring the lock,
disconnect() will also disconnect that new connection; this is
subject to potential change in future.
Initially, a ReentrantLock object was considered but for now this
CL instead uses the synchronized lock on "this" because:
- it minimizes churn on the lines of code in this file because
synchronized (this) { } can be expressed by introduction of
the word "synchronized" on the method header, whereas
mLock.lock(); try { ... } finally { mLock.unlock(); } would
indent all the lines in-between and thus pollute git annotate.
- some methods were already synchronized.
- ReentrantLock.tryLock() is not used for now; most of the time,
lock acquisition should be uncontended but the two cases of
lock contention mentioned above exist, which makes it difficult
to distinguish surprising from unsurprising lock contention.
While this is the case, it seems better to keep the code
simple and to just unconditionally block.
Bug: 114337214
Fixes: 114337214
Fixes: 119900000
Fixes: 129444137
Fixes: 128758794
Fixes: 63002045
Test: Checked manually that bug 114337214 no longer reproduces on
Android API level 27 (Oreo MR1) after cherrypicking this CL.
Test: Ran the following on internal master with this CL:
make cts && cts-tradefed run cts -m CtsMediaTestCases \
-t android.media.cts.NativeDecoderTest#testAMediaDataSourceClose \
--abi arm64-v8a
Test: Ran the following both on AOSP (158 tests) and internal master (178):
atest CtsMediaTestCases:android.media.cts.{MediaPlayer{,2},Routing}Test
All these tests pass except that on AOSP only, the following test
fails both before and after my CL (appears unrelated):
android.media.cts.RoutingTest#test_MediaPlayer_RoutingChangedCallback
(cherry picked from commit 8d9fccee62)
Change-Id: I4e32a58891c3ce60ddfa72d36060486d37906f8d
Merged-In: I4e32a58891c3ce60ddfa72d36060486d37906f8d
459 lines
15 KiB
Java
459 lines
15 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;
|
|
|
|
/** @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;
|
|
|
|
@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() {
|
|
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 unlikely but 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;
|
|
}
|
|
}
|
|
|
|
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 (noProxy) {
|
|
mConnection = (HttpURLConnection)url.openConnection(Proxy.NO_PROXY);
|
|
} else {
|
|
mConnection = (HttpURLConnection)url.openConnection();
|
|
}
|
|
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;
|
|
|
|
}
|