AndroidJavaScriptBridgeInstaller.java

/*
 * Copyright (C) 2015 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.test.espresso.web.bridge;

import static androidx.test.internal.util.Checks.checkNotNull;
import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;

import android.os.Build;
import android.os.Handler;
import android.os.Message;
import android.util.Log;
import android.webkit.WebChromeClient;
import android.webkit.WebView;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;

/**
 * {@link JavaScriptBridgeInstaller} for aosp-browser based WebViews (default on API level 18 and
 * lower).
 */
final class AndroidJavaScriptBridgeInstaller {
  private static final JavaScriptBoundBridge boundBridge = new JavaScriptBoundBridge();
  private static final String WEB_CORE_CLAZZ = "android.webkit.WebViewCore";
  private static final String WEB_CORE_HANDLER = "sWebCoreHandler";
  private static final String JAVASCRIPT_INTERFACES = "mJavascriptInterfaces";
  private static final String CALLBACK_PROXY_CLAZZ = "android.webkit.CallbackProxy";
  private static final String SET_WEB_CHROME_CLIENT_METHOD = "setWebChromeClient";
  private static final String CALLBACK_PROXY_FIELD = "mCallbackProxy";

  public JavaScriptBoundBridge install()
      throws JavaScriptBridgeInstallException {
    try {
      Class<?> webCoreClazz = Class.forName(WEB_CORE_CLAZZ);
      Field webCoreHandlerField = webCoreClazz.getDeclaredField(WEB_CORE_HANDLER);
      Field javascriptInterfacesField = webCoreClazz.getDeclaredField(JAVASCRIPT_INTERFACES);
      Field callbackProxyField = null;
      Method setWebChromeClientMethod = null;
      if (Build.VERSION.SDK_INT < 13) {
        callbackProxyField = webCoreClazz.getDeclaredField(CALLBACK_PROXY_FIELD);
        Class<?> callbackProxyClazz = Class.forName(CALLBACK_PROXY_CLAZZ);
        setWebChromeClientMethod = callbackProxyClazz.getDeclaredMethod(
            SET_WEB_CHROME_CLIENT_METHOD, WebChromeClient.class);
        callbackProxyField.setAccessible(true);
        setWebChromeClientMethod.setAccessible(true);
      }

      webCoreHandlerField.setAccessible(true);
      javascriptInterfacesField.setAccessible(true);
      Handler webCoreHandler = null;
      synchronized (webCoreClazz) {
        webCoreHandler = (Handler) webCoreHandlerField.get(null);
        if (null != webCoreHandler) {
          Log.w(JavaScriptBridge.TAG, "Initializing late - some webviews may be unbridged.");
        }
      }

      if (null == webCoreHandler) {
        // TODO(b/227119444): should the constructed instance be used?
        WebView unused = new WebView(getInstrumentation().getTargetContext());
        while (null == webCoreHandler) {
          synchronized (webCoreClazz) {
            webCoreHandler = (Handler) webCoreHandlerField.get(null);
          }
        }
      }


      Handler instrumentedHandler = new WebCoreHandlerSpy(webCoreHandler,
          javascriptInterfacesField, callbackProxyField, setWebChromeClientMethod);
      synchronized (webCoreClazz) {
        webCoreHandlerField.set(null, instrumentedHandler);
      }

    } catch (ClassNotFoundException cnfe) {
      throw new JavaScriptBridgeInstallException(cnfe);
    } catch (NoSuchFieldException nsfe) {
      throw new JavaScriptBridgeInstallException(nsfe);
    } catch (NoSuchMethodException nsme) {
      throw new JavaScriptBridgeInstallException(nsme);
    } catch (IllegalAccessException iae) {
      throw new JavaScriptBridgeInstallException(iae);
    }
    Log.i(JavaScriptBridge.TAG, "Initialized web view bridging for android WebView.");
    return boundBridge;
  }

  private static final class WebCoreHandlerSpy extends Handler {
    private final Handler realHandler;
    private final Field javascriptInterfacesField;
    private final Field callbackProxyField;
    private final Method setWebViewClientMethod;

    private WebCoreHandlerSpy(Handler realHandler, Field javascriptInterfacesField,
        Field callbackProxyField, Method setWebViewClientMethod) {
      super(realHandler.getLooper());
      this.realHandler = checkNotNull(realHandler);
      this.javascriptInterfacesField = checkNotNull(javascriptInterfacesField);
      // nullables.
      this.callbackProxyField = callbackProxyField;
      this.setWebViewClientMethod = setWebViewClientMethod;
    }

    // Override this method to detect when new WebViewCore's are being initialized
    // We do the injection of the JavaScriptInterfaces field here to ensure that
    // subwindows get our javascript bridge variable.
    @Override
    public boolean sendMessageAtTime(Message message, long delayMillis) {
      // 0 is the initialize message.
      if (message.what == 0) {
        // and it's sent on main.
        try {
          @SuppressWarnings("unchecked")
          Map<Object, Object> jsInterfaces =
              (Map<Object, Object>) javascriptInterfacesField.get(message.obj);
          if (null == jsInterfaces) {
            jsInterfaces = new HashMap<>();
            javascriptInterfacesField.set(message.obj, jsInterfaces);
          }
          jsInterfaces.put(JavaScriptBridge.JS_BRIDGE_NAME, boundBridge);
          if (Build.VERSION.SDK_INT < 13) {
            // progress is not reported unless a webchromeclient is installed on the webview.
            // Knowing progress helps write code that doesn't run while we're reloading.
            // since this code is running in the constructor of WebView - lets install a
            // a webchromeclient immedately. It doesn't need to do anything - just its presence
            // will propagate progress.
            Object callbackProxy = callbackProxyField.get(message.obj);
            setWebViewClientMethod.invoke(callbackProxy, new WebChromeClient());
          }
        } catch (IllegalAccessException iae) {
          Log.e(JavaScriptBridge.TAG, "Couldn't initialize js bridge in webview!", iae);
        } catch (InvocationTargetException ite) {
          Log.e(JavaScriptBridge.TAG, "Couldn't initialize js bridge in webview!", ite);
        }
      }
      return super.sendMessageAtTime(message, delayMillis);
    }

    @Override
    public void handleMessage(Message message) {
      realHandler.handleMessage(message);
    }
  }
}