chromium/android_webview/java/src/org/chromium/android_webview/AndroidProtocolHandler.java

// Copyright 2012 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.android_webview;

import static org.chromium.components.embedder_support.application.ClassLoaderContextWrapperFactory.getOriginalApplicationContext;

import android.content.Context;
import android.content.res.AssetManager;
import android.net.Uri;
import android.util.Log;
import android.util.TypedValue;

import org.jni_zero.CalledByNative;
import org.jni_zero.JNINamespace;
import org.jni_zero.NativeMethods;

import org.chromium.android_webview.common.Lifetime;
import org.chromium.base.ContextUtils;
import org.chromium.url.GURL;

import java.io.IOException;
import java.io.InputStream;
import java.net.URLConnection;
import java.util.List;
import java.util.zip.GZIPInputStream;

/**
 * Implements the Java side of Android URL protocol jobs.
 * See android_protocol_handler.cc.
 */
@Lifetime.Singleton
@JNINamespace("android_webview")
public class AndroidProtocolHandler {
    private static final String TAG = "AndroidProtocolHandler";

    // Supported URL schemes. This needs to be kept in sync with
    // clank/native/framework/chrome/url_request_android_job.cc.
    private static final String FILE_SCHEME = "file";
    private static final String CONTENT_SCHEME = "content";

    /**
     * Open an InputStream for an Android resource.
     *
     * @param url The url to load.
     * @return An InputStream to the Android resource.
     */
    @CalledByNative
    public static InputStream open(GURL url) {
        Uri uri = verifyUrl(url);
        if (uri == null) {
            return null;
        }
        InputStream stream = openByScheme(uri);
        if (stream != null && uri.getLastPathSegment().endsWith(".svgz")) {
            try {
                stream = new GZIPInputStream(stream);
            } catch (IOException e) {
                Log.e(TAG, "Error decompressing " + uri + " - " + e.getMessage());
                return null;
            }
        }
        return stream;
    }

    private static InputStream openByScheme(Uri uri) {
        try {
            if (uri.getScheme().equals(FILE_SCHEME)) {
                String path = uri.getPath();
                if (path.startsWith(AndroidProtocolHandlerJni.get().getAndroidAssetPath())) {
                    return openAsset(uri);
                } else if (path.startsWith(
                        AndroidProtocolHandlerJni.get().getAndroidResourcePath())) {
                    return openResource(uri);
                }
            } else if (uri.getScheme().equals(CONTENT_SCHEME)) {
                return openContent(uri);
            }
        } catch (Exception ex) {
            Log.e(TAG, "Error opening inputstream: " + uri);
        }
        return null;
    }

    // Assumes input string is in the format "foo.bar.baz" and strips out the last component.
    // Returns null on failure.
    private static String removeOneSuffix(String input) {
        if (input == null) return null;
        int lastDotIndex = input.lastIndexOf('.');
        if (lastDotIndex == -1) return null;
        return input.substring(0, lastDotIndex);
    }

    private static Class<?> getClazz(String packageName, String assetType)
            throws ClassNotFoundException {
        return ContextUtils.getApplicationContext()
                .getClassLoader()
                .loadClass(packageName + ".R$" + assetType);
    }

    // file://android_res/ has no choice but to do name-based resource lookups
    @SuppressWarnings("DiscouragedApi")
    private static int getFieldId(String assetType, String assetName)
            throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
        Context appContext = ContextUtils.getApplicationContext();
        String packageName = appContext.getPackageName();
        int id =
                getOriginalApplicationContext(appContext)
                        .getResources()
                        .getIdentifier(assetName, assetType, packageName);
        if (id != 0) {
            return id;
        }
        // Fail back to reflection if getIdentifier fails, see https://crbug.com/923956.
        Class<?> clazz = null;
        try {
            clazz = getClazz(packageName, assetType);
        } catch (ClassNotFoundException e) {
            // Strip out components from the end so gradle generated application suffix such as
            // com.example.my.pkg.pro works. This is by no means bulletproof.
            while (clazz == null) {
                packageName = removeOneSuffix(packageName);
                // Throw original exception which contains the entire package id.
                if (packageName == null) throw e;
                try {
                    clazz = getClazz(packageName, assetType);
                } catch (ClassNotFoundException ignored) {
                    // Strip and try again.
                }
            }
        }
        java.lang.reflect.Field field = clazz.getField(assetName);
        id = field.getInt(null);

