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
17package android.media;
18
19import android.net.NetworkUtils;
20import android.os.IBinder;
21import android.os.StrictMode;
22import android.util.Log;
23
24import java.io.BufferedInputStream;
25import java.io.InputStream;
26import java.io.IOException;
27import java.net.CookieHandler;
28import java.net.CookieManager;
29import java.net.Proxy;
30import java.net.URL;
31import java.net.HttpURLConnection;
32import java.net.MalformedURLException;
33import java.net.NoRouteToHostException;
34import java.net.ProtocolException;
35import java.net.UnknownServiceException;
36import java.util.HashMap;
37import java.util.Map;
38
39import static android.media.MediaPlayer.MEDIA_ERROR_UNSUPPORTED;
40
41/** @hide */
42public class MediaHTTPConnection extends IMediaHTTPConnection.Stub {
43    private static final String TAG = "MediaHTTPConnection";
44    private static final boolean VERBOSE = false;
45
46    // connection timeout - 30 sec
47    private static final int CONNECT_TIMEOUT_MS = 30 * 1000;
48
49    private long mCurrentOffset = -1;
50    private URL mURL = null;
51    private Map<String, String> mHeaders = null;
52    private HttpURLConnection mConnection = null;
53    private long mTotalSize = -1;
54    private InputStream mInputStream = null;
55
56    private boolean mAllowCrossDomainRedirect = true;
57    private boolean mAllowCrossProtocolRedirect = true;
58
59    // from com.squareup.okhttp.internal.http
60    private final static int HTTP_TEMP_REDIRECT = 307;
61    private final static int MAX_REDIRECTS = 20;
62
63    public MediaHTTPConnection() {
64        CookieManager cookieManager = (CookieManager)CookieHandler.getDefault();
65        if (cookieManager == null) {
66            Log.w(TAG, "MediaHTTPConnection: Unexpected. No CookieManager found.");
67        }
68
69        native_setup();
70    }
71
72    @Override
73    public IBinder connect(String uri, String headers) {
74        if (VERBOSE) {
75            Log.d(TAG, "connect: uri=" + uri + ", headers=" + headers);
76        }
77
78        try {
79            disconnect();
80            mAllowCrossDomainRedirect = true;
81            mURL = new URL(uri);
82            mHeaders = convertHeaderStringToMap(headers);
83        } catch (MalformedURLException e) {
84            return null;
85        }
86
87        return native_getIMemory();
88    }
89
90    private boolean parseBoolean(String val) {
91        try {
92            return Long.parseLong(val) != 0;
93        } catch (NumberFormatException e) {
94            return "true".equalsIgnoreCase(val) ||
95                "yes".equalsIgnoreCase(val);
96        }
97    }
98
99    /* returns true iff header is internal */
100    private boolean filterOutInternalHeaders(String key, String val) {
101        if ("android-allow-cross-domain-redirect".equalsIgnoreCase(key)) {
102            mAllowCrossDomainRedirect = parseBoolean(val);
103            // cross-protocol redirects are also controlled by this flag
104            mAllowCrossProtocolRedirect = mAllowCrossDomainRedirect;
105        } else {
106            return false;
107        }
108        return true;
109    }
110
111    private Map<String, String> convertHeaderStringToMap(String headers) {
112        HashMap<String, String> map = new HashMap<String, String>();
113
114        String[] pairs = headers.split("\r\n");
115        for (String pair : pairs) {
116            int colonPos = pair.indexOf(":");
117            if (colonPos >= 0) {
118                String key = pair.substring(0, colonPos);
119                String val = pair.substring(colonPos + 1);
120
121                if (!filterOutInternalHeaders(key, val)) {
122                    map.put(key, val);
123                }
124            }
125        }
126
127        return map;
128    }
129
130    @Override
131    public void disconnect() {
132        teardownConnection();
133        mHeaders = null;
134        mURL = null;
135    }
136
137    private void teardownConnection() {
138        if (mConnection != null) {
139            if (mInputStream != null) {
140                try {
141                    mInputStream.close();
142                } catch (IOException e) {
143                }
144                mInputStream = null;
145            }
146
147            mConnection.disconnect();
148            mConnection = null;
149
150            mCurrentOffset = -1;
151        }
152    }
153
154    private static final boolean isLocalHost(URL url) {
155        if (url == null) {
156            return false;
157        }
158
159        String host = url.getHost();
160
161        if (host == null) {
162            return false;
163        }
164
165        try {
166            if (host.equalsIgnoreCase("localhost")) {
167                return true;
168            }
169            if (NetworkUtils.numericToInetAddress(host).isLoopbackAddress()) {
170                return true;
171            }
172        } catch (IllegalArgumentException iex) {
173        }
174        return false;
175    }
176
177    private void seekTo(long offset) throws IOException {
178        teardownConnection();
179
180        try {
181            int response;
182            int redirectCount = 0;
183
184            URL url = mURL;
185
186            // do not use any proxy for localhost (127.0.0.1)
187            boolean noProxy = isLocalHost(url);
188
189            while (true) {
190                if (noProxy) {
191                    mConnection = (HttpURLConnection)url.openConnection(Proxy.NO_PROXY);
192                } else {
193                    mConnection = (HttpURLConnection)url.openConnection();
194                }
195                mConnection.setConnectTimeout(CONNECT_TIMEOUT_MS);
196
197                // handle redirects ourselves if we do not allow cross-domain redirect
198                mConnection.setInstanceFollowRedirects(mAllowCrossDomainRedirect);
199
200                if (mHeaders != null) {
201                    for (Map.Entry<String, String> entry : mHeaders.entrySet()) {
202                        mConnection.setRequestProperty(
203                                entry.getKey(), entry.getValue());
204                    }
205                }
206
207                if (offset > 0) {
208                    mConnection.setRequestProperty(
209                            "Range", "bytes=" + offset + "-");
210                }
211
212                response = mConnection.getResponseCode();
213                if (response != HttpURLConnection.HTTP_MULT_CHOICE &&
214                        response != HttpURLConnection.HTTP_MOVED_PERM &&
215                        response != HttpURLConnection.HTTP_MOVED_TEMP &&
216                        response != HttpURLConnection.HTTP_SEE_OTHER &&
217                        response != HTTP_TEMP_REDIRECT) {
218                    // not a redirect, or redirect handled by HttpURLConnection
219                    break;
220                }
221
222                if (++redirectCount > MAX_REDIRECTS) {
223                    throw new NoRouteToHostException("Too many redirects: " + redirectCount);
224                }
225
226                String method = mConnection.getRequestMethod();
227                if (response == HTTP_TEMP_REDIRECT &&
228                        !method.equals("GET") && !method.equals("HEAD")) {
229                    // "If the 307 status code is received in response to a
230                    // request other than GET or HEAD, the user agent MUST NOT
231                    // automatically redirect the request"
232                    throw new NoRouteToHostException("Invalid redirect");
233                }
234                String location = mConnection.getHeaderField("Location");
235                if (location == null) {
236                    throw new NoRouteToHostException("Invalid redirect");
237                }
238                url = new URL(mURL /* TRICKY: don't use url! */, location);
239                if (!url.getProtocol().equals("https") &&
240                        !url.getProtocol().equals("http")) {
241                    throw new NoRouteToHostException("Unsupported protocol redirect");
242                }
243                boolean sameProtocol = mURL.getProtocol().equals(url.getProtocol());
244                if (!mAllowCrossProtocolRedirect && !sameProtocol) {
245                    throw new NoRouteToHostException("Cross-protocol redirects are disallowed");
246                }
247                boolean sameHost = mURL.getHost().equals(url.getHost());
248                if (!mAllowCrossDomainRedirect && !sameHost) {
249                    throw new NoRouteToHostException("Cross-domain redirects are disallowed");
250                }
251
252                if (response != HTTP_TEMP_REDIRECT) {
253                    // update effective URL, unless it is a Temporary Redirect
254                    mURL = url;
255                }
256            }
257
258            if (mAllowCrossDomainRedirect) {
259                // remember the current, potentially redirected URL if redirects
260                // were handled by HttpURLConnection
261                mURL = mConnection.getURL();
262            }
263
264            if (response == HttpURLConnection.HTTP_PARTIAL) {
265                // Partial content, we cannot just use getContentLength
266                // because what we want is not just the length of the range
267                // returned but the size of the full content if available.
268
269                String contentRange =
270                    mConnection.getHeaderField("Content-Range");
271
272                mTotalSize = -1;
273                if (contentRange != null) {
274                    // format is "bytes xxx-yyy/zzz
275                    // where "zzz" is the total number of bytes of the
276                    // content or '*' if unknown.
277
278                    int lastSlashPos = contentRange.lastIndexOf('/');
279                    if (lastSlashPos >= 0) {
280                        String total =
281                            contentRange.substring(lastSlashPos + 1);
282
283                        try {
284                            mTotalSize = Long.parseLong(total);
285                        } catch (NumberFormatException e) {
286                        }
287                    }
288                }
289            } else if (response != HttpURLConnection.HTTP_OK) {
290                throw new IOException();
291            } else {
292                mTotalSize = mConnection.getContentLength();
293            }
294
295            if (offset > 0 && response != HttpURLConnection.HTTP_PARTIAL) {
296                // Some servers simply ignore "Range" requests and serve
297                // data from the start of the content.
298                throw new ProtocolException();
299            }
300
301            mInputStream =
302                new BufferedInputStream(mConnection.getInputStream());
303
304            mCurrentOffset = offset;
305        } catch (IOException e) {
306            mTotalSize = -1;
307            teardownConnection();
308            mCurrentOffset = -1;
309
310            throw e;
311        }
312    }
313
314    @Override
315    public int readAt(long offset, int size) {
316        return native_readAt(offset, size);
317    }
318
319    private int readAt(long offset, byte[] data, int size) {
320        StrictMode.ThreadPolicy policy =
321            new StrictMode.ThreadPolicy.Builder().permitAll().build();
322
323        StrictMode.setThreadPolicy(policy);
324
325        try {
326            if (offset != mCurrentOffset) {
327                seekTo(offset);
328            }
329
330            int n = mInputStream.read(data, 0, size);
331
332            if (n == -1) {
333                // InputStream signals EOS using a -1 result, our semantics
334                // are to return a 0-length read.
335                n = 0;
336            }
337
338            mCurrentOffset += n;
339
340            if (VERBOSE) {
341                Log.d(TAG, "readAt " + offset + " / " + size + " => " + n);
342            }
343
344            return n;
345        } catch (ProtocolException e) {
346            Log.w(TAG, "readAt " + offset + " / " + size + " => " + e);
347            return MEDIA_ERROR_UNSUPPORTED;
348        } catch (NoRouteToHostException e) {
349            Log.w(TAG, "readAt " + offset + " / " + size + " => " + e);
350            return MEDIA_ERROR_UNSUPPORTED;
351        } catch (UnknownServiceException e) {
352            Log.w(TAG, "readAt " + offset + " / " + size + " => " + e);
353            return MEDIA_ERROR_UNSUPPORTED;
354        } catch (IOException e) {
355            if (VERBOSE) {
356                Log.d(TAG, "readAt " + offset + " / " + size + " => -1");
357            }
358            return -1;
359        } catch (Exception e) {
360            if (VERBOSE) {
361                Log.d(TAG, "unknown exception " + e);
362                Log.d(TAG, "readAt " + offset + " / " + size + " => -1");
363            }
364            return -1;
365        }
366    }
367
368    @Override
369    public long getSize() {
370        if (mConnection == null) {
371            try {
372                seekTo(0);
373            } catch (IOException e) {
374                return -1;
375            }
376        }
377
378        return mTotalSize;
379    }
380
381    @Override
382    public String getMIMEType() {
383        if (mConnection == null) {
384            try {
385                seekTo(0);
386            } catch (IOException e) {
387                return "application/octet-stream";
388            }
389        }
390
391        return mConnection.getContentType();
392    }
393
394    @Override
395    public String getUri() {
396        return mURL.toString();
397    }
398
399    @Override
400    protected void finalize() {
401        native_finalize();
402    }
403
404    private static native final void native_init();
405    private native final void native_setup();
406    private native final void native_finalize();
407
408    private native final IBinder native_getIMemory();
409    private native final int native_readAt(long offset, int size);
410
411    static {
412        System.loadLibrary("media_jni");
413        native_init();
414    }
415
416    private long mNativeContext;
417
418}
419