Merge "Initial Compose pointer input benchmarks." into androidx-master-dev
diff --git a/ui/integration-tests/benchmark/build.gradle b/ui/integration-tests/benchmark/build.gradle
index 5e992e1..40add9b 100644
--- a/ui/integration-tests/benchmark/build.gradle
+++ b/ui/integration-tests/benchmark/build.gradle
@@ -41,6 +41,7 @@
     implementation(KOTLIN_REFLECT)
     implementation(ANDROIDX_TEST_RULES)
     implementation(JUNIT)
+    implementation(TRUTH)
 
     androidTestImplementation project(":compose:ui:ui")
     androidTestImplementation project(":compose:foundation:foundation-layout")
diff --git a/ui/integration-tests/benchmark/src/androidTest/AndroidManifest.xml b/ui/integration-tests/benchmark/src/androidTest/AndroidManifest.xml
index 1983f55..d23850c 100644
--- a/ui/integration-tests/benchmark/src/androidTest/AndroidManifest.xml
+++ b/ui/integration-tests/benchmark/src/androidTest/AndroidManifest.xml
@@ -29,5 +29,6 @@
         <!-- enable profileableByShell for non-intrusive profiling tools -->
         <!--suppress AndroidElementNotAllowed -->
         <profileable android:shell="true"/>
+        <activity android:name="androidx.ui.pointerinput.TestActivity" />
     </application>
 </manifest>
