WebViewAssetLoader.java

/*
 * Copyright 2019 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.webkit;

import android.content.Context;
import android.net.Uri;
import android.util.Log;
import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.annotation.VisibleForTesting;
import androidx.webkit.internal.AssetHelper;

import java.io.InputStream;
import java.net.URLConnection;

/**
 * Helper class to enable accessing the application's static assets and resources under an
 * http(s):// URL to be loaded by {@link android.webkit.WebView} class.
 * Hosting assets and resources this way is desirable as it is compatible with the Same-Origin
 * policy.
 *
 * <p>
 * For more context about application's assets and resources and how to normally access them please
 * refer to <a href="https://developer.android.com/guide/topics/resources/providing-resources">
 * Android Developer Docs: App resources overview</a>.
 *
 * <p class='note'>
 * This class is expected to be used within
 * {@link android.webkit.WebViewClient#shouldInterceptRequest}, which is not invoked on the
 * application's main thread. Although instances are themselves thread-safe (and may be safely
 * constructed on the application's main thread), exercise caution when accessing private data or
 * the view system.
 *
 * <p>
 * Using http(s):// URLs to access local resources may conflict with a real website. This means
 * that local resources should only be hosted on domains your organization owns (at paths reserved
 * for this purpose) or the default domain Google has reserved for this:
 * {@code appassets.androidplatform.net}.
 *
 * <p>
 * A typical usage would be like:
 * <pre class="prettyprint">
 *     WebViewAssetLoader.Builder assetLoaderBuilder = new WebViewAssetLoader.Builder(this);
 *     final WebViewAssetLoader assetLoader = assetLoaderBuilder.build();
 *     webView.setWebViewClient(new WebViewClient() {
 *         {@literal @}Override
 *         public WebResourceResponse shouldInterceptRequest(WebView view,
 *                                          WebResourceRequest request) {
 *             return assetLoader.shouldInterceptRequest(request);
 *         }
 *     });
 *     // Assets are hosted under http(s)://appassets.androidplatform.net/assets/... by default.
 *     // If the application's assets are in the "main/assets" folder this will read the file
 *     // from "main/assets/www/index.html" and load it as if it were hosted on:
 *     // https://appassets.androidplatform.net/assets/www/index.html
 *     webview.loadUrl(assetLoader.getAssetsHttpsPrefix().buildUpon()
 *                                      .appendPath("www")
 *                                      .appendPath("index.html")
 *                                      .build().toString());
 *
 * </pre>
 */
public class WebViewAssetLoader {
    private static final String TAG = "WebViewAssetLoader";

    /**
     * An unused domain reserved by Google for Android applications to intercept requests
     * for app assets.
     * <p>
     * It'll be used by default unless the user specified a different domain.
     */
    public static final String KNOWN_UNUSED_AUTHORITY = "appassets.androidplatform.net";

    private static final String HTTP_SCHEME = "http";
    private static final String HTTPS_SCHEME = "https";

    @NonNull private final PathHandler mAssetsHandler;
    @NonNull private final PathHandler mResourcesHandler;

