Ensure we don't receive duplicate/old/non-UP InputEvents for Attribution

In order to align as well as we can with the concept of User Activation
on the web, we only allow recent ACTION_UP events to trigger
App to Web Attribution. I've set the limit to a conservative 10 seconds
as app launching can be very, very slow on low end devices. We also
prevent duplicate Events triggering Attribution by making sure the
event is more recent than the last one received.

Unfortunately, Android has made this API about as difficult to test as
they could, so we can't integration test these in an automated way (we
can't generate VerifiedInputEvents in tests). VerifiedMotionEvent is
also a final class, so we can't mock it for unit tests outside of
robolectric, but we don't have robolectric support for R yet in Chrome.

For now, I've written Manual tests to verify that behaviour is correct
and will follow up when possible with unit tests.

Bug: 1198308
Change-Id: I56d71cf85456b03f830e58304377245517328020
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2893008
Commit-Queue: Michael Thiessen <mthiesse@chromium.org>
Reviewed-by: Ted Choc <tedchoc@chromium.org>
Reviewed-by: Charlie Harrison <csharrison@chromium.org>
Cr-Commit-Position: refs/heads/master@{#882590}
diff --git a/chrome/android/BUILD.gn b/chrome/android/BUILD.gn
index e6284e9d..c025067 100644
--- a/chrome/android/BUILD.gn
+++ b/chrome/android/BUILD.gn
@@ -1169,6 +1169,7 @@
     "//chrome/browser/android/webapps/launchpad:javatests",
     "//chrome/browser/attribution_reporting/android:factory_java",
     "//chrome/browser/attribution_reporting/android:java",
+    "//chrome/browser/attribution_reporting/android/internal:javatests",
     "//chrome/browser/banners/android:java",
     "//chrome/browser/browser_controls/android:java",
     "//chrome/browser/commerce/merchant_viewer/android:javatests",
diff --git a/chrome/browser/attribution_reporting/android/internal/BUILD.gn b/chrome/browser/attribution_reporting/android/internal/BUILD.gn
index 68c1856..9f9c8d71 100644
--- a/chrome/browser/attribution_reporting/android/internal/BUILD.gn
+++ b/chrome/browser/attribution_reporting/android/internal/BUILD.gn
@@ -20,7 +20,11 @@
   bypass_platform_checks = true
   testonly = true
 
-  sources = [ "java/src/org/chromium/chrome/browser/attribution_reporting/AttributionIntentHandlerImplTest.java" ]
+  sources = [
+    "java/src/org/chromium/chrome/browser/attribution_reporting/AttributionIntentHandlerImplTest.java",
+    # TODO(https://crbug.com/1198308): Compile and run this test suite once Robolectric supports R.
+    # "java/src/org/chromium/chrome/browser/attribution_reporting/InputEventValidatorTest.java",
+  ]
 
   deps = [
     ":java",
@@ -34,3 +38,20 @@
     "//third_party/mockito:mockito_java",
   ]
 }
+
+android_library("javatests") {
+  testonly = true
+  sources = [ "java/src/org/chromium/chrome/browser/attribution_reporting/InputEventValidatorManualTest.java" ]
+
+  deps = [
+    ":java",
+    "//base:base_java",
+    "//base:base_java_test_support",
+    "//chrome/browser/flags:java",
+    "//chrome/test/android:chrome_java_test_support",
+    "//content/public/test/android:content_java_test_support",
+    "//third_party/androidx:androidx_test_runner_java",
+    "//third_party/junit",
+    "//ui/android:ui_java_test_support",
+  ]
+}
diff --git a/chrome/browser/attribution_reporting/android/internal/java/src/org/chromium/chrome/browser/attribution_reporting/InputEventValidator.java b/chrome/browser/attribution_reporting/android/internal/java/src/org/chromium/chrome/browser/attribution_reporting/InputEventValidator.java
index 9a1094c..67daa7f2 100644
--- a/chrome/browser/attribution_reporting/android/internal/java/src/org/chromium/chrome/browser/attribution_reporting/InputEventValidator.java
+++ b/chrome/browser/attribution_reporting/android/internal/java/src/org/chromium/chrome/browser/attribution_reporting/InputEventValidator.java
@@ -7,7 +7,13 @@
 import android.content.Context;
 import android.hardware.input.InputManager;
 import android.os.Build;
+import android.os.SystemClock;
 import android.view.InputEvent;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.view.VerifiedInputEvent;
+import android.view.VerifiedKeyEvent;
+import android.view.VerifiedMotionEvent;
 
 import org.chromium.base.ContextUtils;
 import org.chromium.base.Predicate;
