chromium/content/public/android/javatests/src/org/chromium/content/browser/JavaBridgeBasicsTest.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.content.browser;

import static org.chromium.base.test.util.ScalableTimeout.scaleTimeout;

import android.webkit.JavascriptInterface;

import androidx.test.InstrumentationRegistry;
import androidx.test.filters.SmallTest;

import org.junit.Assert;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;

import org.chromium.base.test.BaseJUnit4ClassRunner;
import org.chromium.base.test.util.Batch;
import org.chromium.base.test.util.CommandLineFlags;
import org.chromium.base.test.util.Feature;
import org.chromium.content.browser.JavaBridgeActivityTestRule.Controller;
import org.chromium.content_public.browser.LoadUrlParams;
import org.chromium.content_public.browser.test.util.TestCallbackHelperContainer;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.ref.WeakReference;
import java.util.concurrent.CountDownLatch;

/**
 * Part of the test suite for the Java Bridge. Tests a number of features including ... - The type
 * of injected objects - The type of their methods - Replacing objects - Removing objects - Access
 * control - Calling methods on returned objects - Multiply injected objects - Threading -
 * Inheritance
 */
@RunWith(BaseJUnit4ClassRunner.class)
@Batch(JavaBridgeActivityTestRule.BATCH)
public class JavaBridgeBasicsTest {
    @Rule public JavaBridgeActivityTestRule mActivityTestRule = new JavaBridgeActivityTestRule();

    private static class TestController extends Controller {
        private int mIntValue;
        private long mLongValue;
        private String mStringValue;
        private boolean mBooleanValue;

        @JavascriptInterface
        public synchronized void setIntValue(int x) {
            mIntValue = x;
            notifyResultIsReady();
        }

        @JavascriptInterface
        public synchronized void setLongValue(long x) {
            mLongValue = x;
            notifyResultIsReady();
        }

        @JavascriptInterface
        public synchronized void setStringValue(String x) {
            mStringValue = x;
            notifyResultIsReady();
        }

        @JavascriptInterface
        public synchronized void setBooleanValue(boolean x) {
            mBooleanValue = x;
            notifyResultIsReady();
        }

        public synchronized int waitForIntValue() {
            waitForResult();
            return mIntValue;
        }

        public synchronized long waitForLongValue() {
            waitForResult();
            return mLongValue;
        }

        public synchronized String waitForStringValue() {
            waitForResult();
            return mStringValue;
        }

        public synchronized boolean waitForBooleanValue() {
            waitForResult();
            return mBooleanValue;
        }

        public synchronized String getStringValue() {
            return mStringValue;
        }
    }

    private static class ObjectWithStaticMethod {
        @JavascriptInterface
        public static String staticMethod() {
            return "foo";
        }
    }

    TestController mTestController;

    @Before
    public void setUp() {
        mTestController = new TestController();
        mActivityTestRule.injectObjectAndReload(mTestController, "testController");
    }

    // Note that this requires that we can pass a JavaScript string to Java.
    protected String executeJavaScriptAndGetStringResult(String script) throws Throwable {
        mActivityTestRule.executeJavaScript("testController.setStringValue(" + script + ");");
        return mTestController.waitForStringValue();
    }

    // Note that this requires that we can pass a JavaScript boolean to Java.
    private void executeAndSetIfException(String script) throws Throwable {
        mActivityTestRule.executeJavaScript(
                "try {"
                        + script
                        + ";"
                        + "  testController.setBooleanValue(false);"
                        + "} catch (exception) {"
                        + "  testController.setBooleanValue(true);"
                        + "}");
    }

    private void assertRaisesException(String script) throws Throwable {
        executeAndSetIfException(script);
        Assert.assertTrue(mTestController.waitForBooleanValue());
    }