    /**
     * A handler that produces responses for the registered paths.
     *
     * Matches URIs on the form: {@code "http(s)://authority/path/**"}, HTTPS is always enabled.
     *
     * <p>
     * Methods of this handler will be invoked on a background thread and care must be taken to
     * correctly synchronize access to any shared state.
     * <p>
     * On Android KitKat and above these methods may be called on more than one thread. This thread
     * may be different than the thread on which the shouldInterceptRequest method was invoked.
     * This means that on Android KitKat and above it is possible to block in this method without
     * blocking other resources from loading. The number of threads used to parallelize loading
     * is an internal implementation detail of the WebView and may change between updates which
     * means that the amount of time spent blocking in this method should be kept to an absolute
     * minimum.
     */
    @VisibleForTesting
    /*package*/ abstract static class PathHandler {
        final boolean mHttpEnabled;
        @NonNull final String mAuthority;
        @NonNull final String mPath;

        /**
         * @param authority the authority to match (For instance {@code "example.com"})
         * @param path the prefix path to match, it should start and end with a {@code "/"}.
         * @param httpEnabled enable hosting under the HTTP scheme, HTTPS is always enabled.
         */
        PathHandler(@NonNull final String authority, @NonNull final String path,
                            boolean httpEnabled) {
            if (path.isEmpty() || path.charAt(0) != '/') {
                throw new IllegalArgumentException("Path should start with a slash '/'.");
            }
            if (!path.endsWith("/")) {
                throw new IllegalArgumentException("Path should end with a slash '/'");
            }
            this.mAuthority = authority;
            this.mPath = path;
            this.mHttpEnabled = httpEnabled;
        }

        /**
         * Open an {@link InputStream} for the requested URL.
         * <p>
         * This method should be called if {@code match(Uri)} returns true in order to
         * open the file requested by this URL.
         *
         * @param url path that has been matched.
         * @return {@link InputStream} for the requested URL, {@code null} if an error happens
         *         while opening the file or file doesn't exist.
         */
        @Nullable
        public abstract InputStream handle(@NonNull Uri url);

        /**
         * Match against registered scheme, authority and path prefix.
         *
         * Match happens when:
         * <ul>
         *      <li>Scheme is "https" <b>or</b> the scheme is "http" and http is enabled.</li>
         *      <li>Authority exact matches the given URI's authority.</li>
         *      <li>Path is a prefix of the given URI's path.</li>
         * </ul>
         *
         * @param uri the URI whose path we will match against.
         *
         * @return {@code true} if a match happens, {@code false} otherwise.
         */
        public boolean match(@NonNull Uri uri) {
            // Only match HTTP_SCHEME if caller enabled HTTP matches.
            if (uri.getScheme().equals(HTTP_SCHEME) && !mHttpEnabled) {
                return false;
            }
            // Don't match non-HTTP(S) schemes.
            if (!uri.getScheme().equals(HTTP_SCHEME) && !uri.getScheme().equals(HTTPS_SCHEME)) {
                return false;
            }
            if (!uri.getAuthority().equals(mAuthority)) {
                return false;
            }
            return uri.getPath().startsWith(mPath);
        }
    }

    static class AssetsPathHandler extends PathHandler {
        private AssetHelper mAssetHelper;

        AssetsPathHandler(@NonNull final String authority, @NonNull final String path,
                                boolean httpEnabled, @NonNull AssetHelper assetHelper) {
            super(authority, path, httpEnabled);
            mAssetHelper = assetHelper;
        }

        @Override
        public InputStream handle(Uri url) {
            String path = url.getPath().replaceFirst(this.mPath, "");
            Uri.Builder assetUriBuilder = new Uri.Builder();
            assetUriBuilder.path(path);
            Uri assetUri = assetUriBuilder.build();

            return mAssetHelper.openAsset(assetUri);
        }
    }

    static class ResourcesPathHandler extends PathHandler {
        private AssetHelper mAssetHelper;

        ResourcesPathHandler(@NonNull final String authority, @NonNull final String path,
                                boolean httpEnabled, @NonNull AssetHelper assetHelper) {
            super(authority, path, httpEnabled);
            mAssetHelper = assetHelper;
        }

        @Override
        public InputStream handle(Uri url) {
            String path = url.getPath().replaceFirst(this.mPath, "");
            Uri.Builder resourceUriBuilder = new Uri.Builder();
            resourceUriBuilder.path(path);
            Uri resourceUri = resourceUriBuilder.build();

            return mAssetHelper.openResource(resourceUri);
        }
    }


    /**
     * A builder class for constructing {@link WebViewAssetLoader} objects.
     */
    public static final class Builder {
        private final Context mContext;

        boolean mAllowHttp;
        @NonNull Uri mAssetsUri;
        @NonNull Uri mResourcesUri;

