1 /*
2  * Copyright (C) 2013 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package android.media;
18 
19 import static android.media.MediaPlayer.MEDIA_ERROR_UNSUPPORTED;
20 
21 import android.compat.annotation.UnsupportedAppUsage;
22 import android.net.NetworkUtils;
23 import android.os.IBinder;
24 import android.os.StrictMode;
25 import android.util.Log;
26 
27 import com.android.internal.annotations.GuardedBy;
28 
29 import java.io.BufferedInputStream;
30 import java.io.IOException;
31 import java.io.InputStream;
32 import java.net.CookieHandler;
33 import java.net.HttpURLConnection;
34 import java.net.MalformedURLException;
35 import java.net.NoRouteToHostException;
36 import java.net.ProtocolException;
37 import java.net.Proxy;
38 import java.net.URL;
39 import java.net.UnknownServiceException;
40 import java.util.HashMap;
41 import java.util.Map;
42 import java.util.concurrent.atomic.AtomicInteger;
43 
44 /** @hide */
45 public class MediaHTTPConnection extends IMediaHTTPConnection.Stub {
46     private static final String TAG = "MediaHTTPConnection";
47     private static final boolean VERBOSE = false;
48 
49     // connection timeout - 30 sec
50     private static final int CONNECT_TIMEOUT_MS = 30 * 1000;
51 
52     @GuardedBy("this")
53     @UnsupportedAppUsage
54     private long mCurrentOffset = -1;
55 
56     @GuardedBy("this")
57     @UnsupportedAppUsage
58     private URL mURL = null;
59 
60     @GuardedBy("this")
61     @UnsupportedAppUsage
62     private Map<String, String> mHeaders = null;
63 
64     // volatile so that disconnect() can be called without acquiring a lock.
65     // All other access is @GuardedBy("this").
66     @UnsupportedAppUsage
67     private volatile HttpURLConnection mConnection = null;
68 
69     @GuardedBy("this")
70     @UnsupportedAppUsage
71     private long mTotalSize = -1;
72 
73     @GuardedBy("this")
74     private InputStream mInputStream = null;
75 
76     @GuardedBy("this")
77     @UnsupportedAppUsage
78     private boolean mAllowCrossDomainRedirect = true;
79 
80     @GuardedBy("this")
81     @UnsupportedAppUsage
82     private boolean mAllowCrossProtocolRedirect = true;
83 
84     // from com.squareup.okhttp.internal.http
85     private final static int HTTP_TEMP_REDIRECT = 307;
86     private final static int MAX_REDIRECTS = 20;
87 
88     // The number of threads that are currently running disconnect() (possibly
89     // not yet holding the synchronized lock).
90     private final AtomicInteger mNumDisconnectingThreads = new AtomicInteger(0);
91 
92     @UnsupportedAppUsage
MediaHTTPConnection()93     public MediaHTTPConnection() {
94         CookieHandler cookieHandler = CookieHandler.getDefault();
95         if (cookieHandler == null) {
96             Log.w(TAG, "MediaHTTPConnection: Unexpected. No CookieHandler found.");
97         }
98 
99         native_setup();
100     }
101 
102     @Override
103     @UnsupportedAppUsage
connect(String uri, String headers)104     public synchronized IBinder connect(String uri, String headers) {
105         if (VERBOSE) {
106             Log.d(TAG, "connect: uri=" + uri + ", headers=" + headers);
107         }
108 
109         try {
110             disconnect();
111             mAllowCrossDomainRedirect = true;
112             mURL = new URL(uri);
113             mHeaders = convertHeaderStringToMap(headers);
114         } catch (MalformedURLException e) {
115             return null;
116         }
117 
118         return native_getIMemory();
119     }
120 
parseBoolean(String val)121     private static boolean parseBoolean(String val) {
122         try {
123             return Long.parseLong(val) != 0;
124         } catch (NumberFormatException e) {
125             return "true".equalsIgnoreCase(val) ||
126                 "yes".equalsIgnoreCase(val);
127         }
128     }
129 
130     /* returns true iff header is internal */
filterOutInternalHeaders(String key, String val)131     private synchronized boolean filterOutInternalHeaders(String key, String val) {
132         if ("android-allow-cross-domain-redirect".equalsIgnoreCase(key)) {
133             mAllowCrossDomainRedirect = parseBoolean(val);
134             // cross-protocol redirects are also controlled by this flag
135             mAllowCrossProtocolRedirect = mAllowCrossDomainRedirect;
136         } else {
137             return false;
138         }
139         return true;
140     }
141 
convertHeaderStringToMap(String headers)142     private synchronized Map<String, String> convertHeaderStringToMap(String headers) {
143         HashMap<String, String> map = new HashMap<String, String>();
144 
145         String[] pairs = headers.split("\r\n");
146         for (String pair : pairs) {
147             int colonPos = pair.indexOf(":");
148             if (colonPos >= 0) {
149                 String key = pair.substring(0, colonPos);
150                 String val = pair.substring(colonPos + 1);
151 
152                 if (!filterOutInternalHeaders(key, val)) {
153                     map.put(key, val);
154                 }
155             }
156         }
157 
158         return map;
159     }
160 
161     @Override
162     @UnsupportedAppUsage
disconnect()163     public void disconnect() {
164         mNumDisconnectingThreads.incrementAndGet();
165         try {
166             HttpURLConnection connectionToDisconnect = mConnection;
167             // Call disconnect() before blocking for the lock in order to ensure that any
168             // other thread that is blocked in readAt() will return quickly.
169             if (connectionToDisconnect != null) {
170                 connectionToDisconnect.disconnect();
171             }
172             synchronized (this) {
173                 // It's possible that while we were waiting to acquire the lock, another thread
174                 // concurrently started a new connection; if so, we're disconnecting that one
175                 // here, too.
176                 teardownConnection();
177                 mHeaders = null;
178                 mURL = null;
179             }
180         } finally {
181             mNumDisconnectingThreads.decrementAndGet();
182         }
183     }
184 
teardownConnection()185     private synchronized void teardownConnection() {
186         if (mConnection != null) {
187             if (mInputStream != null) {
188                 try {
189                     mInputStream.close();
190                 } catch (IOException e) {
191                 }
192                 mInputStream = null;
193             }
194 
195             mConnection.disconnect();
196             mConnection = null;
197 
198             mCurrentOffset = -1;
199         }
200     }
201 
isLocalHost(URL url)202     private static final boolean isLocalHost(URL url) {
203         if (url == null) {
204             return false;
205         }
206 
207         String host = url.getHost();
208 
209         if (host == null) {
210             return false;
211         }
212 
213         try {
214             if (host.equalsIgnoreCase("localhost")) {
215                 return true;
216             }
217             if (NetworkUtils.numericToInetAddress(host).isLoopbackAddress()) {
218                 return true;
219             }
220         } catch (IllegalArgumentException iex) {
221         }
222         return false;
223     }
224 
seekTo(long offset)225     private synchronized void seekTo(long offset) throws IOException {
226         teardownConnection();
227 
228         try {
229             int response;
230             int redirectCount = 0;
231 
232             URL url = mURL;
233 
234             // do not use any proxy for localhost (127.0.0.1)
235             boolean noProxy = isLocalHost(url);
236 
237             while (true) {
238                 // If another thread is concurrently disconnect()ing, there's a race
239                 // between them and us. Therefore, we check mNumDisconnectingThreads shortly
240                 // (not atomically) before & after writing mConnection. This guarantees that
241                 // we won't "lose" a disconnect by creating a new connection that might
242                 // miss the disconnect.
243                 //
244                 // Note that throwing an instanceof IOException is also what this thread
245                 // would have done if another thread disconnect()ed the connection while
246                 // this thread was blocked reading from that connection further down in this
247                 // loop.
248                 if (mNumDisconnectingThreads.get() > 0) {
249                     throw new IOException("concurrently disconnecting");
250                 }
251                 if (noProxy) {
252                     mConnection = (HttpURLConnection)url.openConnection(Proxy.NO_PROXY);
253                 } else {
254                     mConnection = (HttpURLConnection)url.openConnection();
255                 }
256                 // If another thread is concurrently disconnecting, throwing IOException will
257                 // cause us to release the lock, giving the other thread a chance to acquire
258                 // it. It also ensures that the catch block will run, which will tear down
259                 // the connection even if the other thread happens to already be on its way
260                 // out of disconnect().
261                 if (mNumDisconnectingThreads.get() > 0) {
262                     throw new IOException("concurrently disconnecting");
263                 }
264                 // If we get here without having thrown, we know that other threads
265                 // will see our write to mConnection. Any disconnect() on that mConnection
266                 // instance will cause our read from/write to that connection instance below
267                 // to encounter an instanceof IOException.
268                 mConnection.setConnectTimeout(CONNECT_TIMEOUT_MS);
269 
270                 // handle redirects ourselves if we do not allow cross-domain redirect
271                 mConnection.setInstanceFollowRedirects(mAllowCrossDomainRedirect);
272 
273                 if (mHeaders != null) {
274                     for (Map.Entry<String, String> entry : mHeaders.entrySet()) {
275                         mConnection.setRequestProperty(
276                                 entry.getKey(), entry.getValue());
277                     }
278                 }
279 
280                 if (offset > 0) {
281                     mConnection.setRequestProperty(
282                             "Range", "bytes=" + offset + "-");
283                 }
284 
285                 response = mConnection.getResponseCode();
286                 if (response != HttpURLConnection.HTTP_MULT_CHOICE &&
287                         response != HttpURLConnection.HTTP_MOVED_PERM &&
288                         response != HttpURLConnection.HTTP_MOVED_TEMP &&
289                         response != HttpURLConnection.HTTP_SEE_OTHER &&
290                         response != HTTP_TEMP_REDIRECT) {
291                     // not a redirect, or redirect handled by HttpURLConnection
292                     break;
293                 }
294 
295                 if (++redirectCount > MAX_REDIRECTS) {
296                     throw new NoRouteToHostException("Too many redirects: " + redirectCount);
297                 }
298 
299                 String method = mConnection.getRequestMethod();
300                 if (response == HTTP_TEMP_REDIRECT &&
301                         !method.equals("GET") && !method.equals("HEAD")) {
302                     // "If the 307 status code is received in response to a
303                     // request other than GET or HEAD, the user agent MUST NOT
304                     // automatically redirect the request"
305                     throw new NoRouteToHostException("Invalid redirect");
306                 }
307                 String location = mConnection.getHeaderField("Location");
308                 if (location == null) {
309                     throw new NoRouteToHostException("Invalid redirect");
310                 }
311                 url = new URL(mURL /* TRICKY: don't use url! */, location);
312                 if (!url.getProtocol().equals("https") &&
313                         !url.getProtocol().equals("http")) {
314                     throw new NoRouteToHostException("Unsupported protocol redirect");
315                 }
316                 boolean sameProtocol = mURL.getProtocol().equals(url.getProtocol());
317                 if (!mAllowCrossProtocolRedirect && !sameProtocol) {
318                     throw new NoRouteToHostException("Cross-protocol redirects are disallowed");
319                 }
320                 boolean sameHost = mURL.getHost().equals(url.getHost());
321                 if (!mAllowCrossDomainRedirect && !sameHost) {
322                     throw new NoRouteToHostException("Cross-domain redirects are disallowed");
323                 }
324 
325                 if (response != HTTP_TEMP_REDIRECT) {
326                     // update effective URL, unless it is a Temporary Redirect
327                     mURL = url;
328                 }
329             }
330 
331             if (mAllowCrossDomainRedirect) {
332                 // remember the current, potentially redirected URL if redirects
333                 // were handled by HttpURLConnection
334                 mURL = mConnection.getURL();
335             }
336 
337             if (response == HttpURLConnection.HTTP_PARTIAL) {
338                 // Partial content, we cannot just use getContentLength
339                 // because what we want is not just the length of the range
340                 // returned but the size of the full content if available.
341 
342                 String contentRange =
343                     mConnection.getHeaderField("Content-Range");
344 
345                 mTotalSize = -1;
346                 if (contentRange != null) {
347                     // format is "bytes xxx-yyy/zzz
348                     // where "zzz" is the total number of bytes of the
349                     // content or '*' if unknown.
350 
351                     int lastSlashPos = contentRange.lastIndexOf('/');
352                     if (lastSlashPos >= 0) {
353                         String total =
354                             contentRange.substring(lastSlashPos + 1);
355 
356                         try {
357                             mTotalSize = Long.parseLong(total);
358                         } catch (NumberFormatException e) {
359                         }
360                     }
361                 }
362             } else if (response != HttpURLConnection.HTTP_OK) {
363                 throw new IOException();
364             } else {
365                 mTotalSize = mConnection.getContentLength();
366             }
367 
368             if (offset > 0 && response != HttpURLConnection.HTTP_PARTIAL) {
369                 // Some servers simply ignore "Range" requests and serve
370                 // data from the start of the content.
371                 throw new ProtocolException();
372             }
373 
374             mInputStream =
375                 new BufferedInputStream(mConnection.getInputStream());
376 
377             mCurrentOffset = offset;
378         } catch (IOException e) {
379             mTotalSize = -1;
380             teardownConnection();
381             mCurrentOffset = -1;
382 
383             throw e;
384         }
385     }
386 
387     @Override
388     @UnsupportedAppUsage
readAt(long offset, int size)389     public synchronized int readAt(long offset, int size) {
390         return native_readAt(offset, size);
391     }
392 
readAt(long offset, byte[] data, int size)393     private synchronized int readAt(long offset, byte[] data, int size) {
394         StrictMode.ThreadPolicy policy =
395             new StrictMode.ThreadPolicy.Builder().permitAll().build();
396 
397         StrictMode.setThreadPolicy(policy);
398 
399         try {
400             if (offset != mCurrentOffset) {
401                 seekTo(offset);
402             }
403 
404             int n = mInputStream.read(data, 0, size);
405 
406             if (n == -1) {
407                 // InputStream signals EOS using a -1 result, our semantics
408                 // are to return a 0-length read.
409                 n = 0;
410             }
411 
412             mCurrentOffset += n;
413 
414             if (VERBOSE) {
415                 Log.d(TAG, "readAt " + offset + " / " + size + " => " + n);
416             }
417 
418             return n;
419         } catch (ProtocolException e) {
420             Log.w(TAG, "readAt " + offset + " / " + size + " => " + e);
421             return MEDIA_ERROR_UNSUPPORTED;
422         } catch (NoRouteToHostException e) {
423             Log.w(TAG, "readAt " + offset + " / " + size + " => " + e);
424             return MEDIA_ERROR_UNSUPPORTED;
425         } catch (UnknownServiceException e) {
426             Log.w(TAG, "readAt " + offset + " / " + size + " => " + e);
427             return MEDIA_ERROR_UNSUPPORTED;
428         } catch (IOException e) {
429             if (VERBOSE) {
430                 Log.d(TAG, "readAt " + offset + " / " + size + " => -1");
431             }
432             return -1;
433         } catch (Exception e) {
434             if (VERBOSE) {
435                 Log.d(TAG, "unknown exception " + e);
436                 Log.d(TAG, "readAt " + offset + " / " + size + " => -1");
437             }
438             return -1;
439         }
440     }
441 
442     @Override
getSize()443     public synchronized long getSize() {
444         if (mConnection == null) {
445             try {
446                 seekTo(0);
447             } catch (IOException e) {
448                 return -1;
449             }
450         }
451 
452         return mTotalSize;
453     }
454 
455     @Override
456     @UnsupportedAppUsage
getMIMEType()457     public synchronized String getMIMEType() {
458         if (mConnection == null) {
459             try {
460                 seekTo(0);
461             } catch (IOException e) {
462                 return "application/octet-stream";
463             }
464         }
465 
466         return mConnection.getContentType();
467     }
468 
469     @Override
470     @UnsupportedAppUsage
getUri()471     public synchronized String getUri() {
472         return mURL.toString();
473     }
474 
475     @Override
finalize()476     protected void finalize() {
477         native_finalize();
478     }
479 
native_init()480     private static native final void native_init();
native_setup()481     private native final void native_setup();
native_finalize()482     private native final void native_finalize();
483 
native_getIMemory()484     private native final IBinder native_getIMemory();
native_readAt(long offset, int size)485     private native final int native_readAt(long offset, int size);
486 
487     static {
488         System.loadLibrary("media_jni");
native_init()489         native_init();
490     }
491 
492     private long mNativeContext;
493 
494 }
495