How to get WebViewClient.shouldInterceptRequest invoked asynchronously

Luke picture Luke · Oct 27, 2015 · Viewed 9.9k times · Source

I want to create an Intranet-Application. This app is going to show content, normally only reachable in our internal environment. e.g. http://intranet.ourfirm.com

Now we are having the possibility to access this content from external e.g. https://ourproxy.com/ourIntranetApplicationID/ (this will be directed to http://intranet.ourfirm.com)

I change every original url like http://intranet.ourfirm.com/whatever/index.html to https://ourproxy.com/ourIntranetApplicationID/whatever/index.html.

In the index.htm several resources are defined either in an absolute or relative way. I make them all absolute and convert them to our proxy url(see *1 ) (reachable from everywhere outside our firm)

This all is working perfectly, but with one big issue. It is slow like hell! the process of conversion is initiated in my MyWebViewClient.shouldInterceptRequest method.

my html has 80 Resources to be loaded and shouldInterceptRequest is called sequentially for each resource:

public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
        LOGGER.debug("ENTER shouldInterceptRequest: " + String.format("%012d", interceptCounter.incrementAndGet()));
        WebResourceResponse response;


    HttpURLConnection conn;

        try {
            conn = myRewritingHelper.getConnection(request.getUrl(), method); // *1 this internally converts the url and gets a connection adds the header for Basic Auth etc.

            // add request headers
            for (Map.Entry<String, String> entry : request.getRequestHeaders().entrySet()) {
                conn.setRequestProperty(entry.getKey(), entry.getValue());
            }

            // Read input
            String charset = conn.getContentEncoding() != null ? conn.getContentEncoding() : Charset.defaultCharset().displayName();
            String mime = conn.getContentType();
            InputStream is = conn.getInputStream();



            long interceptStopTimestamp = System.currentTimeMillis();
            long durationIntercepting = interceptStopTimestamp - interceptStartTimestamp;
            LOGGER.info("InterceptionDuration : " + durationIntercepting);

            // *2 we have to define null for the mime-type , so the WebResourceResponse gets the type directly from the stream
            response = new WebResourceResponse(null, charset, isContents);
        } catch (IllegalStateException e) {
            LOGGER.warn("IllegalStateException", e);

        } catch (IOException e) {
            LOGGER.warn("IOException: Could not load resource: " + url, e);
        }


        LOGGER.debug("LEAVE shouldInterceptRequest: " + String.format("%012d", interceptCounter.get()));
        return response;
    }

As you can see, I use an AtomicInteger incrementing and logging at beginning of the interception method, and log the value at the end of the method.

It always logs:

ENTER shouldInterceptRequest:  000000000001
LEAVE shouldInterceptRequest:  000000000001
ENTER shouldInterceptRequest:  000000000002
LEAVE shouldInterceptRequest:  000000000002
ENTER shouldInterceptRequest:  000000000003
LEAVE shouldInterceptRequest:  000000000003
ENTER shouldInterceptRequest:  000000000004
LEAVE shouldInterceptRequest:  000000000004
:
:
ENTER shouldInterceptRequest:  000000000080
LEAVE shouldInterceptRequest:  000000000080

With this I was able to check that the shouldInterceptRequest() method never get startet asynchonously. If the method would get called asynchronously a bigger number @ ENTER- Comment would appear before a LEAVE of the prior number would occure. This unfortunately never happened.

The call to myRewritingHelper.getConnection() is non-locking.

Now my Question: Is there a possibility to provoke the WebviewClient to call its shouldInterceptRequest() method asynchonously? I'm quite sure this would massively improove the performance, if several resources of the Web view could be loaded asynchonously! The Web view loads resource after resource sequentially.

An interesting sub-question is, why i have to define the mime-type in the Creation of the Web Resource to 0 (see *2). A call like... response = new WebResourceResponse(mime, charset, isContents); ... doesn't work.

Thanks for any helpful answers

Edited:

The method of myRewritingHelper.getConnection(..) is fast, it simply opens the connection with appended http headers:

private HttpURLConnection getConnection(String url, String httpMethod) throws MalformedURLException, IOException {

        String absoluteRewrittenUrl = urlConfigurationManager.getRewritedUrl(url); // this gets a rewritten url

        final HttpURLConnection connection = (HttpURLConnection) new URL(absoluteRewrittenUrl).openConnection();
        connection.setRequestMethod(httpMethod);
        connection.setConnectTimeout(CONNECTION_TIMEOUT_MS);
        connection.setReadTimeout(SOCKET_TIMEOUT_MS);
        connection.setRequestProperty("AUTHORIZATION",getBasicAuthentication());

        return connection;
    }

The getConnection(..) method only consumes a couple of milliseconds.

The great "bottleneck" in the shouldInterceptRequest method are the 3 calls after the comment // Read input