        /**
         * @param context {@link Context} used to resolve resources/assets.
         */
        public Builder(@NonNull Context context) {
            mContext = context;
            mAllowHttp = false;
            mAssetsUri = createUriPrefix(KNOWN_UNUSED_AUTHORITY, "/assets/");
            mResourcesUri = createUriPrefix(KNOWN_UNUSED_AUTHORITY, "/res/");
        }

        /**
         * Set the domain under which app assets and resources can be accessed.
         * The default domain is {@code "appassets.androidplatform.net"}
         *
         * @param domain the domain on which app assets should be hosted.
         * @return {@link Builder} object.
         */
        @NonNull
        public Builder setDomain(@NonNull String domain) {
            mAssetsUri = createUriPrefix(domain, mAssetsUri.getPath());
            mResourcesUri = createUriPrefix(domain, mResourcesUri.getPath());
            return this;
        }

        /**
         * Set the prefix path under which app assets should be hosted.
         * The default path for assets is {@code "/assets/"}. The path must start and end with
         * {@code "/"}.
         * <p>
         * A custom prefix path can be used in conjunction with a custom domain, to
         * avoid conflicts with real paths which may be hosted at that domain.
         *
         * @param path the path under which app assets should be hosted.
         * @return {@link Builder} object.
         * @throws IllegalArgumentException if the path is invalid.
         */
        @NonNull
        public Builder setAssetsHostingPath(@NonNull String path) {
            mAssetsUri = createUriPrefix(mAssetsUri.getAuthority(), path);
            return this;
        }

        /**
         * Set the prefix path under which app resources should be hosted.
         * The default path for resources is {@code "/res/"}. The path must start and end with
         * {@code "/"}. A custom prefix path can be used in conjunction with a custom domain, to
         * avoid conflicts with real paths which may be hosted at that domain.
         *
         * @param path the path under which app resources should be hosted.
         * @return {@link Builder} object.
         * @throws IllegalArgumentException if the path is invalid.
         */
        @NonNull
        public Builder setResourcesHostingPath(@NonNull String path) {
            mResourcesUri = createUriPrefix(mResourcesUri.getAuthority(), path);
            return this;
        }

        /**
         * Allow using the HTTP scheme in addition to HTTPS.
         * The default is to not allow HTTP.
         *
         * @return {@link Builder} object.
         */
        @NonNull
        public Builder allowHttp() {
            this.mAllowHttp = true;
            return this;
        }

        /**
         * Build and return a {@link WebViewAssetLoader} object.
         *
         * @return immutable {@link WebViewAssetLoader} object.
         * @throws IllegalArgumentException if the {@code Builder} received conflicting inputs.
         */
        @NonNull
        public WebViewAssetLoader build() {
            String assetsPath = mAssetsUri.getPath();
            String resourcesPath = mResourcesUri.getPath();
            if (assetsPath.startsWith(resourcesPath)) {
                throw new
                    IllegalArgumentException("Resources path cannot be prefix of assets path");
            }
            if (resourcesPath.startsWith(assetsPath)) {
                throw new
                    IllegalArgumentException("Assets path cannot be prefix of resources path");
            }

            AssetHelper assetHelper = new AssetHelper(mContext);
            PathHandler assetHandler = new AssetsPathHandler(mAssetsUri.getAuthority(),
                                                mAssetsUri.getPath(), mAllowHttp, assetHelper);

            PathHandler resourceHandler = new ResourcesPathHandler(mResourcesUri.getAuthority(),
                                                    mResourcesUri.getPath(), mAllowHttp,
                                                    assetHelper);

            return new WebViewAssetLoader(assetHandler, resourceHandler);
        }

        @VisibleForTesting
        @NonNull
        /*package*/ WebViewAssetLoader buildForTest(@NonNull AssetHelper assetHelper) {
            PathHandler assetHandler = new AssetsPathHandler(mAssetsUri.getAuthority(),
                                                mAssetsUri.getPath(), mAllowHttp, assetHelper);

            PathHandler resourceHandler = new ResourcesPathHandler(mResourcesUri.getAuthority(),
                                                    mResourcesUri.getPath(), mAllowHttp,
                                                    assetHelper);

            return new WebViewAssetLoader(assetHandler, resourceHandler);
        }