@@ -18,6 +24,14 @@
  * on Android R+.
  */
 public class InputEventValidator implements Predicate<InputEvent> {
+    private static final long NANOS_PER_MILLISECOND = 1000000;
+
+    // 10 second expiry time for InputEvents to allow for slow devices having to launch Chrome.
+    /* package */ static final long INPUT_EXPIRY_MILLIS = 10 * 1000;
+
+    // In SystemClock#uptimeMillis time base, in nanos.
+    private long mLastEventDowntime;
+
     @Override
     public boolean test(InputEvent inputEvent) {
         // We cannot verify input events pre-R, so we're making a trade-off of compat vs. security
@@ -27,7 +41,36 @@
         InputManager im = (InputManager) ContextUtils.getApplicationContext().getSystemService(
                 Context.INPUT_SERVICE);
 
-        // TODO(https://crbug.com/1198308): Ensure we aren't being sent duplicate or old Events.
-        return ApiHelperForR.verifyInputEvent(im, inputEvent) != null;
+        VerifiedInputEvent verifiedEvent = ApiHelperForR.verifyInputEvent(im, inputEvent);
+        if (verifiedEvent == null) return false;
+
+        // Per documentation, EventTimeNanos is in the SystemClock.uptimeMillis() time base, just
+        // with nanosecond precision (but likely not nanosecond accuracy).
+        long eventTimeMillis = verifiedEvent.getEventTimeNanos() / NANOS_PER_MILLISECOND;
+        if (SystemClock.uptimeMillis() - eventTimeMillis > INPUT_EXPIRY_MILLIS) return false;
+
+        long eventDownTime;
+        if (verifiedEvent instanceof VerifiedMotionEvent) {
+            VerifiedMotionEvent motionEvent = (VerifiedMotionEvent) verifiedEvent;
+
+            // Only allow ACTION_UP to be more equivalent to User Activitation on the web and
+            // prevent canceled events from triggering attribution.
+            if (motionEvent.getActionMasked() != MotionEvent.ACTION_UP) return false;
+            eventDownTime = motionEvent.getDownTimeNanos();
+        } else if (verifiedEvent instanceof VerifiedKeyEvent) {
+            VerifiedKeyEvent keyEvent = (VerifiedKeyEvent) verifiedEvent;
+            if (keyEvent.getAction() != KeyEvent.ACTION_UP) return false;
+            eventDownTime = keyEvent.getDownTimeNanos();
+        } else {
+            // Not reachable as of API level 30.
+            assert false;
+            return false;
+        }
+
+        // In order to avoid event re-use, make sure that the input sequence/gesture that triggered
+        // this attribution is newer than the last event to have triggered an attribution.
+        if (eventDownTime <= mLastEventDowntime) return false;
+        mLastEventDowntime = eventDownTime;
+        return true;
     }
 }