    private void assertNoRaisedException(String script) throws Throwable {
        executeAndSetIfException(script);
        Assert.assertFalse(mTestController.waitForBooleanValue());
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testTypeOfInjectedObject() throws Throwable {
        Assert.assertEquals("object", executeJavaScriptAndGetStringResult("typeof testController"));
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testAdditionNotReflectedUntilReload() throws Throwable {
        Assert.assertEquals("undefined", executeJavaScriptAndGetStringResult("typeof testObject"));
        InstrumentationRegistry.getInstrumentation()
                .runOnMainSync(
                        new Runnable() {
                            @Override
                            public void run() {
                                mActivityTestRule
                                        .getJavascriptInjector()
                                        .addPossiblyUnsafeInterface(
                                                new Object(), "testObject", null);
                            }
                        });
        Assert.assertEquals("undefined", executeJavaScriptAndGetStringResult("typeof testObject"));
        mActivityTestRule.synchronousPageReload();
        Assert.assertEquals("object", executeJavaScriptAndGetStringResult("typeof testObject"));
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testReplaceWithoutReloading() throws Throwable {
        mActivityTestRule.injectObjectAndReload(
                new Object() {
                    @JavascriptInterface
                    public void method() {
                        mTestController.setStringValue("object 1");
                    }
                },
                "testObject");
        mActivityTestRule.executeJavaScript("testObject.method()");
        Assert.assertEquals("object 1", mTestController.waitForStringValue());

        InstrumentationRegistry.getInstrumentation()
                .runOnMainSync(
                        new Runnable() {
                            @Override
                            public void run() {
                                mActivityTestRule
                                        .getJavascriptInjector()
                                        .addPossiblyUnsafeInterface(
                                                new Object() {
                                                    @JavascriptInterface
                                                    public void method() {
                                                        mTestController.setStringValue("object 2");
                                                    }
                                                },
                                                "testObject",
                                                null);
                            }
                        });
        mActivityTestRule.executeJavaScript("testObject.method()");
        // should still return object 1 as the page hasn't reloaded
        Assert.assertEquals("object 1", mTestController.waitForStringValue());
        mActivityTestRule.synchronousPageReload();
        mActivityTestRule.executeJavaScript("testObject.method()");
        Assert.assertEquals("object 2", mTestController.waitForStringValue());
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testRemovalNotReflectedUntilReload() throws Throwable {
        mActivityTestRule.injectObjectAndReload(
                new Object() {
                    @JavascriptInterface
                    public void method() {
                        mTestController.setStringValue("I'm here");
                    }
                },
                "testObject");
        Assert.assertEquals("object", executeJavaScriptAndGetStringResult("typeof testObject"));
        mActivityTestRule.executeJavaScript("testObject.method()");
        Assert.assertEquals("I'm here", mTestController.waitForStringValue());
        InstrumentationRegistry.getInstrumentation()
                .runOnMainSync(
                        new Runnable() {
                            @Override
                            public void run() {
                                mActivityTestRule
                                        .getJavascriptInjector()
                                        .removeInterface("testObject");
                            }
                        });
        // Check that the Java object is being held by the Java bridge, thus it's not
        // collected. Note that despite that what JavaDoc says about invoking "gc()", both Dalvik
        // and ART actually run the collector if called via Runtime.
        Runtime.getRuntime().gc();
        Assert.assertEquals("object", executeJavaScriptAndGetStringResult("typeof testObject"));
        mActivityTestRule.executeJavaScript("testObject.method()");
        Assert.assertEquals("I'm here", mTestController.waitForStringValue());
        mActivityTestRule.synchronousPageReload();
        Assert.assertEquals("undefined", executeJavaScriptAndGetStringResult("typeof testObject"));
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testRemoveObjectNotAdded() throws Throwable {
        TestCallbackHelperContainer.OnPageFinishedHelper onPageFinishedHelper =
                mActivityTestRule.getTestCallBackHelperContainer().getOnPageFinishedHelper();
        int currentCallCount = onPageFinishedHelper.getCallCount();
        InstrumentationRegistry.getInstrumentation()
                .runOnMainSync(
                        new Runnable() {
                            @Override
                            public void run() {
                                mActivityTestRule.getJavascriptInjector().removeInterface("foo");
                                mActivityTestRule
                                        .getWebContents()
                                        .getNavigationController()
                                        .reload(true);
                            }
                        });
        onPageFinishedHelper.waitForCallback(currentCallCount);
        Assert.assertEquals("undefined", executeJavaScriptAndGetStringResult("typeof foo"));
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testTypeOfMethod() throws Throwable {
        Assert.assertEquals(
                "function",
                executeJavaScriptAndGetStringResult("typeof testController.setStringValue"));
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testTypeOfInvalidMethod() throws Throwable {
        Assert.assertEquals(
                "undefined", executeJavaScriptAndGetStringResult("typeof testController.foo"));
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testCallingInvalidMethodRaisesException() throws Throwable {
        assertRaisesException("testController.foo()");
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testUncaughtJavaExceptionRaisesJavaScriptException() throws Throwable {
        mActivityTestRule.injectObjectAndReload(
                new Object() {
                    @JavascriptInterface
                    public void method() {
                        throw new RuntimeException("foo");
                    }
                },
                "testObject");
        assertRaisesException("testObject.method()");
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testCallingAsConstructorRaisesException() throws Throwable {
        assertRaisesException("new testController.setStringValue('foo')");
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testCallingOnNonInjectedObjectRaisesException() throws Throwable {
        assertRaisesException("testController.setStringValue.call({}, 'foo')");
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testCallingOnInstanceOfOtherClassRaisesException() throws Throwable {
        mActivityTestRule.injectObjectAndReload(new Object(), "testObject");
        Assert.assertEquals("object", executeJavaScriptAndGetStringResult("typeof testObject"));
        Assert.assertEquals("object", executeJavaScriptAndGetStringResult("typeof testController"));
        Assert.assertEquals(
                "function",
                executeJavaScriptAndGetStringResult("typeof testController.setStringValue"));
        assertRaisesException("testController.setStringValue.call(testObject, 'foo')");
    }

    // Note that this requires that we can pass a JavaScript string to Java.
    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testTypeOfStaticMethod() throws Throwable {
        mActivityTestRule.injectObjectAndReload(new ObjectWithStaticMethod(), "testObject");
        mActivityTestRule.executeJavaScript(
                "testController.setStringValue(typeof testObject.staticMethod)");
        Assert.assertEquals("function", mTestController.waitForStringValue());
    }

    // Note that this requires that we can pass a JavaScript string to Java.
    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testCallStaticMethod() throws Throwable {
        mActivityTestRule.injectObjectAndReload(new ObjectWithStaticMethod(), "testObject");
        mActivityTestRule.executeJavaScript(
                "testController.setStringValue(testObject.staticMethod())");
        Assert.assertEquals("foo", mTestController.waitForStringValue());
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testPrivateMethodNotExposed() throws Throwable {
        mActivityTestRule.injectObjectAndReload(
                new Object() {
                    private void method() {}

                    protected void method2() {}

                    @JavascriptInterface
                    private void method3() {}

                    @JavascriptInterface
                    protected void method4() {}
                },
                "testObject");
        Assert.assertEquals(
                "undefined", executeJavaScriptAndGetStringResult("typeof testObject.method"));
        Assert.assertEquals(
                "undefined", executeJavaScriptAndGetStringResult("typeof testObject.method2"));
        Assert.assertEquals(
                "undefined", executeJavaScriptAndGetStringResult("typeof testObject.method3"));
        Assert.assertEquals(
                "undefined", executeJavaScriptAndGetStringResult("typeof testObject.method4"));
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testReplaceInjectedObject() throws Throwable {
        mActivityTestRule.injectObjectAndReload(
                new Object() {
                    @JavascriptInterface
                    public void method() {
                        mTestController.setStringValue("object 1");
                    }
                },
                "testObject");
        mActivityTestRule.executeJavaScript("testObject.method()");
        Assert.assertEquals("object 1", mTestController.waitForStringValue());

        mActivityTestRule.injectObjectAndReload(
                new Object() {
                    @JavascriptInterface
                    public void method() {
                        mTestController.setStringValue("object 2");
                    }
                },
                "testObject");
        mActivityTestRule.executeJavaScript("testObject.method()");
        Assert.assertEquals("object 2", mTestController.waitForStringValue());
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testInjectNullObjectIsIgnored() throws Throwable {
        mActivityTestRule.injectObjectAndReload(null, "testObject");
        Assert.assertEquals("undefined", executeJavaScriptAndGetStringResult("typeof testObject"));
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testReplaceInjectedObjectWithNullObjectIsIgnored() throws Throwable {
        mActivityTestRule.injectObjectAndReload(new Object(), "testObject");
        Assert.assertEquals("object", executeJavaScriptAndGetStringResult("typeof testObject"));
        mActivityTestRule.injectObjectAndReload(null, "testObject");
        Assert.assertEquals("object", executeJavaScriptAndGetStringResult("typeof testObject"));
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testCallOverloadedMethodWithDifferentNumberOfArguments() throws Throwable {
        mActivityTestRule.injectObjectAndReload(
                new Object() {
                    @JavascriptInterface
                    public void method() {
                        mTestController.setStringValue("0 args");
                    }

                    @JavascriptInterface
                    public void method(int x) {
                        mTestController.setStringValue("1 arg");
                    }

                    @JavascriptInterface
                    public void method(int x, int y) {
                        mTestController.setStringValue("2 args");
                    }
                },
                "testObject");
        mActivityTestRule.executeJavaScript("testObject.method()");
        Assert.assertEquals("0 args", mTestController.waitForStringValue());
        mActivityTestRule.executeJavaScript("testObject.method(42)");
        Assert.assertEquals("1 arg", mTestController.waitForStringValue());
        mActivityTestRule.executeJavaScript("testObject.method(null)");
        Assert.assertEquals("1 arg", mTestController.waitForStringValue());
        mActivityTestRule.executeJavaScript("testObject.method(undefined)");
        Assert.assertEquals("1 arg", mTestController.waitForStringValue());
        mActivityTestRule.executeJavaScript("testObject.method(42, 42)");
        Assert.assertEquals("2 args", mTestController.waitForStringValue());
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testCallMethodWithWrongNumberOfArgumentsRaisesException() throws Throwable {
        assertRaisesException("testController.setIntValue()");
        assertRaisesException("testController.setIntValue(42, 42)");
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testObjectPersistsAcrossPageLoads() throws Throwable {
        Assert.assertEquals("object", executeJavaScriptAndGetStringResult("typeof testController"));
        mActivityTestRule.synchronousPageReload();
        Assert.assertEquals("object", executeJavaScriptAndGetStringResult("typeof testController"));
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testCustomPropertiesCleanedUpOnPageReloads() throws Throwable {
        Assert.assertEquals("object", executeJavaScriptAndGetStringResult("typeof testController"));
        mActivityTestRule.executeJavaScript("testController.myProperty = 42;");
        Assert.assertEquals("42", executeJavaScriptAndGetStringResult("testController.myProperty"));
        mActivityTestRule.synchronousPageReload();
        Assert.assertEquals("object", executeJavaScriptAndGetStringResult("typeof testController"));
        Assert.assertEquals(
                "undefined", executeJavaScriptAndGetStringResult("testController.myProperty"));
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testSameObjectInjectedMultipleTimes() throws Throwable {
        class TestObject {
            private int mNumMethodInvocations;

            public void method() {
                mTestController.setIntValue(++mNumMethodInvocations);
            }
        }
        final TestObject testObject = new TestObject();
        mActivityTestRule.injectObjectsAndReload(
                testObject, "testObject1", testObject, "testObject2", null);
        mActivityTestRule.executeJavaScript("testObject1.method()");
        Assert.assertEquals(1, mTestController.waitForIntValue());
        mActivityTestRule.executeJavaScript("testObject2.method()");
        Assert.assertEquals(2, mTestController.waitForIntValue());
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testCallMethodOnReturnedObject() throws Throwable {
        mActivityTestRule.injectObjectAndReload(
                new Object() {
                    @JavascriptInterface
                    public Object getInnerObject() {
                        return new Object() {
                            @JavascriptInterface
                            public void method(int x) {
                                mTestController.setIntValue(x);
                            }
                        };
                    }
                },
                "testObject");
        mActivityTestRule.executeJavaScript("testObject.getInnerObject().method(42)");
        Assert.assertEquals(42, mTestController.waitForIntValue());
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testReturnedObjectInjectedElsewhere() throws Throwable {
        class InnerObject {
            private int mNumMethodInvocations;

            @JavascriptInterface
            public void method() {
                mTestController.setIntValue(++mNumMethodInvocations);
            }
        }
        final InnerObject innerObject = new InnerObject();
        final Object object =
                new Object() {
                    @JavascriptInterface
                    public InnerObject getInnerObject() {
                        return innerObject;
                    }
                };
        mActivityTestRule.injectObjectsAndReload(
                object, "testObject", innerObject, "innerObject", null);
        mActivityTestRule.executeJavaScript("testObject.getInnerObject().method()");
        Assert.assertEquals(1, mTestController.waitForIntValue());
        mActivityTestRule.executeJavaScript("innerObject.method()");
        Assert.assertEquals(2, mTestController.waitForIntValue());
    }

    // Verify that Java objects returned from bridge object methods are dereferenced
    // on the Java side once they have been fully dereferenced on the JS side.
    // Failing this test would mean that methods returning objects effectively create a memory
    // leak.
    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    @CommandLineFlags.Add("js-flags=--expose-gc")
    public void testReturnedObjectIsGarbageCollected() throws Throwable {
        Assert.assertEquals("function", executeJavaScriptAndGetStringResult("typeof gc"));
        class InnerObject {}
        class TestObject {
            @JavascriptInterface
            public InnerObject getInnerObject() {
                InnerObject inner = new InnerObject();
                mWeakRefForInner = new WeakReference<InnerObject>(inner);
                return inner;
            }

            // A weak reference is used to check InnerObject instance reachability.
            WeakReference<InnerObject> mWeakRefForInner;
        }
        TestObject object = new TestObject();
        mActivityTestRule.injectObjectAndReload(object, "testObject");
        // Initially, store a reference to the inner object in JS to make sure it's not
        // garbage-collected prematurely.
        Assert.assertEquals(
                "object",
                executeJavaScriptAndGetStringResult(
                        "(function() { globalInner = testObject.getInnerObject(); return typeof"
                                + " globalInner; })()"));
        Assert.assertTrue(object.mWeakRefForInner.get() != null);
        // Check that returned Java object is being held by the Java bridge, thus it's not
        // collected.  Note that despite that what JavaDoc says about invoking "gc()", both Dalvik
        // and ART actually run the collector.
        Runtime.getRuntime().gc();
        Assert.assertTrue(object.mWeakRefForInner.get() != null);
        // Now dereference the inner object in JS and run GC to collect the interface object.
        Assert.assertEquals(
                "true",
                executeJavaScriptAndGetStringResult(
                        """
                        (function() {
                                delete globalInner;
                                gc();
                                return (typeof globalInner == 'undefined');
                         })()"""));
        // Force GC on the Java side again. The bridge had to release the inner object, so it must
        // be collected this time.
        Runtime.getRuntime().gc();
        Assert.assertEquals(null, object.mWeakRefForInner.get());
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testSameReturnedObjectUsesSameWrapper() throws Throwable {
        class InnerObject {}
        final InnerObject innerObject = new InnerObject();
        final Object injectedTestObject =
                new Object() {
                    @JavascriptInterface
                    public InnerObject getInnerObject() {
                        return innerObject;
                    }
                };
        mActivityTestRule.injectObjectAndReload(injectedTestObject, "injectedTestObject");
        mActivityTestRule.executeJavaScript("inner1 = injectedTestObject.getInnerObject()");
        mActivityTestRule.executeJavaScript("inner2 = injectedTestObject.getInnerObject()");
        Assert.assertEquals("object", executeJavaScriptAndGetStringResult("typeof inner1"));
        Assert.assertEquals("object", executeJavaScriptAndGetStringResult("typeof inner2"));
        Assert.assertEquals("true", executeJavaScriptAndGetStringResult("inner1 === inner2"));
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    @CommandLineFlags.Add("js-flags=--expose-gc")
    public void testSameWrapperObjectsAreGarbageCollected() throws Throwable {
        class InnerObject {}
        class TestObject {
            @JavascriptInterface
            public InnerObject getInnerObject() {
                if (mWeakForInnerObject == null) {
                    InnerObject innerObject = new InnerObject();
                    mWeakForInnerObject = new WeakReference<InnerObject>(innerObject);
                    return innerObject;
                }
                return mWeakForInnerObject.get();
            }

            // A weak reference is used to check InnerObject instance reachability.
            WeakReference<InnerObject> mWeakForInnerObject;
        }
        final TestObject injectedTestObject = new TestObject();

        mActivityTestRule.injectObjectAndReload(injectedTestObject, "injectedTestObject");
        mActivityTestRule.executeJavaScript("inner1 = injectedTestObject.getInnerObject()");
        mActivityTestRule.executeJavaScript("inner2 = injectedTestObject.getInnerObject()");
        Assert.assertEquals("object", executeJavaScriptAndGetStringResult("typeof inner1"));
        Assert.assertEquals("object", executeJavaScriptAndGetStringResult("typeof inner2"));

        Assert.assertTrue(injectedTestObject.mWeakForInnerObject.get() != null);
        Runtime.getRuntime().gc();
        Assert.assertTrue(injectedTestObject.mWeakForInnerObject.get() != null);

        // Now dereference the inner object in JS and run GC to collect the interface object.
        Assert.assertEquals(
                "true",
                executeJavaScriptAndGetStringResult(
                        """
                        (function() {
                                delete inner1;
                                delete inner2;
                                gc();
                                return (typeof inner1 == 'undefined');
                        })()"""));
        // Force GC on the Java side again. The bridge had to release the inner object, so it must
        // be collected this time.
        Runtime.getRuntime().gc();
        Assert.assertEquals(null, injectedTestObject.mWeakForInnerObject.get());
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testMethodInvokedOnBackgroundThread() throws Throwable {
        mActivityTestRule.injectObjectAndReload(
                new Object() {
                    @JavascriptInterface
                    public void captureThreadId() {
                        mTestController.setLongValue(Thread.currentThread().getId());
                    }
                },
                "testObject");
        mActivityTestRule.executeJavaScript("testObject.captureThreadId()");
        final long threadId = mTestController.waitForLongValue();
        Assert.assertFalse(threadId == Thread.currentThread().getId());
        InstrumentationRegistry.getInstrumentation()
                .runOnMainSync(
                        new Runnable() {
                            @Override
                            public void run() {
                                Assert.assertFalse(threadId == Thread.currentThread().getId());
                            }
                        });
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testBlockingUiThreadDoesNotBlockCallsFromJs() {
        class TestObject {
            private CountDownLatch mLatch;

            public TestObject() {
                mLatch = new CountDownLatch(1);
            }

            public boolean waitOnTheLatch() throws Exception {
                return mLatch.await(
                        scaleTimeout(10000), java.util.concurrent.TimeUnit.MILLISECONDS);
            }

            @JavascriptInterface
            public void unlockTheLatch() {
                mTestController.setStringValue("unlocked");
                mLatch.countDown();
            }
        }
        final TestObject testObject = new TestObject();
        mActivityTestRule.injectObjectAndReload(testObject, "testObject");
        InstrumentationRegistry.getInstrumentation()
                .runOnMainSync(
                        () -> {
                            // loadUrl is asynchronous, the JS code will start running on the
                            // renderer thread. As soon as we exit loadUrl, the browser UI
                            // thread will be stuck waiting on the latch. If blocking the
                            // browser thread blocks Java Bridge, then the call to
                            // "unlockTheLatch()" will be executed after the
                            // waiting timeout, thus the string value will not yet be updated
                            // by the injected object.
                            mTestController.setStringValue("locked");
                            var js = "javascript:(function() { testObject.unlockTheLatch() })()";
                            mActivityTestRule
                                    .getWebContents()
                                    .getNavigationController()
                                    .loadUrl(new LoadUrlParams(js));
                            try {
                                Assert.assertTrue(testObject.waitOnTheLatch());
                            } catch (Exception e) {
                                android.util.Log.e("JavaBridgeBasicsTest", "Wait exception", e);
                                Assert.fail("Wait exception");
                            }
                            Assert.assertEquals("unlocked", mTestController.getStringValue());
                        });
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testPublicInheritedMethod() throws Throwable {
        class Base {
            @JavascriptInterface
            public void method(int x) {
                mTestController.setIntValue(x);
            }
        }
        class Derived extends Base {}
        mActivityTestRule.injectObjectAndReload(new Derived(), "testObject");
        Assert.assertEquals(
                "function", executeJavaScriptAndGetStringResult("typeof testObject.method"));
        mActivityTestRule.executeJavaScript("testObject.method(42)");
        Assert.assertEquals(42, mTestController.waitForIntValue());
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testPrivateInheritedMethod() throws Throwable {
        class Base {
            @JavascriptInterface
            private void method() {}
        }
        class Derived extends Base {}
        mActivityTestRule.injectObjectAndReload(new Derived(), "testObject");
        Assert.assertEquals(
                "undefined", executeJavaScriptAndGetStringResult("typeof testObject.method"));
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testOverriddenMethod() throws Throwable {
        class Base {
            @JavascriptInterface
            public void method() {
                mTestController.setStringValue("base");
            }
        }
        class Derived extends Base {
            @Override
            @JavascriptInterface
            public void method() {
                mTestController.setStringValue("derived");
            }
        }
        mActivityTestRule.injectObjectAndReload(new Derived(), "testObject");
        mActivityTestRule.executeJavaScript("testObject.method()");
        Assert.assertEquals("derived", mTestController.waitForStringValue());
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testEnumerateMembers() throws Throwable {
        mActivityTestRule.injectObjectAndReload(
                new Object() {
                    public void method() {}

                    private void privateMethod() {}

                    public int field;
                    private int mPrivateField;
                },
                "testObject",
                null);
        mActivityTestRule.executeJavaScript(
                "var result = \"\"; "
                        + "for (x in testObject) { result += \" \" + x } "
                        + "testController.setStringValue(result);");
        Assert.assertEquals(
                " equals getClass hashCode method notify notifyAll toString wait",
                mTestController.waitForStringValue());
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testReflectPublicMethod() throws Throwable {
        mActivityTestRule.injectObjectAndReload(
                new Object() {
                    public Class<?> myGetClass() {
                        return getClass();
                    }

                    public String method() {
                        return "foo";
                    }
                },
                "testObject",
                null);
        Assert.assertEquals(
                "foo",
                executeJavaScriptAndGetStringResult(
                        "testObject.myGetClass().getMethod('method', null).invoke(testObject, null)"
                                + ".toString()"));
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testReflectPublicField() throws Throwable {
        mActivityTestRule.injectObjectAndReload(
                new Object() {
                    public Class<?> myGetClass() {
                        return getClass();
                    }

                    public String field = "foo";
                },
                "testObject",
                null);
        Assert.assertEquals(
                "foo",
                executeJavaScriptAndGetStringResult(
                        "testObject.myGetClass().getField('field').get(testObject).toString()"));
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testReflectPrivateMethodRaisesException() throws Throwable {
        mActivityTestRule.injectObjectAndReload(
                new Object() {
                    public Class<?> myGetClass() {
                        return getClass();
                    }

                    private void method() {}
                    ;
                },
                "testObject",
                null);
        assertRaisesException("testObject.myGetClass().getMethod('method', null)");
        // getDeclaredMethod() is able to get a reference to a private method, but actually invoking
        // the method throws.
        assertRaisesException(
                "testObject.myGetClass().getDeclaredMethod('method', null)."
                        + "invoke(testObject, null)");
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testReflectPrivateFieldRaisesException() throws Throwable {
        mActivityTestRule.injectObjectAndReload(
                new Object() {
                    @JavascriptInterface
                    public Class<?> myGetClass() {
                        return getClass();
                    }

                    private int mField;
                },
                "testObject",
                null);
        String fieldName = "mField";
        assertRaisesException("testObject.myGetClass().getField('" + fieldName + "')");
        // getDeclaredField() is able to get a reference to a private field, but actually retrieving
        // the value of the field throws.
        assertNoRaisedException("testObject.myGetClass().getDeclaredField('" + fieldName + "')");
        assertRaisesException(
                "testObject.myGetClass().getDeclaredField('" + fieldName + "').getInt(testObject)");
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testAllowNonAnnotatedMethods() throws Throwable {
        mActivityTestRule.injectObjectAndReload(
                new Object() {
                    @JavascriptInterface
                    public String allowed() {
                        return "foo";
                    }
                },
                "testObject",
                null);

        // Test calling a method of an explicitly inherited class (Base#allowed()).
        Assert.assertEquals("foo", executeJavaScriptAndGetStringResult("testObject.allowed()"));

        // Test calling a method of an implicitly inherited class (Object#toString()).
        Assert.assertEquals(
                "string", executeJavaScriptAndGetStringResult("typeof testObject.toString()"));
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testAllowOnlyAnnotatedMethods() throws Throwable {
        mActivityTestRule.injectObjectAndReload(
                new Object() {
                    @JavascriptInterface
                    public String allowed() {
                        return "foo";
                    }

                    public String disallowed() {
                        return "bar";
                    }
                },
                "testObject",
                JavascriptInterface.class);

        // getClass() is an Object method and does not have the @JavascriptInterface annotation and
        // should not be able to be called.
        assertRaisesException("testObject.getClass()");
        Assert.assertEquals(
                "undefined", executeJavaScriptAndGetStringResult("typeof testObject.getClass"));

        // allowed() is marked with the @JavascriptInterface annotation and should be allowed to be
        // called.
        Assert.assertEquals("foo", executeJavaScriptAndGetStringResult("testObject.allowed()"));

        // disallowed() is not marked with the @JavascriptInterface annotation and should not be
        // able to be called.
        assertRaisesException("testObject.disallowed()");
        Assert.assertEquals(
                "undefined", executeJavaScriptAndGetStringResult("typeof testObject.disallowed"));
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testAnnotationRequirementRetainsPropertyAcrossObjects() throws Throwable {
        class Test {
            @JavascriptInterface
            public String safe() {
                return "foo";
            }

            public String unsafe() {
                return "bar";
            }
        }

        class TestReturner {
            @JavascriptInterface
            public Test getTest() {
                return new Test();
            }
        }

        // First test with safe mode off.
        mActivityTestRule.injectObjectAndReload(new TestReturner(), "unsafeTestObject", null);

        // safe() should be able to be called regardless of whether or not we are in safe mode.
        Assert.assertEquals(
                "foo", executeJavaScriptAndGetStringResult("unsafeTestObject.getTest().safe()"));
        // unsafe() should be able to be called because we are not in safe mode.
        Assert.assertEquals(
                "bar", executeJavaScriptAndGetStringResult("unsafeTestObject.getTest().unsafe()"));

        // Now test with safe mode on.
        mActivityTestRule.injectObjectAndReload(
                new TestReturner(), "safeTestObject", JavascriptInterface.class);

        // safe() should be able to be called regardless of whether or not we are in safe mode.
        Assert.assertEquals(
                "foo", executeJavaScriptAndGetStringResult("safeTestObject.getTest().safe()"));
        // unsafe() should not be able to be called because we are in safe mode.
        assertRaisesException("safeTestObject.getTest().unsafe()");
        Assert.assertEquals(
                "undefined",
                executeJavaScriptAndGetStringResult("typeof safeTestObject.getTest().unsafe"));
        // getClass() is an Object method and does not have the @JavascriptInterface annotation and
        // should not be able to be called.
        assertRaisesException("safeTestObject.getTest().getClass()");
        Assert.assertEquals(
                "undefined",
                executeJavaScriptAndGetStringResult("typeof safeTestObject.getTest().getClass"));
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testAnnotationDoesNotGetInherited() throws Throwable {
        class Base {
            @JavascriptInterface
            public void base() {}
        }

        class Child extends Base {
            @Override
            public void base() {}
        }

        mActivityTestRule.injectObjectAndReload(
                new Child(), "testObject", JavascriptInterface.class);

        // base() is inherited.  The inherited method does not have the @JavascriptInterface
        // annotation and should not be able to be called.
        assertRaisesException("testObject.base()");
        Assert.assertEquals(
                "undefined", executeJavaScriptAndGetStringResult("typeof testObject.base"));
    }

    @SuppressWarnings("javadoc")
    @Retention(RetentionPolicy.RUNTIME)
    @Target({ElementType.METHOD})
    @interface TestAnnotation {}

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testCustomAnnotationRestriction() throws Throwable {
        class Test {
            @TestAnnotation
            public String checkTestAnnotationFoo() {
                return "bar";
            }

            @JavascriptInterface
            public String checkJavascriptInterfaceFoo() {
                return "bar";
            }
        }

        // Inject javascriptInterfaceObj and require the JavascriptInterface annotation.
        mActivityTestRule.injectObjectAndReload(
                new Test(), "javascriptInterfaceObj", JavascriptInterface.class);

        // Test#testAnnotationFoo() should fail, as it isn't annotated with JavascriptInterface.
        assertRaisesException("javascriptInterfaceObj.checkTestAnnotationFoo()");
        Assert.assertEquals(
                "undefined",
                executeJavaScriptAndGetStringResult(
                        "typeof javascriptInterfaceObj.checkTestAnnotationFoo"));

        // Test#javascriptInterfaceFoo() should pass, as it is annotated with JavascriptInterface.
        Assert.assertEquals(
                "bar",
                executeJavaScriptAndGetStringResult(
                        "javascriptInterfaceObj.checkJavascriptInterfaceFoo()"));

        // Inject testAnnotationObj and require the TestAnnotation annotation.
        mActivityTestRule.injectObjectAndReload(
                new Test(), "testAnnotationObj", TestAnnotation.class);

        // Test#testAnnotationFoo() should pass, as it is annotated with TestAnnotation.
        Assert.assertEquals(
                "bar",
                executeJavaScriptAndGetStringResult("testAnnotationObj.checkTestAnnotationFoo()"));

        // Test#javascriptInterfaceFoo() should fail, as it isn't annotated with TestAnnotation.
        assertRaisesException("testAnnotationObj.checkJavascriptInterfaceFoo()");
        Assert.assertEquals(
                "undefined",
                executeJavaScriptAndGetStringResult(
                        "typeof testAnnotationObj.checkJavascriptInterfaceFoo"));
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testObjectsInspection() throws Throwable {
        class Test {
            @JavascriptInterface
            public String m1() {
                return "foo";
            }

            @JavascriptInterface
            public String m2() {
                return "bar";
            }

            @JavascriptInterface
            public String m2(int x) {
                return "bar " + x;
            }
        }

        final String jsObjectKeysTestTemplate = "Object.keys(%s).toString()";
        final String jsForInTestTemplate =
                "(function(){"
                        + "  var s=[]; for(var m in %s) s.push(m); return s.join(\",\")"
                        + "})()";
        final String inspectableObjectName = "testObj1";
        final String nonInspectableObjectName = "testObj2";

        // Inspection is enabled by default.
        mActivityTestRule.injectObjectAndReload(
                new Test(), inspectableObjectName, JavascriptInterface.class);

        Assert.assertEquals(
                "m1,m2",
                executeJavaScriptAndGetStringResult(
                        String.format(jsObjectKeysTestTemplate, inspectableObjectName)));
        Assert.assertEquals(
                "m1,m2",
                executeJavaScriptAndGetStringResult(
                        String.format(jsForInTestTemplate, inspectableObjectName)));

        InstrumentationRegistry.getInstrumentation()
                .runOnMainSync(
                        new Runnable() {
                            @Override
                            public void run() {
                                mActivityTestRule.getJavascriptInjector().setAllowInspection(false);
                            }
                        });

        mActivityTestRule.injectObjectAndReload(
                new Test(), nonInspectableObjectName, JavascriptInterface.class);

        Assert.assertEquals(
                "",
                executeJavaScriptAndGetStringResult(
                        String.format(jsObjectKeysTestTemplate, nonInspectableObjectName)));
        Assert.assertEquals(
                "",
                executeJavaScriptAndGetStringResult(
                        String.format(jsForInTestTemplate, nonInspectableObjectName)));
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testAccessToObjectGetClassIsBlocked() throws Throwable {
        mActivityTestRule.injectObjectAndReload(new Object(), "testObject", null);
        Assert.assertEquals(
                "function", executeJavaScriptAndGetStringResult("typeof testObject.getClass"));
        assertRaisesException("testObject.getClass()");
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testReplaceJavascriptInterface() throws Throwable {
        class Test {
            public Test(int value) {
                mValue = value;
            }

            @JavascriptInterface
            public int getValue() {
                return mValue;
            }

            private int mValue;
        }
        mActivityTestRule.injectObjectAndReload(new Test(13), "testObject");
        Assert.assertEquals("13", executeJavaScriptAndGetStringResult("testObject.getValue()"));
        // The documentation doesn't specify, what happens if the embedder is trying
        // to inject a different object under the same name. The current implementation
        // simply replaces the old object with the new one.
        mActivityTestRule.injectObjectAndReload(new Test(42), "testObject");
        Assert.assertEquals("42", executeJavaScriptAndGetStringResult("testObject.getValue()"));
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testMethodCalledOnAnotherInstance() throws Throwable {
        class TestObject {
            private int mIndex;

            TestObject(int index) {
                mIndex = index;
            }

            @JavascriptInterface
            public void method() {
                mTestController.setIntValue(mIndex);
            }
        }
        final TestObject testObject1 = new TestObject(1);
        final TestObject testObject2 = new TestObject(2);
        mActivityTestRule.injectObjectsAndReload(
                testObject1, "testObject1", testObject2, "testObject2", null);
        mActivityTestRule.executeJavaScript("testObject1.method()");
        Assert.assertEquals(1, mTestController.waitForIntValue());
        mActivityTestRule.executeJavaScript("testObject2.method()");
        Assert.assertEquals(2, mTestController.waitForIntValue());
        mActivityTestRule.executeJavaScript("testObject1.method.call(testObject2)");
        Assert.assertEquals(2, mTestController.waitForIntValue());
        mActivityTestRule.executeJavaScript("testObject2.method.call(testObject1)");
        Assert.assertEquals(1, mTestController.waitForIntValue());
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView", "Android-JavaBridge"})
    public void testWebViewAfterRenderViewSwapped() throws Throwable {
        class TestObject {
            private int mIndex;

            TestObject(int index) {
                mIndex = index;
            }

            @JavascriptInterface
            public void method() {
                mTestController.setIntValue(mIndex);
            }
        }
        final TestObject testObject = new TestObject(1);
        mActivityTestRule.injectObjectAndReload(testObject, "testObject");

        // This needs renderer swap but not end up in an error page.
        mActivityTestRule.loadUrl(
                mActivityTestRule.getWebContents().getNavigationController(),
                mActivityTestRule.getTestCallBackHelperContainer(),
                new LoadUrlParams("chrome://process-internals"));

        mActivityTestRule.handleBlockingCallbackAction(
                mActivityTestRule.getTestCallBackHelperContainer().getOnPageFinishedHelper(),
                new Runnable() {
                    @Override
                    public void run() {
                        mActivityTestRule.getWebContents().getNavigationController().goBack();
                    }
                });

        mActivityTestRule.executeJavaScript("testObject.method()");
        Assert.assertEquals(1, mTestController.waitForIntValue());
    }
}