        @VisibleForTesting
        @NonNull
        /*package*/ WebViewAssetLoader buildForTest(@NonNull PathHandler assetHandler,
                                                        @NonNull PathHandler resourceHandler) {
            return new WebViewAssetLoader(assetHandler, resourceHandler);
        }

        @NonNull
        private static Uri createUriPrefix(@NonNull String domain, @NonNull String virtualPath) {
            if (virtualPath.indexOf('*') != -1) {
                throw new IllegalArgumentException(
                        "virtualPath cannot contain the '*' character.");
            }
            if (virtualPath.isEmpty() || virtualPath.charAt(0) != '/') {
                throw new IllegalArgumentException(
                        "virtualPath should start with a slash '/'.");
            }
            if (!virtualPath.endsWith("/")) {
                throw new IllegalArgumentException(
                        "virtualPath should end with a slash '/'.");
            }

            Uri.Builder uriBuilder = new Uri.Builder();
            uriBuilder.authority(domain);
            uriBuilder.path(virtualPath);

            return uriBuilder.build();
        }
    }

    /*package*/ WebViewAssetLoader(@NonNull PathHandler assetHandler,
                                        @NonNull PathHandler resourceHandler) {
        this.mAssetsHandler = assetHandler;
        this.mResourcesHandler = resourceHandler;
    }

    @Nullable
    private static Uri parseAndVerifyUrl(@Nullable String url) {
        if (url == null) {
            return null;
        }
        Uri uri = Uri.parse(url);
        if (uri == null) {
            Log.e(TAG, "Malformed URL: " + url);
            return null;
        }
        String path = uri.getPath();
        if (path == null || path.length() == 0) {
            Log.e(TAG, "URL does not have a path: " + url);
            return null;
        }
        return uri;
    }

    /**
     * Attempt to resolve the {@link WebResourceRequest} to an application resource or
     * asset, and return a {@link WebResourceResponse} for the content.
     * <p>
     * The prefix path used shouldn't be a prefix of a real web path. Thus, in case of having a URL
     * that matches a registered prefix path but the requested asset cannot be found or opened a
     * {@link WebResourceResponse} object with a {@code null} {@link InputStream} will be returned
     * instead of {@code null}. This saves the time of falling back to network and trying to
     * resolve a path that doesn't exist. A {@link WebResourceResponse} with {@code null}
     * {@link InputStream} will be received as an HTTP response with status code {@code 404} and
     * no body.
     * <p>
     * This method should be invoked from within
     * {@link android.webkit.WebViewClient#shouldInterceptRequest(android.webkit.WebView, WebResourceRequest)}.
     *
     * @param request the {@link WebResourceRequest} to process.
     * @return {@link WebResourceResponse} if the request URL matches a registered url,
     *         {@code null} otherwise.
     */
    @RequiresApi(21)
    @Nullable
    public WebResourceResponse shouldInterceptRequest(@NonNull WebResourceRequest request) {
        return shouldInterceptRequestImpl(request.getUrl());
    }

    /**
     * Attempt to resolve the {@code url} to an application resource or asset, and return
     * a {@link WebResourceResponse} for the content.
     * <p>
     * The prefix path used shouldn't be a prefix of a real web path. Thus, in case of having a URL
     * that matches a registered prefix path but the requested asset cannot be found or opened a
     * {@link WebResourceResponse} object with a {@code null} {@link InputStream} will be returned
     * instead of {@code null}. This saves the time of falling back to network and trying to
     * resolve a path that doesn't exist. A {@link WebResourceResponse} with {@code null}
     * {@link InputStream} will be received as an HTTP response with status code {@code 404} and
     * no body.
     * <p>
     * This method should be invoked from within
     * {@link android.webkit.WebViewClient#shouldInterceptRequest(android.webkit.WebView, String)}.
     *
     * @param url the URL string to process.
     * @return {@link WebResourceResponse} if the request URL matches a registered URL,
     *         {@code null} otherwise.
     */
    @Nullable
    public WebResourceResponse shouldInterceptRequest(@NonNull String url) {
        PathHandler handler = null;
        Uri uri = parseAndVerifyUrl(url);
        if (uri == null) {
            return null;
        }
        return shouldInterceptRequestImpl(uri);
    }