String charset = conn.getContentEncoding() != null
conn.getContentEncoding():Charset.defaultCharset().displayName();
String mime = conn.getContentType();
InputStream is = conn.getInputStream();

Those 3 calls consume up to 2Seconds each time. So the shouldInterceptRequestMethod() consumes more than 2 seconds each call.(That was the reason I asked to invoke this method asynchronously)

Mikhail Naganov suggested to do a pre-fetch. Can anybody show an example of how to prefetching and give the data properly to the WebResourceResponse?

if I create the WebResourceResponse with the real mime -type instead of null (see *2) then the content cant be loaded. An html/text will be displayed as text in the WebView.

Edited 2: The suggested solution from Mikhail seemed to be the right one. But unfortunately it is not:

public class MyWebResourceResponse extends WebResourceResponse {
    private String url;
    private Context context;
    private MyResourceDownloader myResourceDownloader;
    private String method;
    private Map<String, String> requestHeaders;
    private MyWebViewListener myWebViewListener;
    private String predefinedEncoding;

public MyWebResourceResponse(Context context, String url, MyResourceDownloader myResourceDownloader, String method, Map<String, String> requestHeaders, MyWebViewListener myWebViewListener,String predefinedEncoding) {
        super("", "", null);
        this.url = url;
        this.context = context;
        this.myResourceDownloader = myResourceDownloader;
        this.method = method;
        this.requestHeaders = requestHeaders;
        this.myWebViewListener = myWebViewListener;
        this.predefinedEncoding = predefinedEncoding;
    }

    @Override
    public InputStream getData() {
        return new MyWebResourceInputStream(context, url, myResourceDownloader, method, requestHeaders, myWebViewListener);
    }

    @Override
    public String getEncoding() {
        if(predefinedEncoding!=null){
            return predefinedEncoding;
        }
        return super.getEncoding();
    }

    @Override
    public String getMimeType() {
        return super.getMimeType();
    }
}

The MyWebResourceInputStream is like this:

public class MyWebResourceInputStream extends InputStream {
    private static final Logger LOGGER = LoggerFactory.getLogger(MyWebResourceInputStream.class);
    public static final int NO_MORE_DATA = -1;
    private String url;
    private boolean initialized;
    private InputStream inputStream;
    private MyResourceDownloader myResourceDownloader;
    private String method;
    private Map<String, String> requestHeaders;
    private Context context;
    private MyWebViewListener myWebViewListener;

public MyWebResourceInputStream(Context context, String url, MyResourceDownloader myResourceDownloader,
            String method, Map<String, String> requestHeaders, MyWebViewListener myWebViewListener) {
        this.url = url;
        this.initialized = false;
        this.myResourceDownloader = myResourceDownloader;
        this.method = method;
        this.requestHeaders = requestHeaders;
        this.context = context;
        this.myWebViewListener = myWebViewListener;
    }
@Override
    public int read() throws IOException {
        if (!initialized && !MyWebViewClient.getReceived401()) {
            LOGGER.debug("- -> read ENTER *****");
            try {
                InterceptingHelper.InterceptingHelperResult result = InterceptingHelper.getStream(context, myResourceDownloader, url, method, requestHeaders, false);
                inputStream = result.getInputstream();
                initialized = true;
            } catch (final UnexpectedStatusCodeException e) {
                LOGGER.warn("UnexpectedStatusCodeException", e);
                if (e.getStatusCode() == HttpURLConnection.HTTP_UNAUTHORIZED) {
                    MyWebViewClient.setReceived401(true);
                    if (myWebViewListener != null) {
                        myWebViewListener.onReceivedUnexpectedStatusCode(e.getStatusCode());
                    }
                    LOGGER.warn("UnexpectedStatusCodeException received 401", e);
                }
            } catch (IllegalStateException e) {
                LOGGER.warn("IllegalStateException", e);
            }
        }
        if (inputStream != null && !MyWebViewClient.getReceived401()) {
            return inputStream.read();
        } else {
            return NO_MORE_DATA;
        }

    }
@Override
    public void close() throws IOException {
        if (inputStream != null) {
            inputStream.close();
        }
    }
@Override
    public long skip(long byteCount) throws IOException {
        long skipped = 0;
        if (inputStream != null) {
            skipped = inputStream.skip(byteCount);
        }
        return skipped;
    }
@Override
    public synchronized void reset() throws IOException {
        if (inputStream != null) {
            inputStream.reset();
        }
    }
@Override
    public int read(byte[] buffer) throws IOException {
        if (inputStream != null) {
            return inputStream.read(buffer);
        }
        return super.read(buffer);
    }
@Override
    public int read(byte[] buffer, int byteOffset, int byteCount) throws IOException {
        if (inputStream != null) {
            return inputStream.read(buffer, byteOffset, byteCount);
        }
        return super.read(buffer, byteOffset, byteCount);
    }
 public int available() throws IOException {
        if (inputStream != null) {
            return inputStream.available();
        }
        return super.available();
    }

public synchronized void mark(int readlimit) {
        if (inputStream != null) {
            inputStream.mark(readlimit);
        }
        super.mark(readlimit);
    }
 @Override
    public boolean markSupported() {
        if (inputStream != null) {
            return inputStream.markSupported();
        }
        return super.markSupported();
    }

the call is initiated in

MyWebViewClient extends WebViewClient{

    public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request){
    // a lot of other code
    String predefinedEncoding = getPredefinedEncodingFromUrl(url);
            return new MyWebResourceResponse(context, url, myResourceDownloader, method, requestHeaders, webViewListener, predefinedEncoding);
  }
}

