new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java
@@ -0,0 +1,810 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test.rule;
+
+import org.mozilla.gecko.GeckoSession;
+import org.mozilla.gecko.GeckoSessionSettings;
+import org.mozilla.geckoview.test.util.Callbacks;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.Matchers.greaterThan;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertThat;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import org.junit.runner.Description;
+import org.junit.runners.model.Statement;
+
+import android.app.Instrumentation;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import android.os.MessageQueue;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.test.InstrumentationRegistry;
+import android.support.test.rule.UiThreadTestRule;
+
+import java.lang.annotation.Annotation;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Proxy;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.regex.Pattern;
+
+/**
+ * TestRule that, for each test, sets up a GeckoSession, runs the test on the UI thread,
+ * and tears down the GeckoSession at the end of the test. The rule also provides methods
+ * for waiting on particular callbacks to be called, and methods for asserting that
+ * callbacks are called in the proper order.
+ */
+public class GeckoSessionTestRule extends UiThreadTestRule {
+
+ private static final long DEFAULT_TIMEOUT_MILLIS = 10000;
+ public static final String APK_URI_PREFIX = "resource://android";
+
+ /**
+ * Specify the timeout for any of the wait methods, in milliseconds. Can be used
+ * on classes or methods.
+ */
+ @Target({ElementType.METHOD, ElementType.TYPE})
+ @Retention(RetentionPolicy.RUNTIME)
+ public @interface TimeoutMillis {
+ long value();
+ }
+
+ /**
+ * Specify a list of GeckoSession settings to be applied to the GeckoSession object
+ * under test. Can be used on classes or methods. Note that the settings values must
+ * be string literals regardless of the type of the settings.
+ * <p>
+ * Disable e10s for a particular test:
+ * <pre>
+ * @Setting.List(@Setting(key = Setting.Key.USE_MULTIPROCESS,
+ * value = "false"))
+ * @Test public void test() { ... }
+ * </pre>
+ * <p>
+ * Use multiple settings:
+ * <pre>
+ * @Setting.List({@Setting(key = Setting.Key.USE_MULTIPROCESS,
+ * value = "false"),
+ * @Setting(key = Setting.Key.USE_TRACKING_PROTECTION,
+ * value = "true")})
+ * </pre>
+ */
+ @Target(ElementType.ANNOTATION_TYPE)
+ @Retention(RetentionPolicy.RUNTIME)
+ public @interface Setting {
+ enum Key {
+ CHROME_URI,
+ DISPLAY_MODE,
+ SCREEN_ID,
+ USE_MULTIPROCESS,
+ USE_PRIVATE_MODE,
+ USE_REMOTE_DEBUGGER,
+ USE_TRACKING_PROTECTION;
+
+ private final GeckoSessionSettings.Key<?> mKey;
+ private final Class<?> mType;
+
+ Key() {
+ final Field field;
+ try {
+ field = GeckoSessionSettings.class.getField(name());
+ mKey = (GeckoSessionSettings.Key<?>) field.get(null);
+ } catch (final NoSuchFieldException | IllegalAccessException e) {
+ throw new RuntimeException(e);
+ }
+
+ final ParameterizedType genericType = (ParameterizedType) field.getGenericType();
+ mType = (Class<?>) genericType.getActualTypeArguments()[0];
+ }
+
+ @SuppressWarnings("unchecked")
+ public void set(final GeckoSessionSettings settings, final String value) {
+ if (boolean.class.equals(mType) || Boolean.class.equals(mType)) {
+ settings.setBoolean((GeckoSessionSettings.Key<Boolean>) mKey,
+ Boolean.valueOf(value));
+ } else if (int.class.equals(mType) || Integer.class.equals(mType)) {
+ try {
+ settings.setInt((GeckoSessionSettings.Key<Integer>) mKey,
+ (Integer) GeckoSessionSettings.class.getField(value)
+ .get(null));
+ return;
+ } catch (final NoSuchFieldException | IllegalAccessException |
+ ClassCastException e) {
+ }
+ settings.setInt((GeckoSessionSettings.Key<Integer>) mKey,
+ Integer.valueOf(value));
+ } else if (String.class.equals(mType)) {
+ settings.setString((GeckoSessionSettings.Key<String>) mKey, value);
+ } else {
+ throw new IllegalArgumentException("Unsupported type: " +
+ mType.getSimpleName());
+ }
+ }
+ }
+
+ @Target({ElementType.METHOD, ElementType.TYPE})
+ @Retention(RetentionPolicy.RUNTIME)
+ @interface List {
+ Setting[] value();
+ }
+
+ Key key();
+ String value();
+ }
+
+ /**
+ * Assert that a method is called or not called, and if called, the order and number
+ * of times it is called. The order number is a monotonically increasing integer; if
+ * an called method's order number is less than the current order number, an exception
+ * is raised for out-of-order call.
+ * <p>
+ * {@code @AssertCalled} asserts the method must be called at least once.
+ * <p>
+ * {@code @AssertCalled(false)} asserts the method must not be called.
+ * <p>
+ * {@code @AssertCalled(order = 2)} asserts the method must be called once and
+ * after any other method with order number less than 2.
+ * <p>
+ * {@code @AssertCalled(order = {2, 4})} asserts order number 2 for first
+ * call and order number 4 for any subsequent calls.
+ * <p>
+ * {@code @AssertCalled(count = 2)} asserts two calls total in any order
+ * with respect to other calls.
+ * <p>
+ * {@code @AssertCalled(count = 2, order = 2)} asserts two calls, both with
+ * order number 2.
+ * <p>
+ * {@code @AssertCalled(count = 2, order = {2, 4, 6})} asserts two calls
+ * total: the first with order number 2 and the second with order number 4.
+ */
+ @Target(ElementType.METHOD)
+ @Retention(RetentionPolicy.RUNTIME)
+ public @interface AssertCalled {
+ /**
+ * @return True if the method must be called,
+ * or false if the method must not be called.
+ */
+ boolean value() default true;
+
+ /**
+ * @return If called, the number of calls called, or 0 to allow any number > 0.
+ */
+ int count() default 0;
+
+ /**
+ * @return If called, the order number for each call, or 0 to allow arbitrary
+ * order. If order's length is more than count, extra elements are not used;
+ * if order's length is less than count, the last element is repeated.
+ */
+ int[] order() default 0;
+ }
+
+ public static class CallRequirement {
+ public final boolean allowed;
+ public final int count;
+ public final int[] order;
+
+ public CallRequirement(final boolean allowed, final int count, final int[] order) {
+ this.allowed = allowed;
+ this.count = count;
+ this.order = order;
+ }
+ }
+
+ public static class CallInfo {
+ public final int counter;
+ public final int order;
+
+ /* package */ CallInfo(final int counter, final int order) {
+ this.counter = counter;
+ this.order = order;
+ }
+ }
+
+ public static class MethodCall {
+ public final Method method;
+ public final CallRequirement requirement;
+ private int currentCount;
+
+ /* package */ MethodCall(final Method method) {
+ this(method, (CallRequirement) null);
+ }
+
+ /* package */ MethodCall(final Method method,
+ final AssertCalled requirement) {
+ this(method, requirement != null ? new CallRequirement(
+ requirement.value(), requirement.count(), requirement.order()) : null);
+ }
+
+ public MethodCall(final Method method,
+ final CallRequirement requirement) {
+ this.method = method;
+ this.requirement = requirement;
+ currentCount = 0;
+ }
+
+ @Override
+ public boolean equals(final Object other) {
+ if (this == other) {
+ return true;
+ } else if (other instanceof MethodCall) {
+ return methodsEqual(method, ((MethodCall) other).method);
+ } else if (other instanceof Method) {
+ return methodsEqual(method, (Method) other);
+ }
+ return false;
+ }
+
+ /* package */ int getOrder() {
+ if (requirement == null || currentCount == 0) {
+ return 0;
+ }
+
+ final int[] order = requirement.order;
+ if (order == null || order.length == 0) {
+ return 0;
+ }
+ return order[Math.min(currentCount - 1, order.length - 1)];
+ }
+
+ /* package */ int getCount() {
+ return (requirement == null) ? 0 :
+ !requirement.allowed ? -1 : requirement.count;
+ }
+
+ /* package */ void incrementCounter() {
+ currentCount++;
+ }
+
+ /* package */ boolean allowCalls() {
+ return getCount() >= 0;
+ }
+
+ /* package */ boolean allowUnlimitedCalls() {
+ return getCount() == 0;
+ }
+
+ /* package */ boolean allowMoreCalls() {
+ final int count = getCount();
+ return count == 0 || count > currentCount;
+ }
+
+ /* package */ void assertAllowMoreCalls() {
+ final int count = getCount();
+ assertTrue(method.getName() + " should not be called", allowCalls());
+ assertTrue(method.getName() + " should be limited to " + count +
+ " call" + (count > 1 ? "s" : ""), allowMoreCalls());
+ }
+
+ /* package */ void assertOrder(final int order) {
+ final int newOrder = getOrder();
+ if (newOrder != 0) {
+ assertTrue(method.getName() + " order number " + newOrder +
+ " does not match expected order number " + order,
+ newOrder >= order);
+ }
+ }
+
+ /* package */ void assertMatchesCount() {
+ if (requirement == null) {
+ return;
+ }
+ final int count = getCount();
+ if (count < 0) {
+ assertEquals(method.getName() + " should not be called",
+ 0, currentCount);
+ } else if (count == 0) {
+ assertThat(method.getName() + " should be called", currentCount,
+ is(greaterThan(0)));
+ } else {
+ assertEquals(method.getName() + " should be called " + count + " time" +
+ (count == 1 ? "" : "s"), count, currentCount);
+ }
+ }
+
+ /* package */ CallInfo getInfo() {
+ return new CallInfo(currentCount, getOrder());
+ }
+
+ // Similar to Method.equals, but treat the same method from an interface and an
+ // overriding class as the same (e.g. CharSequence.length == String.length).
+ private static boolean methodsEqual(final @NonNull Method m1, final @NonNull Method m2) {
+ return (m1.getDeclaringClass().isAssignableFrom(m2.getDeclaringClass()) ||
+ m2.getDeclaringClass().isAssignableFrom(m1.getDeclaringClass())) &&
+ m1.getName().equals(m2.getName()) &&
+ m1.getReturnType().equals(m2.getReturnType()) &&
+ Arrays.equals(m1.getParameterTypes(), m2.getParameterTypes());
+ }
+ }
+
+ protected static class CallRecord {
+ public final Method method;
+ public final MethodCall methodCall;
+ public final Object[] args;
+
+ public CallRecord(final Method method, final Object[] args) {
+ this.method = method;
+ this.methodCall = new MethodCall(method);
+ this.args = args;
+ }
+ }
+
+ /* package */ static AssertCalled getAssertCalled(final Method method, final Object callback) {
+ final AssertCalled annotation = method.getAnnotation(AssertCalled.class);
+ if (annotation != null) {
+ return annotation;
+ }
+
+ // Some Kotlin lambdas have an invoke method that carries the annotation,
+ // instead of the interface method carrying the annotation.
+ try {
+ return callback.getClass().getDeclaredMethod(
+ "invoke", method.getParameterTypes()).getAnnotation(AssertCalled.class);
+ } catch (final NoSuchMethodException e) {
+ return null;
+ }
+ }
+
+ private static void addCallbackClasses(final List<Class<?>> list, final Class<?> ifce) {
+ if (!Callbacks.class.equals(ifce.getDeclaringClass())) {
+ list.add(ifce);
+ return;
+ }
+ final Class<?>[] superIfces = ifce.getInterfaces();
+ for (final Class<?> superIfce : superIfces) {
+ addCallbackClasses(list, superIfce);
+ }
+ }
+
+ private static Class<?>[] getCallbackClasses() {
+ final Class<?>[] ifces = Callbacks.class.getDeclaredClasses();
+ final List<Class<?>> list = new ArrayList<>(ifces.length);
+
+ for (final Class<?> ifce : ifces) {
+ addCallbackClasses(list, ifce);
+ }
+
+ final HashSet<Class<?>> set = new HashSet<>(list);
+ return set.toArray(new Class<?>[set.size()]);
+ }
+
+ private static final List<Class<?>> CALLBACK_CLASSES = Arrays.asList(getCallbackClasses());
+
+ protected final Instrumentation mInstrumentation =
+ InstrumentationRegistry.getInstrumentation();
+ protected final GeckoSessionSettings mDefaultSettings;
+
+ protected GeckoSession mSession;
+ protected Object mCallbackProxy;
+ protected List<CallRecord> mCallRecords;
+ protected int mLastWaitStart;
+ protected int mLastWaitEnd;
+ protected MethodCall mCurrentMethodCall;
+ protected long mTimeoutMillis = DEFAULT_TIMEOUT_MILLIS;
+
+ public GeckoSessionTestRule() {
+ mDefaultSettings = new GeckoSessionSettings();
+ }
+
+ /**
+ * Get the session set up for the current test.
+ *
+ * @return GeckoSession object.
+ */
+ public @NonNull GeckoSession getSession() {
+ return mSession;
+ }
+
+ protected static Method getCallbackSetter(final @NonNull Class<?> cls)
+ throws NoSuchMethodException {
+ return GeckoSession.class.getMethod("set" + cls.getSimpleName(), cls);
+ }
+
+ protected static Method getCallbackGetter(final @NonNull Class<?> cls)
+ throws NoSuchMethodException {
+ return GeckoSession.class.getMethod("get" + cls.getSimpleName());
+ }
+
+ protected void applyAnnotations(final Collection<Annotation> annotations,
+ final GeckoSessionSettings settings) {
+ for (final Annotation annotation : annotations) {
+ if (TimeoutMillis.class.equals(annotation.annotationType())) {
+ mTimeoutMillis = Math.max(((TimeoutMillis) annotation).value(), 100);
+ } else if (Setting.List.class.equals(annotation.annotationType())) {
+ for (final Setting setting : ((Setting.List) annotation).value()) {
+ setting.key().set(settings, setting.value());
+ }
+ }
+ }
+ }
+
+ protected void prepareSession(final Description description) throws Throwable {
+ final GeckoSessionSettings settings = new GeckoSessionSettings(mDefaultSettings);
+
+ applyAnnotations(Arrays.asList(description.getTestClass().getAnnotations()), settings);
+ applyAnnotations(description.getAnnotations(), settings);
+
+ final List<CallRecord> records = new ArrayList<>();
+ mCallRecords = records;
+ mLastWaitStart = 0;
+ mLastWaitEnd = 0;
+
+ final InvocationHandler recorder = new InvocationHandler() {
+ @Override
+ public Object invoke(final Object proxy, final Method method,
+ final Object[] args) {
+ assertEquals("Callbacks must be on UI thread",
+ Looper.getMainLooper(), Looper.myLooper());
+
+ records.add(new CallRecord(method, args));
+
+ try {
+ return method.invoke(Callbacks.Default.INSTANCE, args);
+ } catch (final IllegalAccessException | InvocationTargetException e) {
+ throw new RuntimeException(e.getCause() != null ? e.getCause() : e);
+ }
+ }
+ };
+
+ final Class<?>[] classes = CALLBACK_CLASSES.toArray(new Class<?>[CALLBACK_CLASSES.size()]);
+ mCallbackProxy = Proxy.newProxyInstance(GeckoSession.class.getClassLoader(),
+ classes, recorder);
+
+ mSession = new GeckoSession(settings);
+
+ for (final Class<?> cls : CALLBACK_CLASSES) {
+ if (cls != null) {
+ getCallbackSetter(cls).invoke(mSession, mCallbackProxy);
+ }
+ }
+
+ mSession.openWindow(mInstrumentation.getTargetContext());
+
+ if (settings.getBoolean(GeckoSessionSettings.USE_MULTIPROCESS)) {
+ // Under e10s, we receive an initial about:blank load; don't expose that to the test.
+ waitForPageStop();
+ }
+ }
+
+ protected void cleanupSession() throws Throwable {
+ if (mSession.isOpen()) {
+ mSession.closeWindow();
+ }
+ mSession = null;
+ mCallbackProxy = null;
+ mCallRecords = null;
+ mLastWaitStart = 0;
+ mLastWaitEnd = 0;
+ mTimeoutMillis = DEFAULT_TIMEOUT_MILLIS;
+ }
+
+ @Override
+ public Statement apply(final Statement base, final Description description) {
+ return super.apply(new Statement() {
+ @Override
+ public void evaluate() throws Throwable {
+ try {
+ prepareSession(description);
+ base.evaluate();
+ } finally {
+ cleanupSession();
+ }
+ }
+ }, description);
+ }
+
+ @Override
+ protected boolean shouldRunOnUiThread(final Description description) {
+ return true;
+ }
+
+ /**
+ * Loop the current thread until the message queue is idle. If loop is already idle and
+ * timeout is not specified, return immediately. If loop is already idle and timeout is
+ * specified, wait for a message to arrive first; an exception is thrown if timeout
+ * expires during the wait.
+ *
+ * @param timeout Wait timeout in milliseconds or 0 to not wait.
+ */
+ protected static void loopUntilIdle(final long timeout) {
+ // Adapted from GeckoThread.pumpMessageLoop.
+ final Looper looper = Looper.myLooper();
+ assertNotNull("Looper must exist", looper);
+
+ final MessageQueue queue = looper.getQueue();
+ final Handler handler = new Handler(looper);
+ final MessageQueue.IdleHandler idleHandler = new MessageQueue.IdleHandler() {
+ @Override
+ public boolean queueIdle() {
+ final Message msg = Message.obtain(handler);
+ msg.obj = handler;
+ handler.sendMessageAtFrontOfQueue(msg);
+ return false; // Remove this idle handler.
+ }
+ };
+ final Runnable timeoutRunnable = new Runnable() {
+ @Override
+ public void run() {
+ fail("Timed out after " + timeout + "ms");
+ }
+ };
+
+ if (timeout > 0) {
+ handler.postDelayed(timeoutRunnable, timeout);
+ } else {
+ queue.addIdleHandler(idleHandler);
+ }
+
+ final Method getNextMessage;
+ try {
+ getNextMessage = queue.getClass().getDeclaredMethod("next");
+ } catch (final NoSuchMethodException e) {
+ throw new RuntimeException(e);
+ }
+ getNextMessage.setAccessible(true);
+
+ while (true) {
+ final Message msg;
+ try {
+ msg = (Message) getNextMessage.invoke(queue);
+ } catch (final IllegalAccessException | InvocationTargetException e) {
+ throw new RuntimeException(e.getCause() != null ? e.getCause() : e);
+ }
+ if (msg.getTarget() == handler && msg.obj == handler) {
+ // Our idle signal.
+ break;
+ } else if (msg.getTarget() == null) {
+ looper.quit();
+ break;
+ }
+ msg.getTarget().dispatchMessage(msg);
+
+ if (timeout > 0) {
+ handler.removeCallbacks(timeoutRunnable);
+ queue.addIdleHandler(idleHandler);
+ }
+ }
+ }
+
+ /**
+ * Wait until a page load has finished. The session must have started a page load since
+ * the last wait, or this method will wait indefinitely.
+ */
+ public void waitForPageStop() {
+ waitForPageStops(/* count */ 1);
+ }
+
+ /**
+ * Wait until a page load has finished. The session must have started a page load since
+ * the last wait, or this method will wait indefinitely.
+ *
+ * @param count Number of page loads to wait for.
+ */
+ public void waitForPageStops(final int count) {
+ final Method onPageStop;
+ try {
+ onPageStop = GeckoSession.ProgressListener.class.getMethod(
+ "onPageStop", GeckoSession.class, boolean.class);
+ } catch (final NoSuchMethodException e) {
+ throw new RuntimeException(e);
+ }
+
+ final List<MethodCall> methodCalls = new ArrayList<>(1);
+ methodCalls.add(new MethodCall(onPageStop,
+ new CallRequirement(/* allowed */ true, count, null)));
+
+ waitUntilCalled(GeckoSession.ProgressListener.class, methodCalls);
+ }
+
+ /**
+ * Wait until the specified methods have been called on the specified callback
+ * interface. If no methods are specified, wait until any method has been called.
+ *
+ * @param callback Target callback interface; must be an interface under GeckoSession.
+ * @param methods List of methods to wait on; use empty or null or wait on any method.
+ */
+ public void waitUntilCalled(final @NonNull Class<?> callback,
+ final @Nullable String... methods) {
+ assertTrue("Class should be a GeckoSession interface",
+ CALLBACK_CLASSES.contains(callback));
+
+ final int length = (methods != null) ? methods.length : 0;
+ final Pattern[] patterns = new Pattern[length];
+ for (int i = 0; i < length; i++) {
+ patterns[i] = Pattern.compile(methods[i]);
+ }
+
+ final List<MethodCall> waitMethods = new ArrayList<>();
+
+ for (final Method method : callback.getDeclaredMethods()) {
+ for (final Pattern pattern : patterns) {
+ if (pattern.matcher(method.getName()).matches()) {
+ waitMethods.add(new MethodCall(method));
+ }
+ }
+ }
+
+ waitUntilCalled(callback, waitMethods);
+ }
+
+ /**
+ * Wait until the specified methods have been called on the specified object,
+ * as specified by any {@link AssertCalled @AssertCalled} annotations. If no
+ * {@link AssertCalled @AssertCalled} annotations are found, wait until any method
+ * has been called. Only methods belonging to a GeckoSession callback are supported.
+ *
+ * @param callback Target callback object; must implement an interface under GeckoSession.
+ */
+ public void waitUntilCalled(final @NonNull Object callback) {
+ if (callback instanceof Class<?>) {
+ waitUntilCalled((Class<?>) callback, (String[]) null);
+ return;
+ }
+
+ final List<MethodCall> methodCalls = new ArrayList<>();
+ for (final Class<?> ifce : CALLBACK_CLASSES) {
+ if (!ifce.isInstance(callback)) {
+ continue;
+ }
+ for (final Method method : ifce.getMethods()) {
+ final Method callbackMethod;
+ try {
+ callbackMethod = callback.getClass().getMethod(method.getName(),
+ method.getParameterTypes());
+ } catch (final NoSuchMethodException e) {
+ throw new RuntimeException(e);
+ }
+ final AssertCalled ac = getAssertCalled(callbackMethod, callback);
+ if (ac != null && ac.value()) {
+ methodCalls.add(new MethodCall(callbackMethod, ac));
+ }
+ }
+ }
+
+ waitUntilCalled(callback.getClass(), methodCalls);
+ forCallbacksDuringWait(callback);
+ }
+
+ protected void waitUntilCalled(final @NonNull Class<?> listener,
+ final @NonNull List<MethodCall> methodCalls) {
+ // Make sure all handlers are set though #delegateUntilTestEnd or #delegateDuringNextWait,
+ // instead of through GeckoSession directly, so that we can still record calls even with
+ // custom handlers set.
+ for (final Class<?> ifce : CALLBACK_CLASSES) {
+ try {
+ assertSame("Callbacks should be set through" +
+ " GeckoSessionTestRule delegate methods",
+ mCallbackProxy, getCallbackGetter(ifce).invoke(mSession));
+ } catch (final NoSuchMethodException | IllegalAccessException |
+ InvocationTargetException e) {
+ throw new RuntimeException(e.getCause() != null ? e.getCause() : e);
+ }
+ }
+
+ boolean calledAny = false;
+ int index = mLastWaitStart = mLastWaitEnd;
+
+ while (!calledAny || !methodCalls.isEmpty()) {
+ while (index >= mCallRecords.size()) {
+ loopUntilIdle(mTimeoutMillis);
+ }
+
+ final MethodCall recorded = mCallRecords.get(index).methodCall;
+ calledAny |= recorded.method.getDeclaringClass().isAssignableFrom(listener);
+ index++;
+
+ final int i = methodCalls.indexOf(recorded);
+ if (i < 0) {
+ continue;
+ }
+
+ final MethodCall methodCall = methodCalls.get(i);
+ methodCall.incrementCounter();
+ if (methodCall.allowUnlimitedCalls() || !methodCall.allowMoreCalls()) {
+ methodCalls.remove(i);
+ }
+ }
+
+ mLastWaitEnd = index;
+ }
+
+ /**
+ * Playback callbacks that were made during the previous wait. For any methods
+ * annotated with {@link AssertCalled @AssertCalled}, assert that the callbacks
+ * satisfy the specified requirements. If no {@link AssertCalled @AssertCalled}
+ * annotations are found, assert any method has been called. Only methods belonging
+ * to a GeckoSession callback are supported.
+ *
+ * @param callback Target callback object; must implement one or more interfaces
+ * under GeckoSession.
+ */
+ public void forCallbacksDuringWait(final @NonNull Object callback) {
+ final Method[] declaredMethods = callback.getClass().getDeclaredMethods();
+ final List<MethodCall> methodCalls = new ArrayList<>(declaredMethods.length);
+ for (final Class<?> ifce : CALLBACK_CLASSES) {
+ if (!ifce.isInstance(callback)) {
+ continue;
+ }
+ for (final Method method : ifce.getMethods()) {
+ final Method callbackMethod;
+ try {
+ callbackMethod = callback.getClass().getMethod(method.getName(),
+ method.getParameterTypes());
+ } catch (final NoSuchMethodException e) {
+ throw new RuntimeException(e);
+ }
+ methodCalls.add(new MethodCall(
+ callbackMethod, getAssertCalled(callbackMethod, callback)));
+ }
+ }
+
+ int order = 0;
+ boolean calledAny = false;
+
+ for (int index = mLastWaitStart; index < mLastWaitEnd; index++) {
+ final CallRecord record = mCallRecords.get(index);
+ if (!record.method.getDeclaringClass().isInstance(callback)) {
+ continue;
+ }
+
+ final int i = methodCalls.indexOf(record.methodCall);
+ assertTrue(record.method.getName() + " should be found", i >= 0);
+
+ final MethodCall methodCall = methodCalls.get(i);
+ methodCall.assertAllowMoreCalls();
+ methodCall.incrementCounter();
+ methodCall.assertOrder(order);
+ order = Math.max(methodCall.getOrder(), order);
+
+ try {
+ mCurrentMethodCall = methodCall;
+ record.method.invoke(callback, record.args);
+ } catch (final IllegalAccessException | InvocationTargetException e) {
+ throw new RuntimeException(e.getCause() != null ? e.getCause() : e);
+ } finally {
+ mCurrentMethodCall = null;
+ }
+ calledAny = true;
+ }
+
+ for (final MethodCall methodCall : methodCalls) {
+ methodCall.assertMatchesCount();
+ if (methodCall.requirement != null) {
+ calledAny = true;
+ }
+ }
+
+ assertTrue("Should have called one of " +
+ Arrays.toString(callback.getClass().getInterfaces()), calledAny);
+ }
+
+ /**
+ * Get information about the current call. Only valid during a {@link #forCallbacksDuringWait}
+ * callback.
+ *
+ * @return Call information
+ */
+ public @NonNull CallInfo getCurrentCall() {
+ assertNotNull("Should be in a method call", mCurrentMethodCall);
+ return mCurrentMethodCall.getInfo();
+ }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/Callbacks.kt
@@ -0,0 +1,121 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.geckoview.test.util
+
+import org.mozilla.gecko.GeckoSession
+import org.mozilla.gecko.util.GeckoBundle
+
+class Callbacks private constructor() {
+ object Default : All {
+ }
+
+ interface All : ContentListener, NavigationListener, PermissionDelegate, ProgressListener,
+ PromptDelegate, ScrollListener, TrackingProtectionDelegate {
+ }
+
+ interface ContentListener : GeckoSession.ContentListener {
+ override fun onTitleChange(session: GeckoSession, title: String) {
+ }
+
+ override fun onFocusRequest(session: GeckoSession) {
+ }
+
+ override fun onCloseRequest(session: GeckoSession) {
+ }
+
+ override fun onFullScreen(session: GeckoSession, fullScreen: Boolean) {
+ }
+
+ override fun onContextMenu(session: GeckoSession, screenX: Int, screenY: Int, uri: String, elementSrc: String) {
+ }
+ }
+
+ interface NavigationListener : GeckoSession.NavigationListener {
+ override fun onLocationChange(session: GeckoSession, url: String) {
+ }
+
+ override fun onCanGoBack(session: GeckoSession, canGoBack: Boolean) {
+ }
+
+ override fun onCanGoForward(session: GeckoSession, canGoForward: Boolean) {
+ }
+
+ override fun onLoadUri(session: GeckoSession, uri: String, where: GeckoSession.NavigationListener.TargetWindow): Boolean {
+ return false;
+ }
+
+ override fun onNewSession(session: GeckoSession, uri: String, response: GeckoSession.Response<GeckoSession>) {
+ response.respond(null)
+ }
+ }
+
+ interface PermissionDelegate : GeckoSession.PermissionDelegate {
+ override fun requestAndroidPermissions(session: GeckoSession, permissions: Array<out String>, callback: GeckoSession.PermissionDelegate.Callback) {
+ callback.reject()
+ }
+
+ override fun requestContentPermission(session: GeckoSession, uri: String, type: String, access: String, callback: GeckoSession.PermissionDelegate.Callback) {
+ callback.reject()
+ }
+
+ override fun requestMediaPermission(session: GeckoSession, uri: String, video: Array<out GeckoSession.PermissionDelegate.MediaSource>, audio: Array<out GeckoSession.PermissionDelegate.MediaSource>, callback: GeckoSession.PermissionDelegate.MediaCallback) {
+ callback.reject()
+ }
+ }
+
+ interface ProgressListener : GeckoSession.ProgressListener {
+ override fun onPageStart(session: GeckoSession, url: String) {
+ }
+
+ override fun onPageStop(session: GeckoSession, success: Boolean) {
+ }
+
+ override fun onSecurityChange(session: GeckoSession, securityInfo: GeckoSession.ProgressListener.SecurityInformation) {
+ }
+ }
+
+ interface PromptDelegate : GeckoSession.PromptDelegate {
+ override fun alert(session: GeckoSession, title: String, msg: String, callback: GeckoSession.PromptDelegate.AlertCallback) {
+ callback.dismiss()
+ }
+
+ override fun promptForButton(session: GeckoSession, title: String, msg: String, btnMsg: Array<out String>, callback: GeckoSession.PromptDelegate.ButtonCallback) {
+ callback.dismiss()
+ }
+
+ override fun promptForText(session: GeckoSession, title: String, msg: String, value: String, callback: GeckoSession.PromptDelegate.TextCallback) {
+ callback.dismiss()
+ }
+
+ override fun promptForAuth(session: GeckoSession, title: String, msg: String, options: GeckoSession.PromptDelegate.AuthenticationOptions, callback: GeckoSession.PromptDelegate.AuthCallback) {
+ callback.dismiss()
+ }
+
+ override fun promptForChoice(session: GeckoSession, title: String, msg: String, type: Int, choices: Array<out GeckoSession.PromptDelegate.Choice>, callback: GeckoSession.PromptDelegate.ChoiceCallback) {
+ callback.dismiss()
+ }
+
+ override fun promptForColor(session: GeckoSession, title: String, value: String, callback: GeckoSession.PromptDelegate.TextCallback) {
+ callback.dismiss()
+ }
+
+ override fun promptForDateTime(session: GeckoSession, title: String, type: Int, value: String, min: String, max: String, callback: GeckoSession.PromptDelegate.TextCallback) {
+ callback.dismiss()
+ }
+
+ override fun promptForFile(session: GeckoSession, title: String, type: Int, mimeTypes: Array<out String>, callback: GeckoSession.PromptDelegate.FileCallback) {
+ callback.dismiss()
+ }
+ }
+
+ interface ScrollListener : GeckoSession.ScrollListener {
+ override fun onScrollChanged(session: GeckoSession, scrollX: Int, scrollY: Int) {
+ }
+ }
+
+ interface TrackingProtectionDelegate : GeckoSession.TrackingProtectionDelegate {
+ override fun onTrackerBlocked(session: GeckoSession, uri: String, categories: Int) {
+ }
+ }
+}