1 /*
2  * Copyright 2018 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.testutils
18 
19 import android.content.Context
20 import android.view.MotionEvent
21 import android.view.View
22 import android.view.ViewConfiguration
23 import java.util.ArrayList
24 import kotlin.math.ceil
25 import kotlin.math.floor
26 
27 /**
28  * One [MotionEvent] approximately every 10 milliseconds. We care about this frequency because a
29  * standard touchscreen operates at 100 Hz and therefore produces about one touch event every 10
30  * milliseconds. We want to produce a similar frequency to emulate real world input events.
31  */
32 const val MOTION_EVENT_INTERVAL_MILLIS: Int = 10
33 
34 /**
35  * Distance and time span necessary to produce a fling.
36  *
37  * Distance and time necessary to produce a fling for [MotionEvent]
38  *
39  * @property distance Distance between [MotionEvent]s in pixels for a fling.
40  * @property time Time between [MotionEvent]s in milliseconds for a fling.
41  */
42 data class FlingData(val distance: Float, val time: Int) {
43 
44     /** @property velocity Velocity of fling in pixels per millisecond. */
45     val velocity: Float = distance / time
46 }
47 
48 data class MotionEventData(
49     val eventTimeDelta: Int,
50     val action: Int,
51     val x: Float,
52     val y: Float,
53     val metaState: Int
54 )
55 
56 enum class Direction {
57     UP,
58     DOWN,
59     LEFT,
60     RIGHT
61 }
62 
toMotionEventnull63 fun MotionEventData.toMotionEvent(downTime: Long): MotionEvent =
64     MotionEvent.obtain(
65         downTime,
66         this.eventTimeDelta + downTime,
67         this.action,
68         this.x,
69         this.y,
70         this.metaState
71     )
72 
73 /**
74  * Constructs a [FlingData] from a [Context] and [velocityPixelsPerSecond].
75  *
76  * [velocityPixelsPerSecond] must between [ViewConfiguration.getScaledMinimumFlingVelocity] * 1.1
77  * and [ViewConfiguration.getScaledMaximumFlingVelocity] * .9, inclusive. Losses of precision do not
78  * allow the simulated fling to be super precise.
79  */
80 @JvmOverloads
81 fun generateFlingData(context: Context, velocityPixelsPerSecond: Float? = null): FlingData {
82     val configuration = ViewConfiguration.get(context)
83     val touchSlop = configuration.scaledTouchSlop
84     val minimumVelocity = configuration.scaledMinimumFlingVelocity
85     val maximumVelocity = configuration.scaledMaximumFlingVelocity
86 
87     val targetPixelsPerMilli =
88         if (velocityPixelsPerSecond != null) {
89             if (
90                 velocityPixelsPerSecond < minimumVelocity * 1.1 - .001f ||
91                     velocityPixelsPerSecond > maximumVelocity * .9 + .001f
92             ) {
93                 throw IllegalArgumentException(
94                     "velocityPixelsPerSecond must be between " +
95                         "ViewConfiguration.scaledMinimumFlingVelocity * 1.1 and " +
96                         "ViewConfiguration.scaledMinimumFlingVelocity * .9, inclusive"
97                 )
98             }
99             velocityPixelsPerSecond / 1000f
100         } else {
101             ((maximumVelocity + minimumVelocity) / 2) / 1000f
102         }
103 
104     val targetDistancePixels = touchSlop * 2
105     val targetMillisPassed = floor(targetDistancePixels / targetPixelsPerMilli).toInt()
106 
107     if (targetMillisPassed < 1) {
108         throw IllegalArgumentException("Flings must require some time")
109     }
110 
111     return FlingData(targetDistancePixels.toFloat(), targetMillisPassed)
112 }
113 
114 /** Returns [value] rounded up to the closest [interval] * N, where N is a Integer. */
ceilToIntervalnull115 private fun ceilToInterval(value: Int, interval: Int): Int =
116     ceil(value.toFloat() / interval).toInt() * interval
117 
118 /**
119  * Generates a [List] of [MotionEventData] starting from ([originX], [originY]) that will cause a
120  * fling in the finger direction [Direction].
121  */
122 fun FlingData.generateFlingMotionEventData(
123     originX: Float,
124     originY: Float,
125     fingerDirection: Direction
126 ): List<MotionEventData> {
127 
128     // Ceiling the time and distance to match up with motion event intervals.
129     val time: Int = ceilToInterval(this.time, MOTION_EVENT_INTERVAL_MILLIS)
130     val distance: Float = velocity * time
131 
132     val dx: Float =
133         when (fingerDirection) {
134             Direction.LEFT -> -distance
135             Direction.RIGHT -> distance
136             else -> 0f
137         }
138     val dy: Float =
139         when (fingerDirection) {
140             Direction.UP -> -distance
141             Direction.DOWN -> distance
142             else -> 0f
143         }
144     val toX = originX + dx
145     val toY = originY + dy
146 
147     val numberOfInnerEvents = (time / MOTION_EVENT_INTERVAL_MILLIS) - 1
148     val dxIncrement = dx / (numberOfInnerEvents + 1)
149     val dyIncrement = dy / (numberOfInnerEvents + 1)
150 
151     val motionEventData = ArrayList<MotionEventData>()
152     motionEventData.add(MotionEventData(0, MotionEvent.ACTION_DOWN, originX, originY, 0))
153     for (i in 1..(numberOfInnerEvents)) {
154         val timeDelta = i * MOTION_EVENT_INTERVAL_MILLIS
155         val x = originX + (i * dxIncrement)
156         val y = originY + (i * dyIncrement)
157         motionEventData.add(MotionEventData(timeDelta, MotionEvent.ACTION_MOVE, x, y, 0))
158     }
159     motionEventData.add(MotionEventData(time, MotionEvent.ACTION_MOVE, toX, toY, 0))
160     motionEventData.add(MotionEventData(time, MotionEvent.ACTION_UP, toX, toY, 0))
161 
162     return motionEventData
163 }
164 
165 /**
166  * Dispatches an array of [MotionEvent] to a [View].
167  *
168  * The MotionEvents will start at [downTime] and will be generated from the [motionEventData]. The
169  * MotionEvents will be dispatched synchronously, one after the other, with no gaps of time in
170  * between each [MotionEvent].
171  */
dispatchTouchEventsnull172 fun View.dispatchTouchEvents(downTime: Long, motionEventData: List<MotionEventData>) {
173     for (motionEventDataItem in motionEventData) {
174         dispatchTouchEvent(motionEventDataItem.toMotionEvent(downTime))
175     }
176 }
177 
178 /**
179  * Simulates a fling on a [View].
180  *
181  * Convenience method that calls other public api. See documentation of those functions for more
182  * detail.
183  *
184  * @see [generateFlingData]
185  * @see [generateFlingMotionEventData]
186  * @see [dispatchTouchEvents]
187  */
188 @JvmOverloads
simulateFlingnull189 fun View.simulateFling(
190     downTime: Long,
191     originX: Float,
192     originY: Float,
193     direction: Direction,
194     velocityPixelsPerSecond: Float? = null
195 ) {
196     dispatchTouchEvents(
197         downTime,
198         generateFlingData(context, velocityPixelsPerSecond)
199             .generateFlingMotionEventData(originX, originY, direction)
200     )
201 }
202