1// Copyright 2014 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5package org.chromium.net;
6
7import android.util.Log;
8
9import org.apache.http.conn.ConnectTimeoutException;
10import org.chromium.base.CalledByNative;
11import org.chromium.base.JNINamespace;
12
13import java.io.IOException;
14import java.net.MalformedURLException;
15import java.net.URL;
16import java.net.UnknownHostException;
17import java.nio.ByteBuffer;
18import java.nio.channels.ReadableByteChannel;
19import java.nio.channels.WritableByteChannel;
20import java.util.ArrayList;
21import java.util.HashMap;
22import java.util.List;
23import java.util.Map;
24import java.util.Map.Entry;
25
26/**
27 * Network request using the native http stack implementation.
28 */
29@JNINamespace("cronet")
30public class ChromiumUrlRequest implements HttpUrlRequest {
31    /**
32     * Native adapter object, owned by UrlRequest.
33     */
34    private long mUrlRequestAdapter;
35    private final ChromiumUrlRequestContext mRequestContext;
36    private final String mUrl;
37    private final int mPriority;
38    private final Map<String, String> mHeaders;
39    private final WritableByteChannel mSink;
40    private Map<String, String> mAdditionalHeaders;
41    private String mUploadContentType;
42    private String mMethod;
43    private byte[] mUploadData;
44    private ReadableByteChannel mUploadChannel;
45    private boolean mChunkedUpload;
46    private IOException mSinkException;
47    private volatile boolean mStarted;
48    private volatile boolean mCanceled;
49    private volatile boolean mRecycled;
50    private volatile boolean mFinished;
51    private boolean mHeadersAvailable;
52    private String mContentType;
53    private long mUploadContentLength;
54    private final HttpUrlRequestListener mListener;
55    private boolean mBufferFullResponse;
56    private long mOffset;
57    private long mContentLength;
58    private long mContentLengthLimit;
59    private boolean mCancelIfContentLengthOverLimit;
60    private boolean mContentLengthOverLimit;
61    private boolean mSkippingToOffset;
62    private long mSize;
63    private final Object mLock = new Object();
64
65    public ChromiumUrlRequest(ChromiumUrlRequestContext requestContext,
66            String url, int priority, Map<String, String> headers,
67            HttpUrlRequestListener listener) {
68        this(requestContext, url, priority, headers,
69                new ChunkedWritableByteChannel(), listener);
70        mBufferFullResponse = true;
71    }
72
73    /**
74     * Constructor.
75     *
76     * @param requestContext The context.
77     * @param url The URL.
78     * @param priority Request priority, e.g. {@link #REQUEST_PRIORITY_MEDIUM}.
79     * @param headers HTTP headers.
80     * @param sink The output channel into which downloaded content will be
81     *            written.
82     */
83    public ChromiumUrlRequest(ChromiumUrlRequestContext requestContext,
84            String url, int priority, Map<String, String> headers,
85            WritableByteChannel sink, HttpUrlRequestListener listener) {
86        if (requestContext == null) {
87            throw new NullPointerException("Context is required");
88        }
89        if (url == null) {
90            throw new NullPointerException("URL is required");
91        }
92        mRequestContext = requestContext;
93        mUrl = url;
94        mPriority = convertRequestPriority(priority);
95        mHeaders = headers;
96        mSink = sink;
97        mUrlRequestAdapter = nativeCreateRequestAdapter(
98                mRequestContext.getChromiumUrlRequestContextAdapter(),
99                mUrl,
100                mPriority);
101        mListener = listener;
102    }
103
104    @Override
105    public void setOffset(long offset) {
106        mOffset = offset;
107        if (offset != 0) {
108            addHeader("Range", "bytes=" + offset + "-");
109        }
110    }
111
112    /**
113     * The compressed content length as reported by the server.  May be -1 if
114     * the server did not provide a length.  Some servers may also report the
115     * wrong number.  Since this is the compressed content length, and only
116     * uncompressed content is returned by the consumer, the consumer should
117     * not rely on this value.
118     */
119    @Override
120    public long getContentLength() {
121        return mContentLength;
122    }
123
124    @Override
125    public void setContentLengthLimit(long limit, boolean cancelEarly) {
126        mContentLengthLimit = limit;
127        mCancelIfContentLengthOverLimit = cancelEarly;
128    }
129
130    @Override
131    public int getHttpStatusCode() {
132        int httpStatusCode = nativeGetHttpStatusCode(mUrlRequestAdapter);
133
134        // TODO(mef): Investigate the following:
135        // If we have been able to successfully resume a previously interrupted
136        // download, the status code will be 206, not 200. Since the rest of the
137        // application is expecting 200 to indicate success, we need to fake it.
138        if (httpStatusCode == 206) {
139            httpStatusCode = 200;
140        }
141        return httpStatusCode;
142    }
143
144    /**
145     * Returns an exception if any, or null if the request was completed
146     * successfully.
147     */
148    @Override
149    public IOException getException() {
150        if (mSinkException != null) {
151            return mSinkException;
152        }
153
154        validateNotRecycled();
155
156        int errorCode = nativeGetErrorCode(mUrlRequestAdapter);
157        switch (errorCode) {
158            case ChromiumUrlRequestError.SUCCESS:
159                if (mContentLengthOverLimit) {
160                    return new ResponseTooLargeException();
161                }
162                return null;
163            case ChromiumUrlRequestError.UNKNOWN:
164                return new IOException(
165                        nativeGetErrorString(mUrlRequestAdapter));
166            case ChromiumUrlRequestError.MALFORMED_URL:
167                return new MalformedURLException("Malformed URL: " + mUrl);
168            case ChromiumUrlRequestError.CONNECTION_TIMED_OUT:
169                return new ConnectTimeoutException("Connection timed out");
170            case ChromiumUrlRequestError.UNKNOWN_HOST:
171                String host;
172                try {
173                    host = new URL(mUrl).getHost();
174                } catch (MalformedURLException e) {
175                    host = mUrl;
176                }
177                return new UnknownHostException("Unknown host: " + host);
178            default:
179                throw new IllegalStateException(
180                        "Unrecognized error code: " + errorCode);
181        }
182    }
183
184    @Override
185    public ByteBuffer getByteBuffer() {
186        return ((ChunkedWritableByteChannel) getSink()).getByteBuffer();
187    }
188
189    @Override
190    public byte[] getResponseAsBytes() {
191        return ((ChunkedWritableByteChannel) getSink()).getBytes();
192    }
193
194    /**
195     * Adds a request header. Must be done before request has started.
196     */
197    public void addHeader(String header, String value) {
198        synchronized (mLock) {
199            validateNotStarted();
200            if (mAdditionalHeaders == null) {
201                mAdditionalHeaders = new HashMap<String, String>();
202            }
203            mAdditionalHeaders.put(header, value);
204        }
205    }
206
207    /**
208     * Sets data to upload as part of a POST or PUT request.
209     *
210     * @param contentType MIME type of the upload content or null if this is not
211     *            an upload.
212     * @param data The content that needs to be uploaded.
213     */
214    @Override
215    public void setUploadData(String contentType, byte[] data) {
216        synchronized (mLock) {
217            validateNotStarted();
218            validateContentType(contentType);
219            mUploadContentType = contentType;
220            mUploadData = data;
221            mUploadChannel = null;
222            mChunkedUpload = false;
223        }
224    }
225
226    /**
227     * Sets a readable byte channel to upload as part of a POST or PUT request.
228     *
229     * @param contentType MIME type of the upload content or null if this is not
230     *            an upload request.
231     * @param channel The channel to read to read upload data from if this is an
232     *            upload request.
233     * @param contentLength The length of data to upload.
234     */
235    @Override
236    public void setUploadChannel(String contentType,
237            ReadableByteChannel channel, long contentLength) {
238        synchronized (mLock) {
239            validateNotStarted();
240            validateContentType(contentType);
241            mUploadContentType = contentType;
242            mUploadChannel = channel;
243            mUploadContentLength = contentLength;
244            mUploadData = null;
245            mChunkedUpload = false;
246        }
247    }
248
249    /**
250     * Sets this request up for chunked uploading. To upload data call
251     * {@link #appendChunk(ByteBuffer, boolean)} after {@link #start()}.
252     *
253     * @param contentType MIME type of the post content or null if this is not a
254     *            POST request.
255     */
256    public void setChunkedUpload(String contentType) {
257        synchronized (mLock) {
258            validateNotStarted();
259            validateContentType(contentType);
260            mUploadContentType = contentType;
261            mChunkedUpload = true;
262            mUploadData = null;
263            mUploadChannel = null;
264        }
265    }
266
267    /**
268     * Uploads a new chunk. Must have called {@link #setChunkedUpload(String)}
269     * and {@link #start()}.
270     *
271     * @param chunk The data, which will not be modified. It must not be empty
272     *            and its current position must be zero.
273     * @param isLastChunk Whether this chunk is the last one.
274     */
275    public void appendChunk(ByteBuffer chunk, boolean isLastChunk)
276            throws IOException {
277        if (!chunk.hasRemaining()) {
278            throw new IllegalArgumentException(
279                    "Attempted to write empty buffer.");
280        }
281        if (chunk.position() != 0) {
282            throw new IllegalArgumentException("The position must be zero.");
283        }
284        synchronized (mLock) {
285            if (!mStarted) {
286                throw new IllegalStateException("Request not yet started.");
287            }
288            if (!mChunkedUpload) {
289                throw new IllegalStateException(
290                        "Request not set for chunked uploadind.");
291            }
292            if (mUrlRequestAdapter == 0) {
293                throw new IOException("Native peer destroyed.");
294            }
295            nativeAppendChunk(mUrlRequestAdapter, chunk, chunk.limit(),
296                    isLastChunk);
297        }
298    }
299
300    @Override
301    public void setHttpMethod(String method) {
302        validateNotStarted();
303        mMethod = method;
304    }
305
306    public WritableByteChannel getSink() {
307        return mSink;
308    }
309
310    @Override
311    public void start() {
312        synchronized (mLock) {
313            if (mCanceled) {
314                return;
315            }
316
317            validateNotStarted();
318            validateNotRecycled();
319
320            mStarted = true;
321
322            String method = mMethod;
323            if (method == null &&
324                    ((mUploadData != null && mUploadData.length > 0) ||
325                      mUploadChannel != null || mChunkedUpload)) {
326                // Default to POST if there is data to upload but no method was
327                // specified.
328                method = "POST";
329            }
330
331            if (method != null) {
332                nativeSetMethod(mUrlRequestAdapter, method);
333            }
334
335            if (mHeaders != null && !mHeaders.isEmpty()) {
336                for (Entry<String, String> entry : mHeaders.entrySet()) {
337                    nativeAddHeader(mUrlRequestAdapter, entry.getKey(),
338                            entry.getValue());
339                }
340            }
341
342            if (mAdditionalHeaders != null) {
343                for (Entry<String, String> entry :
344                        mAdditionalHeaders.entrySet()) {
345                    nativeAddHeader(mUrlRequestAdapter, entry.getKey(),
346                            entry.getValue());
347                }
348            }
349
350            if (mUploadData != null && mUploadData.length > 0) {
351                nativeSetUploadData(mUrlRequestAdapter, mUploadContentType,
352                                    mUploadData);
353            } else if (mUploadChannel != null) {
354                nativeSetUploadChannel(mUrlRequestAdapter, mUploadContentType,
355                                       mUploadContentLength);
356            } else if (mChunkedUpload) {
357                nativeEnableChunkedUpload(mUrlRequestAdapter,
358                                          mUploadContentType);
359            }
360
361            nativeStart(mUrlRequestAdapter);
362          }
363    }
364
365    @Override
366    public void cancel() {
367        synchronized (mLock) {
368            if (mCanceled) {
369                return;
370            }
371
372            mCanceled = true;
373
374            if (!mRecycled) {
375                nativeCancel(mUrlRequestAdapter);
376            }
377        }
378    }
379
380    @Override
381    public boolean isCanceled() {
382        synchronized (mLock) {
383            return mCanceled;
384        }
385    }
386
387    public boolean isRecycled() {
388        synchronized (mLock) {
389            return mRecycled;
390        }
391    }
392
393    @Override
394    public String getNegotiatedProtocol() {
395        validateNotRecycled();
396        validateHeadersAvailable();
397        return nativeGetNegotiatedProtocol(mUrlRequestAdapter);
398    }
399
400    @Override
401    public String getContentType() {
402        return mContentType;
403    }
404
405    @Override
406    public String getHeader(String name) {
407        validateNotRecycled();
408        validateHeadersAvailable();
409        return nativeGetHeader(mUrlRequestAdapter, name);
410    }
411
412    // All response headers.
413    @Override
414    public Map<String, List<String>> getAllHeaders() {
415        validateNotRecycled();
416        validateHeadersAvailable();
417        ResponseHeadersMap result = new ResponseHeadersMap();
418        nativeGetAllHeaders(mUrlRequestAdapter, result);
419        return result;
420    }
421
422    @Override
423    public String getUrl() {
424        return mUrl;
425    }
426
427    private static int convertRequestPriority(int priority) {
428        switch (priority) {
429            case HttpUrlRequest.REQUEST_PRIORITY_IDLE:
430                return ChromiumUrlRequestPriority.IDLE;
431            case HttpUrlRequest.REQUEST_PRIORITY_LOWEST:
432                return ChromiumUrlRequestPriority.LOWEST;
433            case HttpUrlRequest.REQUEST_PRIORITY_LOW:
434                return ChromiumUrlRequestPriority.LOW;
435            case HttpUrlRequest.REQUEST_PRIORITY_MEDIUM:
436                return ChromiumUrlRequestPriority.MEDIUM;
437            case HttpUrlRequest.REQUEST_PRIORITY_HIGHEST:
438                return ChromiumUrlRequestPriority.HIGHEST;
439            default:
440                return ChromiumUrlRequestPriority.MEDIUM;
441        }
442    }
443
444    private void onContentLengthOverLimit() {
445        mContentLengthOverLimit = true;
446        cancel();
447    }
448
449    /**
450     * A callback invoked when the response has been fully consumed.
451     */
452    private void onRequestComplete() {
453        mListener.onRequestComplete(this);
454    }
455
456    private void validateNotRecycled() {
457        if (mRecycled) {
458            throw new IllegalStateException("Accessing recycled request");
459        }
460    }
461
462    private void validateNotStarted() {
463        if (mStarted) {
464            throw new IllegalStateException("Request already started");
465        }
466    }
467
468    private void validateHeadersAvailable() {
469        if (!mHeadersAvailable) {
470            throw new IllegalStateException("Response headers not available");
471        }
472    }
473
474    private void validateContentType(String contentType) {
475        if (contentType == null) {
476            throw new NullPointerException("contentType is required");
477        }
478    }
479
480    // Private methods called by native library.
481
482    /**
483     * If @CalledByNative method throws an exception, request gets cancelled
484     * and exception could be retrieved from request using getException().
485     */
486    private void onCalledByNativeException(Exception e) {
487        mSinkException = new IOException(
488                "CalledByNative method has thrown an exception", e);
489        Log.e(ChromiumUrlRequestContext.LOG_TAG,
490                "Exception in CalledByNative method", e);
491        try {
492            cancel();
493        } catch (Exception cancel_exception) {
494            Log.e(ChromiumUrlRequestContext.LOG_TAG,
495                    "Exception trying to cancel request", cancel_exception);
496        }
497    }
498
499    /**
500     * A callback invoked when the first chunk of the response has arrived.
501     */
502    @CalledByNative
503    private void onResponseStarted() {
504        try {
505            mContentType = nativeGetContentType(mUrlRequestAdapter);
506            mContentLength = nativeGetContentLength(mUrlRequestAdapter);
507            mHeadersAvailable = true;
508
509            if (mContentLengthLimit > 0 &&
510                    mContentLength > mContentLengthLimit &&
511                    mCancelIfContentLengthOverLimit) {
512                onContentLengthOverLimit();
513                    return;
514            }
515
516            if (mBufferFullResponse && mContentLength != -1 &&
517                    !mContentLengthOverLimit) {
518                ((ChunkedWritableByteChannel) getSink()).setCapacity(
519                        (int) mContentLength);
520            }
521
522            if (mOffset != 0) {
523                // The server may ignore the request for a byte range, in which
524                // case status code will be 200, instead of 206. Note that we
525                // cannot call getHttpStatusCode as it rewrites 206 into 200.
526                if (nativeGetHttpStatusCode(mUrlRequestAdapter) == 200) {
527                    // TODO(mef): Revisit this logic.
528                    if (mContentLength != -1) {
529                        mContentLength -= mOffset;
530                    }
531                    mSkippingToOffset = true;
532                } else {
533                    mSize = mOffset;
534                }
535            }
536            mListener.onResponseStarted(this);
537        } catch (Exception e) {
538            onCalledByNativeException(e);
539        }
540    }
541
542    /**
543     * Consumes a portion of the response.
544     *
545     * @param byteBuffer The ByteBuffer to append. Must be a direct buffer, and
546     *            no references to it may be retained after the method ends, as
547     *            it wraps code managed on the native heap.
548     */
549    @CalledByNative
550    private void onBytesRead(ByteBuffer buffer) {
551        try {
552            if (mContentLengthOverLimit) {
553                return;
554            }
555
556            int size = buffer.remaining();
557            mSize += size;
558            if (mSkippingToOffset) {
559                if (mSize <= mOffset) {
560                    return;
561                } else {
562                    mSkippingToOffset = false;
563                    buffer.position((int) (mOffset - (mSize - size)));
564                }
565            }
566
567            boolean contentLengthOverLimit =
568                    (mContentLengthLimit != 0 && mSize > mContentLengthLimit);
569            if (contentLengthOverLimit) {
570                buffer.limit(size - (int) (mSize - mContentLengthLimit));
571            }
572
573            while (buffer.hasRemaining()) {
574                mSink.write(buffer);
575            }
576            if (contentLengthOverLimit) {
577                onContentLengthOverLimit();
578            }
579        } catch (Exception e) {
580            onCalledByNativeException(e);
581        }
582    }
583
584    /**
585     * Notifies the listener, releases native data structures.
586     */
587    @SuppressWarnings("unused")
588    @CalledByNative
589    private void finish() {
590        try {
591            synchronized (mLock) {
592                mFinished = true;
593
594                if (mRecycled) {
595                    return;
596                }
597                try {
598                    mSink.close();
599                } catch (IOException e) {
600                    // Ignore
601                }
602                try {
603                    if (mUploadChannel != null && mUploadChannel.isOpen()) {
604                        mUploadChannel.close();
605                    }
606                } catch (IOException e) {
607                    // Ignore
608                }
609                onRequestComplete();
610                nativeDestroyRequestAdapter(mUrlRequestAdapter);
611                mUrlRequestAdapter = 0;
612                mRecycled = true;
613            }
614        } catch (Exception e) {
615            mSinkException = new IOException("Exception in finish", e);
616        }
617    }
618
619    /**
620     * Appends header |name| with value |value| to |headersMap|.
621     */
622    @SuppressWarnings("unused")
623    @CalledByNative
624    private void onAppendResponseHeader(ResponseHeadersMap headersMap,
625            String name, String value) {
626        try {
627            if (!headersMap.containsKey(name)) {
628                headersMap.put(name, new ArrayList<String>());
629            }
630            headersMap.get(name).add(value);
631        } catch (Exception e) {
632            onCalledByNativeException(e);
633        }
634    }
635
636    /**
637     * Reads a sequence of bytes from upload channel into the given buffer.
638     * @param dest The buffer into which bytes are to be transferred.
639     * @return Returns number of bytes read (could be 0) or -1 and closes
640     * the channel if error occured.
641     */
642    @SuppressWarnings("unused")
643    @CalledByNative
644    private int readFromUploadChannel(ByteBuffer dest) {
645        try {
646            if (mUploadChannel == null || !mUploadChannel.isOpen())
647                return -1;
648            int result = mUploadChannel.read(dest);
649            if (result < 0) {
650                mUploadChannel.close();
651                return 0;
652            }
653            return result;
654        } catch (Exception e) {
655            onCalledByNativeException(e);
656        }
657        return -1;
658    }
659
660    // Native methods are implemented in chromium_url_request.cc.
661
662    private native long nativeCreateRequestAdapter(
663            long urlRequestContextAdapter, String url, int priority);
664
665    private native void nativeAddHeader(long urlRequestAdapter, String name,
666            String value);
667
668    private native void nativeSetMethod(long urlRequestAdapter, String method);
669
670    private native void nativeSetUploadData(long urlRequestAdapter,
671            String contentType, byte[] content);
672
673    private native void nativeSetUploadChannel(long urlRequestAdapter,
674            String contentType, long contentLength);
675
676    private native void nativeEnableChunkedUpload(long urlRequestAdapter,
677            String contentType);
678
679    private native void nativeAppendChunk(long urlRequestAdapter,
680            ByteBuffer chunk, int chunkSize, boolean isLastChunk);
681
682    private native void nativeStart(long urlRequestAdapter);
683
684    private native void nativeCancel(long urlRequestAdapter);
685
686    private native void nativeDestroyRequestAdapter(long urlRequestAdapter);
687
688    private native int nativeGetErrorCode(long urlRequestAdapter);
689
690    private native int nativeGetHttpStatusCode(long urlRequestAdapter);
691
692    private native String nativeGetErrorString(long urlRequestAdapter);
693
694    private native String nativeGetContentType(long urlRequestAdapter);
695
696    private native long nativeGetContentLength(long urlRequestAdapter);
697
698    private native String nativeGetHeader(long urlRequestAdapter, String name);
699
700    private native void nativeGetAllHeaders(long urlRequestAdapter,
701            ResponseHeadersMap headers);
702
703    private native String nativeGetNegotiatedProtocol(long urlRequestAdapter);
704
705    // Explicit class to work around JNI-generator generics confusion.
706    private class ResponseHeadersMap extends HashMap<String, List<String>> {
707    }
708}
709