-
Notifications
You must be signed in to change notification settings - Fork 22
Expand file tree
/
Copy pathClickWithoutDisplayConstraint.java
More file actions
203 lines (186 loc) · 9.09 KB
/
ClickWithoutDisplayConstraint.java
File metadata and controls
203 lines (186 loc) · 9.09 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
package de.dotwee.micropinner;
import static de.dotwee.micropinner.VisibleViewMatcher.isVisible;
import static org.hamcrest.Matchers.allOf;
import android.util.Log;
import android.view.View;
import android.view.ViewConfiguration;
import android.webkit.WebView;
import android.support.test.espresso.PerformException;
import android.support.test.espresso.UiController;
import android.support.test.espresso.ViewAction;
import android.support.test.espresso.action.CoordinatesProvider;
import android.support.test.espresso.action.PrecisionDescriber;
import android.support.test.espresso.action.Tap;
import android.support.test.espresso.action.Tapper;
import android.support.test.espresso.util.HumanReadables;
import java.util.Locale;
import java.util.Optional;
import org.hamcrest.Matcher;
/**
* Custom click action similar to the GeneralClickAction provided by Espresso.
*
* The only difference is that it does not force the target view to be displayed at least 90% on
* screen (i.e., 90% of the view in sight of the user).
* In this custom class, the only constraint is that the view needs to have "Visible" visibility and
* positive height and width. A typical example is when a long form has a visible view at the
* bottom, but the UI needs to be scrolled to reach it.
*/
public final class ClickWithoutDisplayConstraint implements ViewAction {
private static final String TAG = "ClickWithoutDisplayConstraint";
final CoordinatesProvider coordinatesProvider;
final Tapper tapper;
final PrecisionDescriber precisionDescriber;
private final Optional<ViewAction> rollbackAction;
private final int inputDevice;
private final int buttonState;
@Deprecated
public ClickWithoutDisplayConstraint(
Tapper tapper,
CoordinatesProvider coordinatesProvider,
PrecisionDescriber precisionDescriber) {
this(tapper, coordinatesProvider, precisionDescriber, 0, 0, null);
}
public ClickWithoutDisplayConstraint(
Tapper tapper,
CoordinatesProvider coordinatesProvider,
PrecisionDescriber precisionDescriber,
int inputDevice,
int buttonState) {
this(tapper, coordinatesProvider, precisionDescriber, inputDevice, buttonState, null);
}
@Deprecated
public ClickWithoutDisplayConstraint(
Tapper tapper,
CoordinatesProvider coordinatesProvider,
PrecisionDescriber precisionDescriber,
ViewAction rollbackAction) {
this(tapper, coordinatesProvider, precisionDescriber, 0, 0, rollbackAction);
}
public ClickWithoutDisplayConstraint(
Tapper tapper,
CoordinatesProvider coordinatesProvider,
PrecisionDescriber precisionDescriber,
int inputDevice,
int buttonState,
ViewAction rollbackAction) {
this.coordinatesProvider = coordinatesProvider;
this.tapper = tapper;
this.precisionDescriber = precisionDescriber;
this.inputDevice = inputDevice;
this.buttonState = buttonState;
this.rollbackAction = Optional.ofNullable(rollbackAction);
}
@Override
@SuppressWarnings("unchecked")
public Matcher<View> getConstraints() {
Matcher<View> standardConstraint = isVisible();
if (rollbackAction.isPresent()) {
return allOf(standardConstraint, rollbackAction.get().getConstraints());
} else {
return standardConstraint;
}
}
@Override
public void perform(UiController uiController, View view) {
float[] coordinates = coordinatesProvider.calculateCoordinates(view);
float[] precision = precisionDescriber.describePrecision();
Tapper.Status status = Tapper.Status.FAILURE;
int loopCount = 0;
// Native event injection is quite a tricky process. A tap is actually 2
// seperate motion events which need to get injected into the system. Injection
// makes an RPC call from our app under test to the Android system server, the
// system server decides which window layer to deliver the event to, the system
// server makes an RPC to that window layer, that window layer delivers the event
// to the correct UI element, activity, or window object. Now we need to repeat
// that 2x. for a simple down and up. Oh and the down event triggers timers to
// detect whether or not the event is a long vs. short press. The timers are
// removed the moment the up event is received (NOTE: the possibility of eventTime
// being in the future is totally ignored by most motion event processors).
//
// Phew.
//
// The net result of this is sometimes we'll want to do a regular tap, and for
// whatever reason the up event (last half) of the tap is delivered after long
// press timeout (depending on system load) and the long press behaviour is
// displayed (EG: show a context menu). There is no way to avoid or handle this more
// gracefully. Also the longpress behavour is app/widget specific. So if you have
// a seperate long press behaviour from your short press, you can pass in a
// 'RollBack' ViewAction which when executed will undo the effects of long press.
while (status != Tapper.Status.SUCCESS && loopCount < 3) {
try {
status = tapper.sendTap(uiController, coordinates, precision, inputDevice, buttonState);
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(
TAG,
"perform: "
+ String.format(
Locale.ROOT,
"%s - At Coordinates: %d, %d and precision: %d, %d",
this.getDescription(),
(int) coordinates[0],
(int) coordinates[1],
(int) precision[0],
(int) precision[1]));
}
} catch (RuntimeException re) {
throw new PerformException.Builder()
.withActionDescription(
String.format(
Locale.ROOT,
"%s - At Coordinates: %d, %d and precision: %d, %d",
this.getDescription(),
(int) coordinates[0],
(int) coordinates[1],
(int) precision[0],
(int) precision[1]))
.withViewDescription(HumanReadables.describe(view))
.withCause(re)
.build();
}
int duration = ViewConfiguration.getPressedStateDuration();
// ensures that all work enqueued to process the tap has been run.
if (duration > 0) {
uiController.loopMainThreadForAtLeast(duration);
}
if (status == Tapper.Status.WARNING) {
if (rollbackAction.isPresent()) {
rollbackAction.get().perform(uiController, view);
} else {
break;
}
}
loopCount++;
}
if (status == Tapper.Status.FAILURE) {
throw new PerformException.Builder()
.withActionDescription(this.getDescription())
.withViewDescription(HumanReadables.describe(view))
.withCause(
new RuntimeException(
String.format(
Locale.ROOT,
"Couldn't click at: %s,%s precision: %s, %s . Tapper: %s coordinate"
+ " provider: %s precision describer: %s. Tried %s times. With Rollback?"
+ " %s",
coordinates[0],
coordinates[1],
precision[0],
precision[1],
tapper,
coordinatesProvider,
precisionDescriber,
loopCount,
rollbackAction.isPresent())))
.build();
}
if (tapper == Tap.SINGLE && view instanceof WebView) {
// WebViews will not process click events until double tap
// timeout. Not the best place for this - but good for now.
uiController.loopMainThreadForAtLeast(ViewConfiguration.getDoubleTapTimeout());
}
}
@Override
public String getDescription() {
return tapper.toString().toLowerCase() + " click";
}
}