-
-
Notifications
You must be signed in to change notification settings - Fork 426
/
SentryGestureListener.java
400 lines (350 loc) · 11.8 KB
/
SentryGestureListener.java
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
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
package io.sentry.android.core.internal.gestures;
import static io.sentry.TypeCheckHint.ANDROID_MOTION_EVENT;
import static io.sentry.TypeCheckHint.ANDROID_VIEW;
import android.app.Activity;
import android.content.res.Resources;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.View;
import android.view.Window;
import io.sentry.Breadcrumb;
import io.sentry.Hint;
import io.sentry.IHub;
import io.sentry.ITransaction;
import io.sentry.Scope;
import io.sentry.SentryLevel;
import io.sentry.SpanStatus;
import io.sentry.TransactionContext;
import io.sentry.TransactionOptions;
import io.sentry.android.core.SentryAndroidOptions;
import io.sentry.protocol.TransactionNameSource;
import java.lang.ref.WeakReference;
import java.util.Collections;
import java.util.Map;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.VisibleForTesting;
@ApiStatus.Internal
public final class SentryGestureListener implements GestureDetector.OnGestureListener {
static final String UI_ACTION = "ui.action";
private final @NotNull WeakReference<Activity> activityRef;
private final @NotNull IHub hub;
private final @NotNull SentryAndroidOptions options;
private final boolean isAndroidXAvailable;
private @Nullable WeakReference<View> activeView = null;
private @Nullable ITransaction activeTransaction = null;
private @Nullable String activeEventType = null;
private final ScrollState scrollState = new ScrollState();
public SentryGestureListener(
final @NotNull Activity currentActivity,
final @NotNull IHub hub,
final @NotNull SentryAndroidOptions options,
final boolean isAndroidXAvailable) {
this.activityRef = new WeakReference<>(currentActivity);
this.hub = hub;
this.options = options;
this.isAndroidXAvailable = isAndroidXAvailable;
}
public void onUp(final @NotNull MotionEvent motionEvent) {
final View decorView = ensureWindowDecorView("onUp");
final View scrollTarget = scrollState.targetRef.get();
if (decorView == null || scrollTarget == null) {
return;
}
if (scrollState.type == null) {
options
.getLogger()
.log(SentryLevel.DEBUG, "Unable to define scroll type. No breadcrumb captured.");
return;
}
final String direction = scrollState.calculateDirection(motionEvent);
addBreadcrumb(
scrollTarget,
scrollState.type,
Collections.singletonMap("direction", direction),
motionEvent);
startTracing(scrollTarget, scrollState.type);
scrollState.reset();
}
@Override
public boolean onDown(final @Nullable MotionEvent motionEvent) {
if (motionEvent == null) {
return false;
}
scrollState.reset();
scrollState.startX = motionEvent.getX();
scrollState.startY = motionEvent.getY();
return false;
}
@Override
public boolean onSingleTapUp(final @Nullable MotionEvent motionEvent) {
final View decorView = ensureWindowDecorView("onSingleTapUp");
if (decorView == null || motionEvent == null) {
return false;
}
@SuppressWarnings("Convert2MethodRef")
final @Nullable View target =
ViewUtils.findTarget(
decorView,
motionEvent.getX(),
motionEvent.getY(),
view -> ViewUtils.isViewTappable(view));
if (target == null) {
options
.getLogger()
.log(SentryLevel.DEBUG, "Unable to find click target. No breadcrumb captured.");
return false;
}
addBreadcrumb(target, "click", Collections.emptyMap(), motionEvent);
startTracing(target, "click");
return false;
}
@Override
public boolean onScroll(
final @Nullable MotionEvent firstEvent,
final @Nullable MotionEvent currentEvent,
final float distX,
final float distY) {
final View decorView = ensureWindowDecorView("onScroll");
if (decorView == null || firstEvent == null) {
return false;
}
if (scrollState.type == null) {
final @Nullable View target =
ViewUtils.findTarget(
decorView,
firstEvent.getX(),
firstEvent.getY(),
new ViewTargetSelector() {
@Override
public boolean select(@NotNull View view) {
return ViewUtils.isViewScrollable(view, isAndroidXAvailable);
}
@Override
public boolean skipChildren() {
return true;
}
});
if (target == null) {
options
.getLogger()
.log(SentryLevel.DEBUG, "Unable to find scroll target. No breadcrumb captured.");
return false;
}
scrollState.setTarget(target);
scrollState.type = "scroll";
}
return false;
}
@Override
public boolean onFling(
final @Nullable MotionEvent motionEvent,
final @Nullable MotionEvent motionEvent1,
final float v,
final float v1) {
scrollState.type = "swipe";
return false;
}
@Override
public void onShowPress(MotionEvent motionEvent) {}
@Override
public void onLongPress(MotionEvent motionEvent) {}
// region utils
private void addBreadcrumb(
final @NotNull View target,
final @NotNull String eventType,
final @NotNull Map<String, Object> additionalData,
final @NotNull MotionEvent motionEvent) {
if ((!options.isEnableUserInteractionBreadcrumbs())) {
return;
}
@NotNull String className;
@Nullable String canonicalName = target.getClass().getCanonicalName();
if (canonicalName != null) {
className = canonicalName;
} else {
className = target.getClass().getSimpleName();
}
final Hint hint = new Hint();
hint.set(ANDROID_MOTION_EVENT, motionEvent);
hint.set(ANDROID_VIEW, target);
hub.addBreadcrumb(
Breadcrumb.userInteraction(
eventType, ViewUtils.getResourceIdWithFallback(target), className, additionalData),
hint);
}
private void startTracing(final @NotNull View target, final @NotNull String eventType) {
if (!(options.isTracingEnabled() && options.isEnableUserInteractionTracing())) {
return;
}
final Activity activity = activityRef.get();
if (activity == null) {
options.getLogger().log(SentryLevel.DEBUG, "Activity is null, no transaction captured.");
return;
}
final String viewId;
try {
viewId = ViewUtils.getResourceId(target);
} catch (Resources.NotFoundException e) {
options
.getLogger()
.log(
SentryLevel.DEBUG,
"View id cannot be retrieved from Resources, no transaction captured.");
return;
}
final View view = (activeView != null) ? activeView.get() : null;
if (activeTransaction != null) {
if (target.equals(view)
&& eventType.equals(activeEventType)
&& !activeTransaction.isFinished()) {
options
.getLogger()
.log(
SentryLevel.DEBUG,
"The view with id: "
+ viewId
+ " already has an ongoing transaction assigned. Rescheduling finish");
final Long idleTimeout = options.getIdleTimeout();
if (idleTimeout != null) {
// reschedule the finish task for the idle transaction, so it keeps running for the same
// view
activeTransaction.scheduleFinish();
}
return;
} else {
// as we allow a single UI transaction running on the bound Scope, we finish the previous
// one, if it's a new view
stopTracing(SpanStatus.OK);
}
}
// we can only bind to the scope if there's no running transaction
final String name = getActivityName(activity) + "." + viewId;
final String op = UI_ACTION + "." + eventType;
final TransactionOptions transactionOptions = new TransactionOptions();
transactionOptions.setWaitForChildren(true);
transactionOptions.setIdleTimeout(options.getIdleTimeout());
transactionOptions.setTrimEnd(true);
final ITransaction transaction =
hub.startTransaction(
new TransactionContext(name, TransactionNameSource.COMPONENT, op), transactionOptions);
hub.configureScope(
scope -> {
applyScope(scope, transaction);
});
activeTransaction = transaction;
activeView = new WeakReference<>(target);
activeEventType = eventType;
}
void stopTracing(final @NotNull SpanStatus status) {
if (activeTransaction != null) {
activeTransaction.finish(status);
}
hub.configureScope(
scope -> {
clearScope(scope);
});
activeTransaction = null;
if (activeView != null) {
activeView.clear();
}
activeEventType = null;
}
@VisibleForTesting
void clearScope(final @NotNull Scope scope) {
scope.withTransaction(
transaction -> {
if (transaction == activeTransaction) {
scope.clearTransaction();
}
});
}
@VisibleForTesting
void applyScope(final @NotNull Scope scope, final @NotNull ITransaction transaction) {
scope.withTransaction(
scopeTransaction -> {
// we'd not like to overwrite existent transactions bound to the Scope manually
if (scopeTransaction == null) {
scope.setTransaction(transaction);
} else {
options
.getLogger()
.log(
SentryLevel.DEBUG,
"Transaction '%s' won't be bound to the Scope since there's one already in there.",
transaction.getName());
}
});
}
private @NotNull String getActivityName(final @NotNull Activity activity) {
return activity.getClass().getSimpleName();
}
private @Nullable View ensureWindowDecorView(final @NotNull String caller) {
final Activity activity = activityRef.get();
if (activity == null) {
options
.getLogger()
.log(SentryLevel.DEBUG, "Activity is null in " + caller + ". No breadcrumb captured.");
return null;
}
final Window window = activity.getWindow();
if (window == null) {
options
.getLogger()
.log(SentryLevel.DEBUG, "Window is null in " + caller + ". No breadcrumb captured.");
return null;
}
final View decorView = window.getDecorView();
if (decorView == null) {
options
.getLogger()
.log(SentryLevel.DEBUG, "DecorView is null in " + caller + ". No breadcrumb captured.");
return null;
}
return decorView;
}
// endregion
// region scroll logic
private static final class ScrollState {
private @Nullable String type = null;
private WeakReference<View> targetRef = new WeakReference<>(null);
private float startX = 0f;
private float startY = 0f;
private void setTarget(final @NotNull View target) {
targetRef = new WeakReference<>(target);
}
/**
* Calculates the direction of the scroll/swipe based on startX and startY and a given event
*
* @param endEvent - the event which notifies when the scroll/swipe ended
* @return String, one of (left|right|up|down)
*/
private @NotNull String calculateDirection(MotionEvent endEvent) {
final float diffX = endEvent.getX() - startX;
final float diffY = endEvent.getY() - startY;
final String direction;
if (Math.abs(diffX) > Math.abs(diffY)) {
if (diffX > 0f) {
direction = "right";
} else {
direction = "left";
}
} else {
if (diffY > 0) {
direction = "down";
} else {
direction = "up";
}
}
return direction;
}
private void reset() {
targetRef.clear();
type = null;
startX = 0f;
startY = 0f;
}
}
// endregion
}