1 /*
2  * Copyright (C) 2016 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 com.android.incallui.answer.impl.answermethod;
18 
19 import android.animation.Animator;
20 import android.animation.AnimatorListenerAdapter;
21 import android.animation.ValueAnimator;
22 import android.animation.ValueAnimator.AnimatorUpdateListener;
23 import android.annotation.SuppressLint;
24 import android.content.Context;
25 import android.support.annotation.FloatRange;
26 import android.support.annotation.IntDef;
27 import android.support.annotation.NonNull;
28 import android.support.annotation.Nullable;
29 import android.view.MotionEvent;
30 import android.view.VelocityTracker;
31 import android.view.View;
32 import android.view.View.OnTouchListener;
33 import android.view.ViewConfiguration;
34 import com.android.dialer.common.DpUtil;
35 import com.android.dialer.common.LogUtil;
36 import com.android.dialer.common.MathUtil;
37 import com.android.incallui.answer.impl.classifier.FalsingManager;
38 import com.android.incallui.answer.impl.utils.FlingAnimationUtils;
39 import java.lang.annotation.Retention;
40 import java.lang.annotation.RetentionPolicy;
41 
42 /** Touch handler that keeps track of flings for {@link FlingUpDownMethod}. */
43 @SuppressLint("ClickableViewAccessibility")
44 class FlingUpDownTouchHandler implements OnTouchListener {
45 
46   /** Callback interface for significant events with this touch handler */
47   interface OnProgressChangedListener {
48 
49     /**
50      * Called when the visible answer progress has changed. Implementations should use this for
51      * animation, but should not perform accepts or rejects until {@link #onMoveFinish(boolean)} is
52      * called.
53      *
54      * @param progress float representation of the progress with +1f fully accepted, -1f fully
55      *     rejected, and 0 neutral.
56      */
onProgressChanged(@loatRangefrom = -1f, to = 1f) float progress)57     void onProgressChanged(@FloatRange(from = -1f, to = 1f) float progress);
58 
59     /** Called when a touch event has started being tracked. */
onTrackingStart()60     void onTrackingStart();
61 
62     /** Called when touch events stop being tracked. */
onTrackingStopped()63     void onTrackingStopped();
64 
65     /**
66      * Called when the progress has fully animated back to neutral. Normal resting animation should
67      * resume, possibly with a hint animation first.
68      *
69      * @param showHint {@code true} iff the hint animation should be run before resuming normal
70      *     animation.
71      */
onMoveReset(boolean showHint)72     void onMoveReset(boolean showHint);
73 
74     /**
75      * Called when the progress has animated fully to accept or reject.
76      *
77      * @param accept {@code true} if the call has been accepted, {@code false} if it has been
78      *     rejected.
79      */
onMoveFinish(boolean accept)80     void onMoveFinish(boolean accept);
81 
82     /**
83      * Determine whether this gesture should use the {@link FalsingManager} to reject accidental
84      * touches
85      *
86      * @param downEvent the MotionEvent corresponding to the start of the gesture
87      * @return {@code true} if the {@link FalsingManager} should be used to reject accidental
88      *     touches for this gesture
89      */
shouldUseFalsing(@onNull MotionEvent downEvent)90     boolean shouldUseFalsing(@NonNull MotionEvent downEvent);
91   }
92 
93   // Progress that must be moved through to not show the hint animation after gesture completes
94   private static final float HINT_MOVE_THRESHOLD_RATIO = .1f;
95   // Dp touch needs to move upward to be considered fully accepted
96   private static final int ACCEPT_THRESHOLD_DP = 150;
97   // Dp touch needs to move downward to be considered fully rejected
98   private static final int REJECT_THRESHOLD_DP = 150;
99   // Dp touch needs to move for it to not be considered a false touch (if FalsingManager is not
100   // enabled)
101   private static final int FALSING_THRESHOLD_DP = 40;
102 
103   // Progress at which a fling in the opposite direction will recenter instead of
104   // accepting/rejecting
105   private static final float PROGRESS_FLING_RECENTER = .1f;
106 
107   // Progress at which a slow swipe would continue toward accept/reject after the
108   // touch has been let go, otherwise will recenter
109   private static final float PROGRESS_SWIPE_RECENTER = .8f;
110 
111   private static final float REJECT_FLING_THRESHOLD_MODIFIER = 2f;
112 
113   @Retention(RetentionPolicy.SOURCE)
114   @IntDef({FlingTarget.CENTER, FlingTarget.ACCEPT, FlingTarget.REJECT})
115   private @interface FlingTarget {
116     int CENTER = 0;
117     int ACCEPT = 1;
118     int REJECT = -1;
119   }
120 
121   /**
122    * Create a new FlingUpDownTouchHandler and attach it to the target. Will call {@link
123    * View#setOnTouchListener(OnTouchListener)} before returning.
124    *
125    * @param target View whose touches are to be listened to
126    * @param listener Callback to listen to major events
127    * @param falsingManager FalsingManager to identify false touches
128    * @return the instance of FlingUpDownTouchHandler that has been added as a touch listener
129    */
attach( @onNull View target, @NonNull OnProgressChangedListener listener, @Nullable FalsingManager falsingManager)130   public static FlingUpDownTouchHandler attach(
131       @NonNull View target,
132       @NonNull OnProgressChangedListener listener,
133       @Nullable FalsingManager falsingManager) {
134     FlingUpDownTouchHandler handler = new FlingUpDownTouchHandler(target, listener, falsingManager);
135     target.setOnTouchListener(handler);
136     return handler;
137   }
138 
139   @NonNull private final View target;
140   @NonNull private final OnProgressChangedListener listener;
141 
142   private VelocityTracker velocityTracker;
143   private FlingAnimationUtils flingAnimationUtils;
144 
145   private boolean touchEnabled = true;
146   private boolean flingEnabled = true;
147   private float currentProgress;
148   private boolean tracking;
149 
150   private boolean motionAborted;
151   private boolean touchSlopExceeded;
152   private boolean hintDistanceExceeded;
153   private int trackingPointer;
154   private Animator progressAnimator;
155 
156   private float touchSlop;
157   private float initialTouchY;
158   private float acceptThresholdY;
159   private float rejectThresholdY;
160   private float zeroY;
161 
162   private boolean touchAboveFalsingThreshold;
163   private float falsingThresholdPx;
164   private boolean touchUsesFalsing;
165 
166   private final float acceptThresholdPx;
167   private final float rejectThresholdPx;
168   private final float deadZoneTopPx;
169 
170   @Nullable private final FalsingManager falsingManager;
171 
FlingUpDownTouchHandler( @onNull View target, @NonNull OnProgressChangedListener listener, @Nullable FalsingManager falsingManager)172   private FlingUpDownTouchHandler(
173       @NonNull View target,
174       @NonNull OnProgressChangedListener listener,
175       @Nullable FalsingManager falsingManager) {
176     this.target = target;
177     this.listener = listener;
178     Context context = target.getContext();
179     touchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
180     flingAnimationUtils = new FlingAnimationUtils(context, .6f);
181     falsingThresholdPx = DpUtil.dpToPx(context, FALSING_THRESHOLD_DP);
182     acceptThresholdPx = DpUtil.dpToPx(context, ACCEPT_THRESHOLD_DP);
183     rejectThresholdPx = DpUtil.dpToPx(context, REJECT_THRESHOLD_DP);
184 
185     deadZoneTopPx =
186         Math.max(
187             context.getResources().getDimension(R.dimen.answer_swipe_dead_zone_top),
188             acceptThresholdPx);
189     this.falsingManager = falsingManager;
190   }
191 
192   /** Returns {@code true} iff a touch is being tracked */
isTracking()193   public boolean isTracking() {
194     return tracking;
195   }
196 
197   /**
198    * Sets whether touch events will continue to be listened to
199    *
200    * @param touchEnabled whether future touch events will be listened to
201    */
setTouchEnabled(boolean touchEnabled)202   public void setTouchEnabled(boolean touchEnabled) {
203     this.touchEnabled = touchEnabled;
204   }
205 
206   /**
207    * Sets whether fling velocity is used to affect accept/reject behavior
208    *
209    * @param flingEnabled whether fling velocity will be used when determining whether to
210    *     accept/reject or recenter
211    */
setFlingEnabled(boolean flingEnabled)212   public void setFlingEnabled(boolean flingEnabled) {
213     this.flingEnabled = flingEnabled;
214   }
215 
detach()216   public void detach() {
217     cancelProgressAnimator();
218     setTouchEnabled(false);
219   }
220 
221   @Override
onTouch(View v, MotionEvent event)222   public boolean onTouch(View v, MotionEvent event) {
223     if (falsingManager != null) {
224       falsingManager.onTouchEvent(event);
225     }
226     if (!touchEnabled) {
227       return false;
228     }
229     if (motionAborted && (event.getActionMasked() != MotionEvent.ACTION_DOWN)) {
230       return false;
231     }
232 
233     int pointerIndex = event.findPointerIndex(trackingPointer);
234     if (pointerIndex < 0) {
235       pointerIndex = 0;
236       trackingPointer = event.getPointerId(pointerIndex);
237     }
238     final float pointerY = event.getY(pointerIndex);
239 
240     switch (event.getActionMasked()) {
241       case MotionEvent.ACTION_DOWN:
242         if (pointerY < deadZoneTopPx) {
243           return false;
244         }
245         motionAborted = false;
246         startMotion(pointerY, false, currentProgress);
247         touchAboveFalsingThreshold = false;
248         touchUsesFalsing = listener.shouldUseFalsing(event);
249         if (velocityTracker == null) {
250           initVelocityTracker();
251         }
252         trackMovement(event);
253         cancelProgressAnimator();
254         touchSlopExceeded = progressAnimator != null;
255         onTrackingStarted();
256         break;
257       case MotionEvent.ACTION_POINTER_UP:
258         final int upPointer = event.getPointerId(event.getActionIndex());
259         if (trackingPointer == upPointer) {
260           // gesture is ongoing, find a new pointer to track
261           int newIndex = event.getPointerId(0) != upPointer ? 0 : 1;
262           float newY = event.getY(newIndex);
263           trackingPointer = event.getPointerId(newIndex);
264           startMotion(newY, true, currentProgress);
265         }
266         break;
267       case MotionEvent.ACTION_POINTER_DOWN:
268         motionAborted = true;
269         endMotionEvent(event, pointerY, true);
270         return false;
271       case MotionEvent.ACTION_MOVE:
272         float deltaY = pointerY - initialTouchY;
273 
274         if (Math.abs(deltaY) > touchSlop) {
275           touchSlopExceeded = true;
276         }
277         if (Math.abs(deltaY) >= falsingThresholdPx) {
278           touchAboveFalsingThreshold = true;
279         }
280         setCurrentProgress(pointerYToProgress(pointerY));
281         trackMovement(event);
282         break;
283 
284       case MotionEvent.ACTION_UP:
285       case MotionEvent.ACTION_CANCEL:
286         trackMovement(event);
287         endMotionEvent(event, pointerY, false);
288     }
289     return true;
290   }
291 
endMotionEvent(MotionEvent event, float pointerY, boolean forceCancel)292   private void endMotionEvent(MotionEvent event, float pointerY, boolean forceCancel) {
293     trackingPointer = -1;
294     if ((tracking && touchSlopExceeded)
295         || Math.abs(pointerY - initialTouchY) > touchSlop
296         || event.getActionMasked() == MotionEvent.ACTION_CANCEL
297         || forceCancel) {
298       float vel = 0f;
299       float vectorVel = 0f;
300       if (velocityTracker != null) {
301         velocityTracker.computeCurrentVelocity(1000);
302         vel = velocityTracker.getYVelocity();
303         vectorVel =
304             Math.copySign(
305                 (float) Math.hypot(velocityTracker.getXVelocity(), velocityTracker.getYVelocity()),
306                 vel);
307       }
308 
309       boolean falseTouch = isFalseTouch();
310       boolean forceRecenter =
311           falseTouch
312               || !touchSlopExceeded
313               || forceCancel
314               || event.getActionMasked() == MotionEvent.ACTION_CANCEL;
315 
316       @FlingTarget
317       int target = forceRecenter ? FlingTarget.CENTER : getFlingTarget(pointerY, vectorVel);
318 
319       fling(vel, target, falseTouch);
320       onTrackingStopped();
321     } else {
322       onTrackingStopped();
323       setCurrentProgress(0);
324       onMoveEnded();
325     }
326 
327     if (velocityTracker != null) {
328       velocityTracker.recycle();
329       velocityTracker = null;
330     }
331   }
332 
333   @FlingTarget
getFlingTarget(float pointerY, float vectorVel)334   private int getFlingTarget(float pointerY, float vectorVel) {
335     float progress = pointerYToProgress(pointerY);
336 
337     float minVelocityPxPerSecond = flingAnimationUtils.getMinVelocityPxPerSecond();
338     if (vectorVel > 0) {
339       minVelocityPxPerSecond *= REJECT_FLING_THRESHOLD_MODIFIER;
340     }
341     if (!flingEnabled || Math.abs(vectorVel) < minVelocityPxPerSecond) {
342       // Not a fling
343       if (Math.abs(progress) > PROGRESS_SWIPE_RECENTER) {
344         // Progress near one of the edges
345         return progress > 0 ? FlingTarget.ACCEPT : FlingTarget.REJECT;
346       } else {
347         return FlingTarget.CENTER;
348       }
349     }
350 
351     boolean sameDirection = vectorVel < 0 == progress > 0;
352     if (!sameDirection && Math.abs(progress) >= PROGRESS_FLING_RECENTER) {
353       // Being flung back toward center
354       return FlingTarget.CENTER;
355     }
356     // Flung toward an edge
357     return vectorVel < 0 ? FlingTarget.ACCEPT : FlingTarget.REJECT;
358   }
359 
360   @FloatRange(from = -1f, to = 1f)
pointerYToProgress(float pointerY)361   private float pointerYToProgress(float pointerY) {
362     boolean pointerAboveZero = pointerY > zeroY;
363     float nearestThreshold = pointerAboveZero ? rejectThresholdY : acceptThresholdY;
364 
365     float absoluteProgress = (pointerY - zeroY) / (nearestThreshold - zeroY);
366     return MathUtil.clamp(absoluteProgress * (pointerAboveZero ? -1 : 1), -1f, 1f);
367   }
368 
isFalseTouch()369   private boolean isFalseTouch() {
370     if (falsingManager != null && falsingManager.isEnabled()) {
371       if (falsingManager.isFalseTouch()) {
372         if (touchUsesFalsing) {
373           LogUtil.i("FlingUpDownTouchHandler.isFalseTouch", "rejecting false touch");
374           return true;
375         } else {
376           LogUtil.i(
377               "FlingUpDownTouchHandler.isFalseTouch",
378               "Suspected false touch, but not using false touch rejection for this gesture");
379           return false;
380         }
381       } else {
382         return false;
383       }
384     }
385     return !touchAboveFalsingThreshold;
386   }
387 
trackMovement(MotionEvent event)388   private void trackMovement(MotionEvent event) {
389     if (velocityTracker != null) {
390       velocityTracker.addMovement(event);
391     }
392   }
393 
fling(float velocity, @FlingTarget int target, boolean centerBecauseOfFalsing)394   private void fling(float velocity, @FlingTarget int target, boolean centerBecauseOfFalsing) {
395     ValueAnimator animator = createProgressAnimator(target);
396     if (target == FlingTarget.CENTER) {
397       flingAnimationUtils.apply(animator, currentProgress, target, velocity);
398     } else {
399       flingAnimationUtils.applyDismissing(animator, currentProgress, target, velocity, 1);
400     }
401     if (target == FlingTarget.CENTER && centerBecauseOfFalsing) {
402       velocity = 0;
403     }
404     if (velocity == 0) {
405       animator.setDuration(350);
406     }
407 
408     animator.addListener(
409         new AnimatorListenerAdapter() {
410           boolean canceled;
411 
412           @Override
413           public void onAnimationCancel(Animator animation) {
414             canceled = true;
415           }
416 
417           @Override
418           public void onAnimationEnd(Animator animation) {
419             progressAnimator = null;
420             if (!canceled) {
421               onMoveEnded();
422             }
423           }
424         });
425     progressAnimator = animator;
426     animator.start();
427   }
428 
onMoveEnded()429   private void onMoveEnded() {
430     if (currentProgress == 0) {
431       listener.onMoveReset(!hintDistanceExceeded);
432     } else {
433       listener.onMoveFinish(currentProgress > 0);
434     }
435   }
436 
createProgressAnimator(float targetProgress)437   private ValueAnimator createProgressAnimator(float targetProgress) {
438     ValueAnimator animator = ValueAnimator.ofFloat(currentProgress, targetProgress);
439     animator.addUpdateListener(
440         new AnimatorUpdateListener() {
441           @Override
442           public void onAnimationUpdate(ValueAnimator animation) {
443             setCurrentProgress((Float) animation.getAnimatedValue());
444           }
445         });
446     return animator;
447   }
448 
initVelocityTracker()449   private void initVelocityTracker() {
450     if (velocityTracker != null) {
451       velocityTracker.recycle();
452     }
453     velocityTracker = VelocityTracker.obtain();
454   }
455 
startMotion(float newY, boolean startTracking, float startProgress)456   private void startMotion(float newY, boolean startTracking, float startProgress) {
457     initialTouchY = newY;
458     hintDistanceExceeded = false;
459 
460     if (startProgress <= .25) {
461       acceptThresholdY = Math.max(0, initialTouchY - acceptThresholdPx);
462       rejectThresholdY = Math.min(target.getHeight(), initialTouchY + rejectThresholdPx);
463       zeroY = initialTouchY;
464     }
465 
466     if (startTracking) {
467       touchSlopExceeded = true;
468       onTrackingStarted();
469       setCurrentProgress(startProgress);
470     }
471   }
472 
onTrackingStarted()473   private void onTrackingStarted() {
474     tracking = true;
475     listener.onTrackingStart();
476   }
477 
onTrackingStopped()478   private void onTrackingStopped() {
479     tracking = false;
480     listener.onTrackingStopped();
481   }
482 
cancelProgressAnimator()483   private void cancelProgressAnimator() {
484     if (progressAnimator != null) {
485       progressAnimator.cancel();
486     }
487   }
488 
setCurrentProgress(float progress)489   private void setCurrentProgress(float progress) {
490     if (Math.abs(progress) > HINT_MOVE_THRESHOLD_RATIO) {
491       hintDistanceExceeded = true;
492     }
493     currentProgress = progress;
494     listener.onProgressChanged(progress);
495   }
496 }
497