/* * 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.compat.annotation.UnsupportedAppUsage; import android.net.InetAddresses; 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 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 convertHeaderStringToMap(String headers) { HashMap map = new HashMap(); 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 (InetAddresses.parseNumericAddress(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 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; }