diff --git a/chrome/browser/attribution_reporting/android/internal/java/src/org/chromium/chrome/browser/attribution_reporting/InputEventValidatorManualTest.java b/chrome/browser/attribution_reporting/android/internal/java/src/org/chromium/chrome/browser/attribution_reporting/InputEventValidatorManualTest.java
new file mode 100644
index 0000000..4559fdf
--- /dev/null
+++ b/chrome/browser/attribution_reporting/android/internal/java/src/org/chromium/chrome/browser/attribution_reporting/InputEventValidatorManualTest.java
@@ -0,0 +1,141 @@
+// Copyright 2021 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package org.chromium.chrome.browser.attribution_reporting;
+
+import android.os.Build;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.widget.TextView;
+
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import org.chromium.base.ThreadUtils;
+import org.chromium.base.test.util.Manual;
+import org.chromium.chrome.test.ChromeJUnit4ClassRunner;
+import org.chromium.ui.test.util.DummyUiActivityTestCase;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * Manual tests for InputEventValidator. VerifiedInputEvents cannot be generated for tests, so
+ * true integration tests have to be manual.
+ *
+ * You can run these tests with:
+ * tools/autotest.py -C out/<dir> InputEventValidatorManualTest -A Manual
+ *
+ * These tests only work on R+.
+ */
+@RunWith(ChromeJUnit4ClassRunner.class)
+public class InputEventValidatorManualTest extends DummyUiActivityTestCase {
+    private String mPackageName = "com.android.packageName";
+    private byte mPackageMac[];
+
+    @Before
+    public void setUp() {
+        // We could use DisableIf, but given these are only run manually, this makes it easier to
+        // realize that the tests don't run pre-R if you run them on the wrong device.
+        assert Build.VERSION.SDK_INT >= Build.VERSION_CODES.R;
+    }
+
+    private void setText(String text) {
+        ThreadUtils.runOnUiThreadBlocking(() -> {
+            TextView textView = new TextView(getActivity());
+            textView.setText(text);
+            getActivity().setContentView(textView);
+        });
+    }
+
+    private MotionEvent waitForMotionEventWithAction(int action) throws Exception {
+        final AtomicReference<MotionEvent> motionEvent = new AtomicReference<MotionEvent>();
+        final CountDownLatch countDownLatch = new CountDownLatch(1);
+        getActivity().setTouchEventCallback((event) -> {
+            if (event.getActionMasked() != action) return;
+            motionEvent.set(event);
+            countDownLatch.countDown();
+        });
+        countDownLatch.await();
+        return motionEvent.get();
+    }
+
+    private KeyEvent waitForKeyEventWithAction(int action) throws Exception {
+        final AtomicReference<KeyEvent> keyEvent = new AtomicReference<KeyEvent>();
+        final CountDownLatch countDownLatch = new CountDownLatch(1);
+        getActivity().setKeyEventCallback((event) -> {
+            if (event.getAction() != action) return;
+            keyEvent.set(event);
+            countDownLatch.countDown();
+        });
+        countDownLatch.await();
+        return keyEvent.get();
+    }
+
+    @Test
+    @Manual
+    public void testValidMotionEvent() throws Exception {
+        setText("Tap the screen to continue.");
+        MotionEvent validEvent = waitForMotionEventWithAction(MotionEvent.ACTION_UP);
+
+        InputEventValidator validator = new InputEventValidator();
+        Assert.assertTrue(validator.test(validEvent));
+        Assert.assertFalse(validator.test(validEvent));
+    }
+
+    @Test
+    @Manual
+    public void testInvalidMotionEvent() throws Exception {
+        setText("Tap the screen to continue.");
+        MotionEvent invalidEvent = waitForMotionEventWithAction(MotionEvent.ACTION_DOWN);
+
+        InputEventValidator validator = new InputEventValidator();
+        Assert.assertFalse(validator.test(invalidEvent));
+    }
+
+    @Test
+    @Manual
+    public void testOldMotionEvent() throws Exception {
+        setText("Tap the screen then wait 10 seconds.");
+        MotionEvent validEvent = waitForMotionEventWithAction(MotionEvent.ACTION_UP);
+
+        Thread.sleep(InputEventValidator.INPUT_EXPIRY_MILLIS + 100);
+        InputEventValidator validator = new InputEventValidator();
+        Assert.assertFalse(validator.test(validEvent));
+    }
+
+    @Test
+    @Manual
+    public void testValidKeyEvent() throws Exception {
+        setText("Press a key to continue.\n eg. adb shell input keyevent 23");
+        KeyEvent validEvent = waitForKeyEventWithAction(KeyEvent.ACTION_UP);
+
+        InputEventValidator validator = new InputEventValidator();
+        Assert.assertTrue(validator.test(validEvent));
+        Assert.assertFalse(validator.test(validEvent));
+    }
+
+    @Test
+    @Manual
+    public void testInvalidKeyEvent() throws Exception {
+        setText("Press a key to continue.\n eg. adb shell input keyevent 23");
+        KeyEvent validEvent = waitForKeyEventWithAction(KeyEvent.ACTION_DOWN);
+
+        InputEventValidator validator = new InputEventValidator();
+        Assert.assertFalse(validator.test(validEvent));
+    }
+
+    @Test
+    @Manual
+    public void testOldKeyEvent() throws Exception {
+        setText("Press a key then wait 10 seconds.\n eg. adb shell input keyevent 23");
+        KeyEvent validEvent = waitForKeyEventWithAction(KeyEvent.ACTION_UP);
+
+        Thread.sleep(InputEventValidator.INPUT_EXPIRY_MILLIS + 100);
+        InputEventValidator validator = new InputEventValidator();
+        Assert.assertFalse(validator.test(validEvent));
+    }
+}
diff --git a/chrome/browser/attribution_reporting/android/internal/java/src/org/chromium/chrome/browser/attribution_reporting/InputEventValidatorTest.java b/chrome/browser/attribution_reporting/android/internal/java/src/org/chromium/chrome/browser/attribution_reporting/InputEventValidatorTest.java
new file mode 100644
index 0000000..86c4c05
--- /dev/null
+++ b/chrome/browser/attribution_reporting/android/internal/java/src/org/chromium/chrome/browser/attribution_reporting/InputEventValidatorTest.java
@@ -0,0 +1,81 @@
+// Copyright 2021 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package org.chromium.chrome.browser.attribution_reporting;
+
+import static org.mockito.ArgumentMatchers.eq;
+
+import android.content.Context;
+import android.hardware.input.InputManager;
+import android.os.Build;
+import android.os.SystemClock;
+import android.view.InputEvent;
+import android.view.VerifiedKeyEvent;
+import android.view.VerifiedMotionEvent;
+
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.mockito.quality.Strictness;
+import org.robolectric.annotation.Config;
+
+import org.chromium.base.ContextUtils;
+import org.chromium.base.test.BaseRobolectricTestRunner;
+
+/**
+ * Unit tests for InputEventValidator.
+ *
+ * These tests are not currently compiled and run because Robolectric doesn't support Android R at
+ * the time of writing.
+ *
+ * TODO(https://crbug.com/1198308): Compile, run, and extend this test suite once we can update
+ * Robolectric to support R.
+ */
+@RunWith(BaseRobolectricTestRunner.class)
+@Config(manifest = Config.NONE, sdk = Build.VERSION_CODES.R)
+public class InputEventValidatorTest {
+    @Rule
+    public MockitoRule mMockitoRule = MockitoJUnit.rule().strictness(Strictness.STRICT_STUBS);
+
+    @Mock
+    private InputEvent mInputEvent;
+
+    @Mock
+    private VerifiedMotionEvent mMotionEvent;
+
+    @Mock
+    private VerifiedKeyEvent mKeyEvent;
+
+    @Mock
+    private InputManager mInputManager;
+
+    @Mock
+    private Context mContext;
+
+    private InputEventValidator mInputEventValidator = new InputEventValidator();
+
+    @Before
+    public void setUp() {
+        // Reset by BaseRobolectricTestRunner.
+        ContextUtils.initApplicationContextForTests(mContext);
+        Mockito.when(mContext.getSystemService(eq(Context.INPUT_SERVICE)))
+                .thenReturn(mInputManager);
+    }
+
+    @Test
+    public void testValidMotionEvent() throws Exception {
+        VerifiedMotionEvent mMotionEvent = Mockito.mock(VerifiedMotionEvent.class);
+        Mockito.when(mInputManager.verifyInputEvent(eq(mInputEvent))).thenReturn(mMotionEvent);
+        Mockito.when(mMotionEvent.getEventTimeNanos()).thenReturn(SystemClock.uptimeMillis());
+        Mockito.when(mMotionEvent.getDownTimeNanos()).thenReturn(SystemClock.uptimeMillis() - 1);
+        Assert.assertTrue(mInputEventValidator.test(mInputEvent));
+        Assert.assertFalse(mInputEventValidator.test(mInputEvent));
+    }
+}
diff --git a/ui/android/javatests/src/org/chromium/ui/test/util/DummyUiActivity.java b/ui/android/javatests/src/org/chromium/ui/test/util/DummyUiActivity.java
index b06fa41..e32f73e 100644
--- a/ui/android/javatests/src/org/chromium/ui/test/util/DummyUiActivity.java
+++ b/ui/android/javatests/src/org/chromium/ui/test/util/DummyUiActivity.java
@@ -5,16 +5,23 @@
 package org.chromium.ui.test.util;
 
 import android.os.Bundle;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
 
 import androidx.annotation.IdRes;
 import androidx.annotation.LayoutRes;
 import androidx.appcompat.app.AppCompatActivity;
 
