mobile/android/base/tests/helpers/JavascriptBridge.java

Wed, 31 Dec 2014 06:09:35 +0100

author
Michael Schloh von Bennewitz <michael@schloh.com>
date
Wed, 31 Dec 2014 06:09:35 +0100
changeset 0
6474c204b198
permissions
-rw-r--r--

Cloned upstream origin tor-browser at tor-browser-31.3.0esr-4.5-1-build1
revision ID fc1c9ff7c1b2defdbc039f12214767608f46423f for hacking purpose.

     1 /* This Source Code Form is subject to the terms of the Mozilla Public
     2  * License, v. 2.0. If a copy of the MPL was not distributed with this
     3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
     5 package org.mozilla.gecko.tests.helpers;
     7 import java.lang.reflect.InvocationTargetException;
     8 import java.lang.reflect.Method;
    10 import junit.framework.AssertionFailedError;
    12 import org.json.JSONArray;
    13 import org.json.JSONException;
    14 import org.json.JSONObject;
    16 import org.mozilla.gecko.Actions;
    17 import org.mozilla.gecko.Actions.EventExpecter;
    18 import org.mozilla.gecko.Assert;
    19 import org.mozilla.gecko.tests.UITestContext;
    21 /**
    22  * Javascript bridge allows calls to and from JavaScript.
    23  *
    24  * To establish communication, create an instance of JavascriptBridge in Java and pass in
    25  * an object that will receive calls from JavaScript. For example:
    26  *
    27  *  {@code final JavascriptBridge js = new JavascriptBridge(javaObj);}
    28  *
    29  * Next, create an instance of JavaBridge in JavaScript and pass in another object
    30  * that will receive calls from Java. For example:
    31  *
    32  *  {@code let java = new JavaBridge(jsObj);}
    33  *
    34  * Once a link is established, calls can be made using the methods syncCall and asyncCall.
    35  * syncCall waits for the call to finish before returning. For example:
    36  *
    37  *  {@code js.syncCall("abc", 1, 2, 3);} will synchronously call the JavaScript method
    38  *    jsObj.abc and pass in arguments 1, 2, and 3.
    39  *
    40  *  {@code java.asyncCall("def", 4, 5, 6);} will asynchronously call the Java method
    41  *    javaObj.def and pass in arguments 4, 5, and 6.
    42  *
    43  * Supported argument types include int, double, boolean, String, and JSONObject. Note
    44  * that only implicit conversion is done, meaning if a floating point argument is passed
    45  * from JavaScript to Java, the call will fail if the Java method has an int argument.
    46  *
    47  * Because JavascriptBridge and JavaBridge use one underlying communication channel,
    48  * creating multiple instances of them will not create independent links.
    49  *
    50  * Note also that because Robocop tests finish as soon as the Java test method returns,
    51  * the last call to JavaScript from Java must be a synchronous call. Otherwise, the test
    52  * will finish before the JavaScript method is run. Calls to Java from JavaScript do not
    53  * have this requirement. Because of these considerations, calls from Java to JavaScript
    54  * are usually synchronous and calls from JavaScript to Java are usually asynchronous.
    55  * See testJavascriptBridge.java for examples.
    56  */
    57 public final class JavascriptBridge {
    59     private static enum MessageStatus {
    60         QUEUE_EMPTY, // Did not process a message; queue was empty.
    61         PROCESSED,   // A message other than sync was processed.
    62         REPLIED,     // A sync message was processed.
    63         SAVED,       // An async message was saved; see processMessage().
    64     };
    66     @SuppressWarnings("serial")
    67     public static class CallException extends RuntimeException {
    68         public CallException() {
    69             super();
    70         }
    72         public CallException(final String msg) {
    73             super(msg);
    74         }
    76         public CallException(final String msg, final Throwable e) {
    77             super(msg, e);
    78         }
    80         public CallException(final Throwable e) {
    81             super(e);
    82         }
    83     }
    85     public static final String EVENT_TYPE = "Robocop:JS";
    87     private static Actions sActions;
    88     private static Assert sAsserter;
    90     // Target of JS-to-Java calls
    91     private final Object mTarget;
    92     // List of public methods in subclass
    93     private final Method[] mMethods;
    94     // Parser for handling xpcshell assertions
    95     private final JavascriptMessageParser mLogParser;
    96     // Expecter of our internal Robocop event
    97     private final EventExpecter mExpecter;
    98     // Saved async message; see processMessage() for its purpose.
    99     private JSONObject mSavedAsyncMessage;
   100     // Number of levels in the synchronous call stack
   101     private int mCallStackDepth;
   102     // If JavaBridge has been loaded
   103     private boolean mJavaBridgeLoaded;
   105     /* package */ static void init(final UITestContext context) {
   106         sActions = context.getActions();
   107         sAsserter = context.getAsserter();
   108     }
   110     public JavascriptBridge(final Object target) {
   111         mTarget = target;
   112         mMethods = target.getClass().getMethods();
   113         mExpecter = sActions.expectGeckoEvent(EVENT_TYPE);
   114         // The JS here is unrelated to a test harness, so we
   115         // have our message parser end on assertion failure.
   116         mLogParser = new JavascriptMessageParser(sAsserter, true);
   117     }
   119     /**
   120      * Synchronously calls a method in Javascript.
   121      *
   122      * @param method Name of the method to call
   123      * @param args Arguments to pass to the Javascript method; must be a list of
   124      *             values allowed by JSONObject.
   125      */
   126     public void syncCall(final String method, final Object... args) {
   127         mCallStackDepth++;
   129         sendMessage("sync-call", method, args);
   130         try {
   131             while (processPendingMessage() != MessageStatus.REPLIED) {
   132             }
   133         } catch (final AssertionFailedError e) {
   134             // Most likely an event expecter time out
   135             throw new CallException("Cannot call " + method, e);
   136         }
   138         // If syncCall was called reentrantly from processPendingMessage(), mCallStackDepth
   139         // will be greater than 1 here. In that case we don't have to wait for pending calls
   140         // because the outermost syncCall will do it for us.
   141         if (mCallStackDepth == 1) {
   142             // We want to wait for all asynchronous calls to finish,
   143             // because the test may end immediately after this method returns.
   144             finishPendingCalls();
   145         }
   146         mCallStackDepth--;
   147     }
   149     /**
   150      * Asynchronously calls a method in Javascript.
   151      *
   152      * @param method Name of the method to call
   153      * @param args Arguments to pass to the Javascript method; must be a list of
   154      *             values allowed by JSONObject.
   155      */
   156     public void asyncCall(final String method, final Object... args) {
   157         sendMessage("async-call", method, args);
   158     }
   160     /**
   161      * Disconnect the bridge.
   162      */
   163     public void disconnect() {
   164         mExpecter.unregisterListener();
   165     }
   167     /**
   168      * Process a new message; wait for new message if necessary.
   169      *
   170      * @return MessageStatus value to indicate result of processing the message
   171      */
   172     private MessageStatus processPendingMessage() {
   173         // We're on the test thread.
   174         // We clear mSavedAsyncMessage in maybeProcessPendingMessage() but not here,
   175         // because we always have a new message for processing here, so we never
   176         // get a chance to clear mSavedAsyncMessage.
   177         try {
   178             final String message = mExpecter.blockForEventData();
   179             return processMessage(new JSONObject(message));
   180         } catch (final JSONException e) {
   181             throw new IllegalStateException("Invalid message", e);
   182         }
   183     }
   185     /**
   186      * Process a message if a new or saved message is available.
   187      *
   188      * @return MessageStatus value to indicate result of processing the message
   189      */
   190     private MessageStatus maybeProcessPendingMessage() {
   191         // We're on the test thread.
   192         final String message = mExpecter.blockForEventDataWithTimeout(0);
   193         if (message != null) {
   194             try {
   195                 return processMessage(new JSONObject(message));
   196             } catch (final JSONException e) {
   197                 throw new IllegalStateException("Invalid message", e);
   198             }
   199         }
   200         if (mSavedAsyncMessage != null) {
   201             // processMessage clears mSavedAsyncMessage.
   202             return processMessage(mSavedAsyncMessage);
   203         }
   204         return MessageStatus.QUEUE_EMPTY;
   205     }
   207     /**
   208      * Wait for all asynchronous messages from Javascript to be processed.
   209      */
   210     private void finishPendingCalls() {
   211         MessageStatus result;
   212         do {
   213             result = maybeProcessPendingMessage();
   214             if (result == MessageStatus.REPLIED) {
   215                 throw new IllegalStateException("Sync reply was unexpected");
   216             }
   217         } while (result != MessageStatus.QUEUE_EMPTY);
   218     }
   220     private void ensureJavaBridgeLoaded() {
   221         while (!mJavaBridgeLoaded) {
   222             processPendingMessage();
   223         }
   224     }
   226     private void sendMessage(final String innerType, final String method, final Object[] args) {
   227         ensureJavaBridgeLoaded();
   229         // Call from Java to Javascript
   230         final JSONObject message = new JSONObject();
   231         final JSONArray jsonArgs = new JSONArray();
   232         try {
   233             if (args != null) {
   234                 for (final Object arg : args) {
   235                     jsonArgs.put(convertToJSONValue(arg));
   236                 }
   237             }
   238             message.put("type", EVENT_TYPE)
   239                    .put("innerType", innerType)
   240                    .put("method", method)
   241                    .put("args", jsonArgs);
   242         } catch (final JSONException e) {
   243             throw new IllegalStateException("Unable to create JSON message", e);
   244         }
   245         sActions.sendGeckoEvent(EVENT_TYPE, message.toString());
   246     }
   248     private MessageStatus processMessage(JSONObject message) {
   249         final String type;
   250         final String methodName;
   251         final JSONArray argsArray;
   252         final Object[] args;
   253         try {
   254             if (!EVENT_TYPE.equals(message.getString("type"))) {
   255                 throw new IllegalStateException("Message type is not " + EVENT_TYPE);
   256             }
   257             type = message.getString("innerType");
   259             if ("progress".equals(type)) {
   260                 // Javascript harness message
   261                 mLogParser.logMessage(message.getString("message"));
   262                 return MessageStatus.PROCESSED;
   264             } else if ("notify-loaded".equals(type)) {
   265                 mJavaBridgeLoaded = true;
   266                 return MessageStatus.PROCESSED;
   268             } else if ("sync-reply".equals(type)) {
   269                 // Reply to Java-to-Javascript sync call
   270                 return MessageStatus.REPLIED;
   272             } else if ("sync-call".equals(type) || "async-call".equals(type)) {
   274                 if ("async-call".equals(type)) {
   275                     // Save this async message until another async message arrives, then we
   276                     // process the saved message and save the new one. This is done as a
   277                     // form of tail call optimization, by making sync-replies come before
   278                     // async-calls. On the other hand, if (message == mSavedAsyncMessage),
   279                     // it means we're currently processing the saved message and should clear
   280                     // mSavedAsyncMessage.
   281                     final JSONObject newSavedMessage =
   282                         (message != mSavedAsyncMessage ? message : null);
   283                     message = mSavedAsyncMessage;
   284                     mSavedAsyncMessage = newSavedMessage;
   285                     if (message == null) {
   286                         // Saved current message and there wasn't an already saved one.
   287                         return MessageStatus.SAVED;
   288                     }
   289                 }
   291                 methodName = message.getString("method");
   292                 argsArray = message.getJSONArray("args");
   293                 args = new Object[argsArray.length()];
   294                 for (int i = 0; i < args.length; i++) {
   295                     args[i] = convertFromJSONValue(argsArray.get(i));
   296                 }
   297                 invokeMethod(methodName, args);
   299                 if ("sync-call".equals(type)) {
   300                     // Reply for sync messages
   301                     sendMessage("sync-reply", methodName, null);
   302                 }
   303                 return MessageStatus.PROCESSED;
   304             }
   305             throw new IllegalStateException("Message type is unexpected");
   307         } catch (final JSONException e) {
   308             throw new IllegalStateException("Unable to retrieve JSON message", e);
   309         }
   310     }
   312     /**
   313      * Given a method name and a list of arguments,
   314      * call the most suitable method in the subclass.
   315      */
   316     private Object invokeMethod(final String methodName, final Object[] args) {
   317         final Class<?>[] argTypes = new Class<?>[args.length];
   318         for (int i = 0; i < argTypes.length; i++) {
   319             if (args[i] == null) {
   320                 argTypes[i] = Object.class;
   321             } else {
   322                 argTypes[i] = args[i].getClass();
   323             }
   324         }
   326         // Try using argument types directly without casting.
   327         try {
   328             return invokeMethod(mTarget.getClass().getMethod(methodName, argTypes), args);
   329         } catch (final NoSuchMethodException e) {
   330             // getMethod() failed; try fallback below.
   331         }
   333         // One scenario for getMethod() to fail above is that we don't have the exact
   334         // argument types in argTypes (e.g. JS gave us an int but we're using a double,
   335         // or JS gave us a null and we don't know its intended type), or the number of
   336         // arguments is incorrect. Now we find all the methods with the given name and
   337         // try calling them one-by-one. If one call fails, we move to the next call.
   338         // Java will try to convert our arguments to the right types.
   339         Throwable lastException = null;
   340         for (final Method method : mMethods) {
   341             if (!method.getName().equals(methodName)) {
   342                 continue;
   343             }
   344             try {
   345                 return invokeMethod(method, args);
   346             } catch (final IllegalArgumentException e) {
   347                 lastException = e;
   348                 // Try the next method
   349             } catch (final UnsupportedOperationException e) {
   350                 // "Cannot access method" exception below, see if there are other public methods
   351                 lastException = e;
   352                 // Try the next method
   353             }
   354         }
   355         // Now we're out of options
   356         throw new UnsupportedOperationException(
   357             "Cannot call method " + methodName + " (not public? wrong argument types?)",
   358             lastException);
   359     }
   361     private Object invokeMethod(final Method method, final Object[] args) {
   362         try {
   363             return method.invoke(mTarget, args);
   364         } catch (final IllegalAccessException e) {
   365             throw new UnsupportedOperationException(
   366                 "Cannot access method " + method.getName(), e);
   367         } catch (final InvocationTargetException e) {
   368             final Throwable cause = e.getCause();
   369             if (cause instanceof CallException) {
   370                 // Don't wrap CallExceptions; this can happen if a call is nested on top
   371                 // of existing sync calls, and the nested call throws a CallException
   372                 throw (CallException) cause;
   373             }
   374             throw new CallException("Failed to invoke " + method.getName(), cause);
   375         }
   376     }
   378     private Object convertFromJSONValue(final Object value) {
   379         if (value == JSONObject.NULL) {
   380             return null;
   381         }
   382         return value;
   383     }
   385     private Object convertToJSONValue(final Object value) {
   386         if (value == null) {
   387             return JSONObject.NULL;
   388         }
   389         return value;
   390     }
   391 }

mercurial