it brought a performance boost, but it has the huge drawback that the encoding is not defined during creation of the MyWebResourceResponse class. Because the connection gets established not until MyWebResourceInputStream.read() is called. I have discovered that the webkit calls getEncoding() prior to getData(), when the connection is not established, so it all the time getEncoding will be null. I started to define a Workaround with a predefined encoding (depending on the url ). But that's far away from a generic solution!And does not work in each case Does anybody known an alternative solution? Sorry Mikhail for taking away the accepted answer.

Answer

Mikhail Naganov picture Mikhail Naganov · Oct 27, 2015

The resource loading process consists of two phases: creating request jobs, and then running them for getting the data. shouldInterceptRequest is called during the first phase, and these calls indeed run on a single thread, in sequence. But as the WebView's resource loader receives the request jobs, it then starts to load the resource contents from the provided streams in parallel.

Creating request jobs should be fast, and it shouldn't be a bottleneck. Did you actually measure how long does it take for your shouldInterceptRequest to complete?

The next step would be to check that the input streams are actually not blocking each other. Also, does RewritingHelper pre-fetch the contents, or does it only loads them on demand when the stream is being read? Pre-fetching can help increasing loading speed.

As for the mime type -- usually browsers get it from the response headers, and that's why it is needed to provide it via WebResourceResponse constructor. I'm actually not sure what do you mean by "WebResourceResponse gets the type directly from the stream" in your comment -- the stream only contains the data of the reply, but not the response headers.

UPDATE

So, from your updated question it seems that HttpURLConnection actually does loading of the resource inside shouldInterceptRequest, which is why everything is so slow. What you need to do instead is to define your own class that wraps WebResourceResponse and does nothing on the construction, so shouldInterceptRequest executes fast. The actual loading should start afterwards.

I couldn't find a lot of good code examples for this technique, but this one seems to be doing more or less what you need: https://github.com/mobilyzer/Mobilyzer/blob/master/Mobilyzer/src/com/mobilyzer/util/AndroidWebView.java#L252

By pre-fetching I mean that you can start loading your data almost immediately after you have returned from shouldInterceptRequest, not waiting until WebView calls getData method on the returned WebResourceResponse. That way, you will already have the data loaded by the time WebView asks you.

UPDATE 2

It's actually a problem in WebView that it queries the response headers immediately after receiving the instance of WebResourceResponse from shouldInterceptRequest. It means that if the app wants to load resources from the network itself (e.g. for modifying them), loading will never be as fast as when WebView loads those resources itself.

The best approach the app can do is something like this (the code lacks proper exception and error handling, otherwise it will be 3 times bigger):

public WebResourceResponse shouldInterceptRequest(WebView view, final WebResourceRequest request) {
    final CountDownLatch haveHeaders = new CountDownLatch(1);
    final AtomicReference<Map<String, String>> headersRef = new AtomicReference<>();
    final CountDownLatch haveData = new CountDownLatch(1);
    final AtomicReference<InputStream> inputStreamRef = new AtomicReference<>();

    new Thread() {
        @Override
        public void run() {
            HttpURLConnection urlConnection =
                (HttpURLConnection) new URL(request.getUrl().toString()).openConnection();
            Map<String, List<String>> rawHeaders = urlConnection.getHeaderFields();
            // Copy headers from rawHeaders to headersRef
            haveHeaders.countDown();

            inputStreamRef.set(new BufferedInputStream(urlConnection.getInputStream()));
            haveData.countDown();
        }
    }.start();

    return new WebResourceResponse(
            null,
            "UTF-8",
            new InputStream() {
               @Override
               public int read() throws IOException {
                   haveInputStream.await(100, TimeUnit.SECONDS));
                   return inputStreamRef.get().read();
            }) {

        @Override
        public Map<String, String> getResponseHeaders() {
            haveHeaders.await(100, TimeUnit.SECONDS))
            return headersRef.get();
        }
    }
);