    @Nullable
    private WebResourceResponse shouldInterceptRequestImpl(@NonNull Uri url) {
        PathHandler handler;
        if (mAssetsHandler.match(url)) {
            handler = mAssetsHandler;
        } else if (mResourcesHandler.match(url)) {
            handler = mResourcesHandler;
        } else {
            return null;
        }

        InputStream is = handler.handle(url);
        String mimeType = URLConnection.guessContentTypeFromName(url.getPath());

        return new WebResourceResponse(mimeType, null, is);
    }

    /**
     * Get the HTTP URL prefix under which assets are hosted.
     * <p>
     * If HTTP is allowed, the prefix will be on the format:
     * {@code "http://<domain>/<prefix-path>/"}, for example:
     * {@code "http://appassets.androidplatform.net/assets/"}.
     *
     * @return the HTTP URL prefix under which assets are hosted, or {@code null} if HTTP is not
     *         enabled.
     */
    @Nullable
    public Uri getAssetsHttpPrefix() {
        if (!mAssetsHandler.mHttpEnabled) {
            return null;
        }

        Uri.Builder uriBuilder = new Uri.Builder();
        uriBuilder.authority(mAssetsHandler.mAuthority);
        uriBuilder.path(mAssetsHandler.mPath);
        uriBuilder.scheme(HTTP_SCHEME);

        return uriBuilder.build();
    }

    /**
     * Get the HTTPS URL prefix under which assets are hosted.
     * <p>
     * The prefix will be on the format: {@code "https://<domain>/<prefix-path>/"}, if the default
     * values are used then it will be: {@code "https://appassets.androidplatform.net/assets/"}.
     *
     * @return the HTTPS URL prefix under which assets are hosted.
     */
    @NonNull
    public Uri getAssetsHttpsPrefix() {
        Uri.Builder uriBuilder = new Uri.Builder();
        uriBuilder.authority(mAssetsHandler.mAuthority);
        uriBuilder.path(mAssetsHandler.mPath);
        uriBuilder.scheme(HTTPS_SCHEME);

        return uriBuilder.build();
    }

    /**
     * Get the HTTP URL prefix under which resources are hosted.
     * <p>
     * If HTTP is allowed, the prefix will be on the format:
     * {@code "http://<domain>/<prefix-path>/"}, for example
     * {@code "http://appassets.androidplatform.net/res/"}.
     *
     * @return the HTTP URL prefix under which resources are hosted, or {@code null} if HTTP is not
     *         enabled.
     */
    @Nullable
    public Uri getResourcesHttpPrefix() {
        if (!mResourcesHandler.mHttpEnabled) {
            return null;
        }

        Uri.Builder uriBuilder = new Uri.Builder();
        uriBuilder.authority(mResourcesHandler.mAuthority);
        uriBuilder.path(mResourcesHandler.mPath);
        uriBuilder.scheme(HTTP_SCHEME);

        return uriBuilder.build();
    }

    /**
     * Get the HTTPS URL prefix under which resources are hosted.
     * <p>
     * The prefix will be on the format: {@code "https://<domain>/<prefix-path>/"}, if the default
     * values are used then it will be: {@code "https://appassets.androidplatform.net/res/"}.
     *
     * @return the HTTPs URL prefix under which resources are hosted.
     */
    @NonNull
    public Uri getResourcesHttpsPrefix() {
        Uri.Builder uriBuilder = new Uri.Builder();
        uriBuilder.authority(mResourcesHandler.mAuthority);
        uriBuilder.path(mResourcesHandler.mPath);
        uriBuilder.scheme(HTTPS_SCHEME);

        return uriBuilder.build();
    }
}