diff --git a/ui/integration-tests/benchmark/src/androidTest/java/androidx/ui/pointerinput/AndroidTapIntegrationBenchmark.kt b/ui/integration-tests/benchmark/src/androidTest/java/androidx/ui/pointerinput/AndroidTapIntegrationBenchmark.kt
new file mode 100644
index 0000000..4d84c53
--- /dev/null
+++ b/ui/integration-tests/benchmark/src/androidTest/java/androidx/ui/pointerinput/AndroidTapIntegrationBenchmark.kt
@@ -0,0 +1,205 @@
+/*
+ * Copyright 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ui.pointerinput
+
+import android.content.Context
+import android.view.MotionEvent
+import android.view.MotionEvent.ACTION_DOWN
+import android.view.MotionEvent.ACTION_UP
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewGroup.LayoutParams.MATCH_PARENT
+import android.widget.FrameLayout
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.benchmark.junit4.BenchmarkRule
+import androidx.benchmark.junit4.measureRepeated
+import androidx.test.annotation.UiThreadTest
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import com.google.common.truth.Truth.assertThat
+import org.junit.Assert
+import org.junit.Before
+import org.junit.Ignore
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.util.concurrent.TimeUnit
+
+/**
+ * Benchmark for simply tapping on an item in Android.
+ *
+ * The intent is to measure the speed of all parts necessary for a normal tap starting from
+ * MotionEvents getting dispatched to a particular view. The test therefore includes hit
+ * testing and dispatch.
+ *
+ * This is intended to be an equivalent counterpart to [ComposeTapIntegrationBenchmark].
+ *
+ * The hierarchy is set up to look like:
+ * rootView
+ *   -> LinearLayout
+ *     -> CustomView (with click listener)
+ *       -> TextView
+ *       -> TextView
+ *       -> TextView
+ *       -> ...
+ *
+ * MotionEvents are dispatched to rootView as ACTION_DOWN followed by ACTION_UP.  The validity of
+ * the test is verified in a custom click listener in CustomView with
+ * com.google.common.truth.Truth.assertThat and by counting the clicks in the click listener and
+ * later verifying that they count is sufficiently high.
+ *
+ * The reason a CustomView is used with a custom click listener is that View's normal click
+ * listener is called via a posted Runnable, which is problematic for the benchmark library and
+ * less equivalent to what Compose does anyway.
+ */
+@LargeTest
+@RunWith(AndroidJUnit4::class)
+class AndroidTapIntegrationBenchmark {
+
+    private lateinit var rootView: View
+    private lateinit var expectedLabel: String
+
+    private var actualClickCount = 0
+    private var expectedClickCount = 0
+
+    @get:Rule
+    val benchmarkRule = BenchmarkRule()
+
+    @Suppress("DEPRECATION")
+    @get:Rule
+    val activityTestRule = androidx.test.rule.ActivityTestRule(TestActivity::class.java)
+
+    @Before
+    fun setup() {
+        val activity = activityTestRule.activity
+        Assert.assertTrue(
+            "timed out waiting for activity focus",
+            activity.hasFocusLatch.await(5, TimeUnit.SECONDS)
+        )
+
+        rootView = activity.findViewById<ViewGroup>(android.R.id.content)
+
+        activityTestRule.runOnUiThread {
+
+            val children = (0 until NumItems).map { i ->
+                CustomView(activity).apply {
+                    layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, ItemHeightPx.toInt())
+                    label = "$i"
+                    clickListener = {
+                        assertThat(this.label).isEqualTo(expectedLabel)
+                        actualClickCount++
+                    }
+                }
+            }
+
+            val linearLayout = LinearLayout(activity).apply {
+                orientation = LinearLayout.VERTICAL
+                layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)
+                children.forEach {
+                    addView(it)
+                }
+            }
+
+            activity.setContentView(linearLayout)
+        }
+    }
+
+    // This test requires more hit test processing so changes to hit testing will be tracked more
+    // by this test.
+    @UiThreadTest
+    @Test
+    @Ignore("We don't want this to show up in our benchmark CI tests.")
+    fun clickOnLateItem() {
+        // As items that are laid out last are hit tested first (so z order is respected), item
+        // at 0 will be hit tested late.
+        clickOnItem(0, "0")
+    }
+
+    // This test requires less hit testing so changes to dispatch will be tracked more by this test.
+    @UiThreadTest
+    @Test
+    @Ignore("We don't want this to show up in our benchmark CI tests.")
+    fun clickOnEarlyItem() {
+        // As items that are laid out last are hit tested first (so z order is respected), item
+        // at NumItems - 1 will be hit tested early.
+        val lastItem = NumItems - 1
+        clickOnItem(lastItem, "$lastItem")
+    }
+
+    private fun clickOnItem(item: Int, expectedLabel: String) {
+
+        this.expectedLabel = expectedLabel
+
+        // half height of an item + top of the chosen item = middle of the chosen item
+        val y = (ItemHeightPx / 2) + (item * ItemHeightPx)
+
+        val down = MotionEvent(
+            0,
+            ACTION_DOWN,
+            1,
+            0,
+            arrayOf(PointerProperties(0)),
+            arrayOf(PointerCoords(0f, y)),
+            rootView
+        )
+
+        val up = MotionEvent(
+            10,
+            ACTION_UP,
+            1,
+            0,
+            arrayOf(PointerProperties(0)),
+            arrayOf(PointerCoords(0f, y)),
+            rootView
+        )
+
+        benchmarkRule.measureRepeated {
+            rootView.dispatchTouchEvent(down)
+            rootView.dispatchTouchEvent(up)
+            expectedClickCount++
+        }
+
+        assertThat(actualClickCount).isEqualTo(expectedClickCount)
+    }
+}
+
+private class CustomView(context: Context) : FrameLayout(context) {
+    var label: String
+        get() = textView.text.toString()
+        set(value) {
+            textView.text = value
+        }
+
+    lateinit var clickListener: () -> Unit
+
+    val textView: TextView = TextView(context).apply {
+        layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)
+    }
+
+    init {
+        addView(textView)
+    }
+
+    override fun onTouchEvent(event: MotionEvent?): Boolean {
+        if (event!!.actionMasked == ACTION_UP) {
+            clickListener.invoke()
+        }
+
+        return true
+    }
+}
\ No newline at end of file
diff --git a/ui/integration-tests/benchmark/src/androidTest/java/androidx/ui/pointerinput/ComposeTapIntegrationBenchmark.kt b/ui/integration-tests/benchmark/src/androidTest/java/androidx/ui/pointerinput/ComposeTapIntegrationBenchmark.kt
new file mode 100644
index 0000000..8f592d5
--- /dev/null
+++ b/ui/integration-tests/benchmark/src/androidTest/java/androidx/ui/pointerinput/ComposeTapIntegrationBenchmark.kt
@@ -0,0 +1,187 @@
+/*
+ * Copyright 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ui.pointerinput
+
+import android.view.View
+import android.view.ViewGroup
+import androidx.benchmark.junit4.BenchmarkRule
+import androidx.benchmark.junit4.measureRepeated
+import androidx.compose.foundation.Text
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.DensityAmbient
+import androidx.compose.ui.platform.setContent
+import androidx.compose.ui.unit.dp
+import androidx.test.annotation.UiThreadTest
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import com.google.common.truth.Truth.assertThat
+import org.junit.Assert
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.util.concurrent.TimeUnit
+
+/**
+ * Benchmark for simply tapping on an item in Compose.
+ *
+ * The intent is to measure the speed of all parts necessary for a normal tap starting from
+ * [MotionEvent]s getting dispatched to a particular view.  The test therefore includes hit
+ * testing and dispatch.
+ *
+ * This is intended to be an equivalent counterpart to [AndroidTapIntegrationBenchmark].
+ *
+ * The hierarchy is set up to look like:
+ * rootView
+ *   -> Column
+ *     -> Text (with click listener)
+ *     -> Text (with click listener)
+ *     -> Text (with click listener)
+ *     -> ...
+ *
+ * MotionEvents are dispatched to rootView as ACTION_DOWN followed by ACTION_UP.  The validity of
+ * the test is verified inside the click listener with com.google.common.truth.Truth.assertThat
+ * and by counting the clicks in the click listener and later verifying that they count is
+ * sufficiently high.
+ */
+@LargeTest
+@RunWith(AndroidJUnit4::class)
+class ComposeTapIntegrationBenchmark {
+
+    private lateinit var rootView: View
+    private lateinit var expectedLabel: String
+
+    private var itemHeightDp = 0.dp // Is set to correct value during composition.
+    private var actualClickCount = 0
+    private var expectedClickCount = 0
+
+    @get:Rule
+    val benchmarkRule = BenchmarkRule()
+
+    @Suppress("DEPRECATION")
+    @get:Rule
+    val activityTestRule = androidx.test.rule.ActivityTestRule(TestActivity::class.java)
+
+    @Before
+    fun setup() {
+        val activity = activityTestRule.activity
+        Assert.assertTrue(
+            "timed out waiting for activity focus",
+            activity.hasFocusLatch.await(5, TimeUnit.SECONDS)
+        )
+
+        rootView = activity.findViewById<ViewGroup>(android.R.id.content)
+
+        activityTestRule.runOnUiThreadIR {
+            activity.setContent {
+                with(DensityAmbient.current) {
+                    itemHeightDp = ItemHeightPx.toDp()
+                }
+                App()
+            }
+        }
+    }
+
+    // This test requires more hit test processing so changes to hit testing will be tracked more
+    // by this test.
+    @UiThreadTest
+    @Test
+    fun clickOnLateItem() {
+        // As items that are laid out last are hit tested first (so z order is respected), item
+        // at 0 will be hit tested late.
+        clickOnItem(0, "0")
+    }
+
+    // This test requires less hit testing so changes to dispatch will be tracked more by this test.
+    @UiThreadTest
+    @Test
+    fun clickOnEarlyItemFyi() {
+        // As items that are laid out last are hit tested first (so z order is respected), item
+        // at NumItems - 1 will be hit tested early.
+        val lastItem = NumItems - 1
+        clickOnItem(lastItem, "$lastItem")
+    }
+
+    private fun clickOnItem(item: Int, expectedLabel: String) {
+
+        this.expectedLabel = expectedLabel
+
+        // half height of an item + top of the chosen item = middle of the chosen item
+        val y = (ItemHeightPx / 2) + (item * ItemHeightPx)
+
+        val down = MotionEvent(
+            0,
+            android.view.MotionEvent.ACTION_DOWN,
+            1,
+            0,
+            arrayOf(PointerProperties(0)),
+            arrayOf(PointerCoords(0f, y)),
+            rootView
+        )
+
+        val up = MotionEvent(
+            10,
+            android.view.MotionEvent.ACTION_UP,
+            1,
+            0,
+            arrayOf(PointerProperties(0)),
+            arrayOf(PointerCoords(0f, y)),
+            rootView
+        )
+
+        benchmarkRule.measureRepeated {
+            rootView.dispatchTouchEvent(down)
+            rootView.dispatchTouchEvent(up)
+            expectedClickCount++
+        }
+
+        assertThat(actualClickCount).isEqualTo(expectedClickCount)
+    }
+
+    @Composable
+    fun App() {
+        EmailList(NumItems)
+    }
+
+    @Composable
+    fun EmailList(count: Int) {
+        Column {
+            repeat(count) { i ->
+                Email("$i")
+            }
+        }
+    }
+
+    @Composable
+    fun Email(label: String) {
+        Text(
+            text = label,
+            modifier = Modifier
+                .clickable {
+                    assertThat(label).isEqualTo(expectedLabel)
+                    actualClickCount++
+                }
+                .fillMaxWidth()
+                .height(itemHeightDp)
+        )
+    }
+}
diff --git a/ui/integration-tests/benchmark/src/androidTest/java/androidx/ui/pointerinput/TapIntegrationBenchmarkValues.kt b/ui/integration-tests/benchmark/src/androidTest/java/androidx/ui/pointerinput/TapIntegrationBenchmarkValues.kt
new file mode 100644
index 0000000..903c918
--- /dev/null
+++ b/ui/integration-tests/benchmark/src/androidTest/java/androidx/ui/pointerinput/TapIntegrationBenchmarkValues.kt
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ui.pointerinput
+
+val ItemHeightPx = 1f
+val NumItems = 100
\ No newline at end of file
diff --git a/ui/integration-tests/benchmark/src/androidTest/java/androidx/ui/pointerinput/TestActivity.kt b/ui/integration-tests/benchmark/src/androidTest/java/androidx/ui/pointerinput/TestActivity.kt
new file mode 100644
index 0000000..811b1b8
--- /dev/null
+++ b/ui/integration-tests/benchmark/src/androidTest/java/androidx/ui/pointerinput/TestActivity.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package androidx.ui.pointerinput
+
+import androidx.activity.ComponentActivity
+import java.util.concurrent.CountDownLatch
+
+class TestActivity : ComponentActivity() {
+    var hasFocusLatch = CountDownLatch(1)
+
+    override fun onWindowFocusChanged(hasFocus: Boolean) {
+        super.onWindowFocusChanged(hasFocus)
+        if (hasFocus) {
+            hasFocusLatch.countDown()
+        }
+    }
+}
diff --git a/ui/integration-tests/benchmark/src/androidTest/java/androidx/ui/pointerinput/utils.kt b/ui/integration-tests/benchmark/src/androidTest/java/androidx/ui/pointerinput/utils.kt
new file mode 100644
index 0000000..aeb62bf
--- /dev/null
+++ b/ui/integration-tests/benchmark/src/androidTest/java/androidx/ui/pointerinput/utils.kt
@@ -0,0 +1,94 @@
+/*
+ * Copyright 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.ui.pointerinput
+
+import android.view.MotionEvent
+import android.view.View
+
+// We only need this because IR compiler doesn't like converting lambdas to Runnables
+@Suppress("DEPRECATION")
+internal fun androidx.test.rule.ActivityTestRule<*>.runOnUiThreadIR(block: () -> Unit) {
+    val runnable: Runnable = object : Runnable {
+        override fun run() {
+            block()
+        }
+    }
+    runOnUiThread(runnable)
+}
+
+/**
+ * Creates a simple [MotionEvent].
+ *
+ * @param dispatchTarget The [View] that the [MotionEvent] is going to be dispatched to. This
+ * guarantees that the MotionEvent is created correctly for both Compose (which relies on raw
+ * coordinates being correct) and Android (which requires that local coordinates are correct).
+ */
+internal fun MotionEvent(
+    eventTime: Int,
+    action: Int,
+    numPointers: Int,
+    actionIndex: Int,
+    pointerProperties: Array<MotionEvent.PointerProperties>,
+    pointerCoords: Array<MotionEvent.PointerCoords>,
+    dispatchTarget: View
+): MotionEvent {
+
+    val locationOnScreen = IntArray(2) { 0 }
+    dispatchTarget.getLocationOnScreen(locationOnScreen)
+
+    pointerCoords.forEach {
+        it.x += locationOnScreen[0]
+        it.y += locationOnScreen[1]
+    }
+
+    val motionEvent = MotionEvent.obtain(
+        0,
+        eventTime.toLong(),
+        action + (actionIndex shl MotionEvent.ACTION_POINTER_INDEX_SHIFT),
+        numPointers,
+        pointerProperties,
+        pointerCoords,
+        0,
+        0,
+        0f,
+        0f,
+        0,
+        0,
+        0,
+        0
+    ).apply {
+        offsetLocation(-locationOnScreen[0].toFloat(), -locationOnScreen[1].toFloat())
+    }
+
+    pointerCoords.forEach {
+        it.x -= locationOnScreen[0]
+        it.y -= locationOnScreen[1]
+    }
+
+    return motionEvent
+}
+
+@Suppress("RemoveRedundantQualifierName")
+internal fun PointerProperties(id: Int) =
+    MotionEvent.PointerProperties().apply { this.id = id }
+
+@Suppress("RemoveRedundantQualifierName")
+internal fun PointerCoords(x: Float, y: Float) =
+    MotionEvent.PointerCoords().apply {
+        this.x = x
+        this.y = y
+    }
\ No newline at end of file