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