        return id;
    }

    private static int getValueType(int fieldId) {
        TypedValue value = new TypedValue();
        getOriginalApplicationContext(ContextUtils.getApplicationContext())
                .getResources()
                .getValue(fieldId, value, true);
        return value.type;
    }

    private static InputStream openResource(Uri uri) {
        assert uri.getScheme().equals(FILE_SCHEME);
        assert uri.getPath() != null;
        assert uri.getPath().startsWith(AndroidProtocolHandlerJni.get().getAndroidResourcePath());
        // The path must be of the form "/android_res/asset_type/asset_name.ext".
        List<String> pathSegments = uri.getPathSegments();
        if (pathSegments.size() != 3) {
            Log.e(TAG, "Incorrect resource path: " + uri);
            return null;
        }
        String assetPath = pathSegments.get(0);
        String assetType = pathSegments.get(1);
        String assetName = pathSegments.get(2);
        if (!("/" + assetPath + "/")
                .equals(AndroidProtocolHandlerJni.get().getAndroidResourcePath())) {
            Log.e(
                    TAG,
                    "Resource path does not start with "
                            + AndroidProtocolHandlerJni.get().getAndroidResourcePath()
                            + ": "
                            + uri);
            return null;
        }
        // Drop the file extension.
        assetName = assetName.split("\\.")[0];
        try {
            int fieldId = getFieldId(assetType, assetName);
            int valueType = getValueType(fieldId);
            if (valueType == TypedValue.TYPE_STRING) {
                return getOriginalApplicationContext(ContextUtils.getApplicationContext())
                        .getResources()
                        .openRawResource(fieldId);
            } else {
                Log.e(TAG, "Asset not of type string: " + uri);
                return null;
            }
        } catch (ClassNotFoundException e) {
            Log.e(TAG, "Unable to open resource URL: " + uri, e);
            return null;
        } catch (NoSuchFieldException e) {
            Log.e(TAG, "Unable to open resource URL: " + uri, e);
            return null;
        } catch (IllegalAccessException e) {
            Log.e(TAG, "Unable to open resource URL: " + uri, e);
            return null;
        }
    }

    private static InputStream openAsset(Uri uri) {
        assert uri.getScheme().equals(FILE_SCHEME);
        assert uri.getPath() != null;
        assert uri.getPath().startsWith(AndroidProtocolHandlerJni.get().getAndroidAssetPath());
        String path =
                uri.getPath()
                        .replaceFirst(AndroidProtocolHandlerJni.get().getAndroidAssetPath(), "");
        try {
            AssetManager assets =
                    getOriginalApplicationContext(ContextUtils.getApplicationContext()).getAssets();
            return assets.open(path, AssetManager.ACCESS_STREAMING);
        } catch (IOException e) {
            Log.e(TAG, "Unable to open asset URL: " + uri);
            return null;
        }
    }

    private static InputStream openContent(Uri uri) {
        assert uri.getScheme().equals(CONTENT_SCHEME);
        try {
            return ContextUtils.getApplicationContext().getContentResolver().openInputStream(uri);
        } catch (Exception e) {
            Log.e(TAG, "Unable to open content URL: " + uri);
            return null;
        }
    }

    /**
     * Determine the mime type for an Android resource.
     *
     * @param stream  The opened input stream which to examine.
     * @param url     The url from which the stream was opened.
     * @return The mime type or null if the type is unknown.
     */
    @CalledByNative
    public static String getMimeType(InputStream stream, GURL url) {
        Uri uri = verifyUrl(url);
        if (uri == null) {
            return null;
        }
        try {
            String path = uri.getPath();
            // The content URL type can be queried directly.
            if (uri.getScheme().equals(CONTENT_SCHEME)) {
                return ContextUtils.getApplicationContext().getContentResolver().getType(uri);
                // Asset files may have a known extension.
            } else if (uri.getScheme().equals(FILE_SCHEME)
                    && path.startsWith(AndroidProtocolHandlerJni.get().getAndroidAssetPath())) {
                String mimeType = URLConnection.guessContentTypeFromName(path);

                // If the OS didn't find anything we fall back to Chromium's implementation which
                // should be far more reliable since we can control this.
                // See crbug.com/1019528
                if (mimeType == null) {
                    mimeType = AndroidProtocolHandlerJni.get().getWellKnownMimeType(path);
                }

                if (mimeType != null) {
                    return mimeType;
                }
            }
        } catch (Exception ex) {
            Log.e(TAG, "Unable to get mime type" + url);
            return null;
        }
        // Fall back to sniffing the type from the stream.
        try {
            return URLConnection.guessContentTypeFromStream(stream);
        } catch (IOException e) {
            return null;
        }
    }

    /**
     * Make sure the given GURL is correctly formed and parse it into a Uri.
     *
     * @return a Uri instance, or null if the URL was invalid.
     */
    private static Uri verifyUrl(GURL url) {
        if (url == null) return null;
        if (url.isEmpty()) return null;
        // Never null. parse() doesn't actually parse or verify anything.
        Uri uri = Uri.parse(url.getSpec());
        String path = uri.getPath();
        if (path == null || path.isEmpty() || path.equals("/")) {
            Log.e(TAG, "URL does not have a path: " + url);
            return null;
        }
        return uri;
    }

    @NativeMethods
    interface Natives {
        String getAndroidAssetPath();

        String getAndroidResourcePath();

        String getWellKnownMimeType(String path);
    }
}