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