+import org.chromium.base.Callback;
+
 /** Dummy activity to test UI components without Chrome browser initialization and natives. */
 public class DummyUiActivity extends AppCompatActivity {
     private static int sTestTheme;
     private static int sTestLayout;
 
+    private Callback<MotionEvent> mMotionEventCallback;
+    private Callback<KeyEvent> mKeyEventCallback;
+
     @Override
     public void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
@@ -45,4 +52,32 @@
     public static void setTestLayout(@LayoutRes int layoutResID) {
         sTestLayout = layoutResID;
     }
+
+    @Override
+    public boolean dispatchTouchEvent(MotionEvent event) {
+        if (mMotionEventCallback != null) mMotionEventCallback.onResult(event);
+        return super.dispatchTouchEvent(event);
+    }
+
+    @Override
+    public boolean dispatchKeyEvent(KeyEvent event) {
+        if (mKeyEventCallback != null) mKeyEventCallback.onResult(event);
+        return super.dispatchKeyEvent(event);
+    }
+
+    /**
+     * Registers a callback for getting a stream of touch events prior to being dispatched to the
+     * view tree.
+     */
+    public void setTouchEventCallback(Callback<MotionEvent> callback) {
+        mMotionEventCallback = callback;
+    }
+
+    /**
+     * Registers a callback for getting a stream of key events prior to being dispatched to the
+     * view tree.
+     */
+    public void setKeyEventCallback(Callback<KeyEvent> callback) {
+        mKeyEventCallback = callback;
+    }
 }