1 /*
2  * Copyright (C) 2019 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 package com.android.quickstep.util;
17 
18 import static com.android.launcher3.testing.shared.TestProtocol.PAUSE_DETECTED_MESSAGE;
19 
20 import android.content.Context;
21 import android.content.res.Resources;
22 import android.util.Log;
23 import android.view.MotionEvent;
24 import android.view.VelocityTracker;
25 
26 import com.android.launcher3.Alarm;
27 import com.android.launcher3.R;
28 import com.android.launcher3.Utilities;
29 import com.android.launcher3.compat.AccessibilityManagerCompat;
30 
31 /**
32  * Given positions along x- or y-axis, tracks velocity and acceleration and determines when there is
33  * a pause in motion.
34  */
35 public class MotionPauseDetector {
36 
37     private static final String TAG = "MotionPauseDetector";
38 
39     // The percentage of the previous speed that determines whether this is a rapid deceleration.
40     // The bigger this number, the easier it is to trigger the first pause.
41     private static final float RAPID_DECELERATION_FACTOR = 0.6f;
42 
43     /** If no motion is added for this amount of time, assume the motion has paused. */
44     private static final long FORCE_PAUSE_TIMEOUT = 300;
45 
46     /**
47      * After {@link #mMakePauseHarderToTrigger}, must move slowly for this long to trigger a pause.
48      */
49     private static final long HARDER_TRIGGER_TIMEOUT = 400;
50 
51     /**
52      * When running in a test harness, if no motion is added for this amount of time, assume the
53      * motion has paused. (We use an increased timeout since sometimes test devices can be slow.)
54      */
55     private static final long TEST_HARNESS_TRIGGER_TIMEOUT = 2000;
56 
57     private final float mSpeedVerySlow;
58     private final float mSpeedSlow;
59     private final float mSpeedSomewhatFast;
60     private final float mSpeedFast;
61     private final Alarm mForcePauseTimeout;
62     private final boolean mMakePauseHarderToTrigger;
63     private final Context mContext;
64     private final SystemVelocityProvider mVelocityProvider;
65 
66     private Float mPreviousVelocity = null;
67 
68     private OnMotionPauseListener mOnMotionPauseListener;
69     private boolean mIsPaused;
70     // Bias more for the first pause to make it feel extra responsive.
71     private boolean mHasEverBeenPaused;
72     /** @see #setDisallowPause(boolean) */
73     private boolean mDisallowPause;
74     // Time at which speed became < mSpeedSlow (only used if mMakePauseHarderToTrigger == true).
75     private long mSlowStartTime;
76 
MotionPauseDetector(Context context)77     public MotionPauseDetector(Context context) {
78         this(context, false);
79     }
80 
81     /**
82      * @param makePauseHarderToTrigger Used for gestures that require a more explicit pause.
83      */
MotionPauseDetector(Context context, boolean makePauseHarderToTrigger)84     public MotionPauseDetector(Context context, boolean makePauseHarderToTrigger) {
85         this(context, makePauseHarderToTrigger, MotionEvent.AXIS_Y);
86     }
87 
88     /**
89      * @param makePauseHarderToTrigger Used for gestures that require a more explicit pause.
90      */
MotionPauseDetector(Context context, boolean makePauseHarderToTrigger, int axis)91     public MotionPauseDetector(Context context, boolean makePauseHarderToTrigger, int axis) {
92         mContext = context;
93         Resources res = context.getResources();
94         mSpeedVerySlow = res.getDimension(R.dimen.motion_pause_detector_speed_very_slow);
95         mSpeedSlow = res.getDimension(R.dimen.motion_pause_detector_speed_slow);
96         mSpeedSomewhatFast = res.getDimension(R.dimen.motion_pause_detector_speed_somewhat_fast);
97         mSpeedFast = res.getDimension(R.dimen.motion_pause_detector_speed_fast);
98         mForcePauseTimeout = new Alarm();
99         mForcePauseTimeout.setOnAlarmListener(alarm -> {
100             ActiveGestureLog.CompoundString log =
101                     new ActiveGestureLog.CompoundString("Force pause timeout after ")
102                             .append(alarm.getLastSetTimeout())
103                             .append("ms");
104             addLogs(log);
105             updatePaused(true /* isPaused */, log);
106         });
107         mMakePauseHarderToTrigger = makePauseHarderToTrigger;
108         mVelocityProvider = new SystemVelocityProvider(axis);
109     }
110 
111     /**
112      * Get callbacks for when motion pauses and resumes.
113      */
setOnMotionPauseListener(OnMotionPauseListener listener)114     public void setOnMotionPauseListener(OnMotionPauseListener listener) {
115         mOnMotionPauseListener = listener;
116     }
117 
118     /**
119      * @param disallowPause If true, we will not detect any pauses until this is set to false again.
120      */
setDisallowPause(boolean disallowPause)121     public void setDisallowPause(boolean disallowPause) {
122         ActiveGestureLog.CompoundString log =
123                 new ActiveGestureLog.CompoundString("Set disallowPause=")
124                         .append(disallowPause);
125         if (mDisallowPause != disallowPause) {
126             addLogs(log);
127         }
128         mDisallowPause = disallowPause;
129         updatePaused(mIsPaused, log);
130     }
131 
132     /**
133      * Computes velocity and acceleration to determine whether the motion is paused.
134      * @param ev The motion being tracked.
135      */
addPosition(MotionEvent ev)136     public void addPosition(MotionEvent ev) {
137         addPosition(ev, 0);
138     }
139 
140     /**
141      * Computes velocity and acceleration to determine whether the motion is paused.
142      * @param ev The motion being tracked.
143      * @param pointerIndex Index for the pointer being tracked in the motion event
144      */
addPosition(MotionEvent ev, int pointerIndex)145     public void addPosition(MotionEvent ev, int pointerIndex) {
146         long timeoutMs = Utilities.isRunningInTestHarness()
147                 ? TEST_HARNESS_TRIGGER_TIMEOUT
148                 : mMakePauseHarderToTrigger
149                         ? HARDER_TRIGGER_TIMEOUT
150                         : FORCE_PAUSE_TIMEOUT;
151         mForcePauseTimeout.setAlarm(timeoutMs);
152         float newVelocity = mVelocityProvider.addMotionEvent(ev, ev.getPointerId(pointerIndex));
153         if (mPreviousVelocity != null) {
154             checkMotionPaused(newVelocity, mPreviousVelocity, ev.getEventTime());
155         }
156         mPreviousVelocity = newVelocity;
157     }
158 
checkMotionPaused(float velocity, float prevVelocity, long time)159     private void checkMotionPaused(float velocity, float prevVelocity, long time) {
160         float speed = Math.abs(velocity);
161         float previousSpeed = Math.abs(prevVelocity);
162         boolean isPaused;
163         ActiveGestureLog.CompoundString isPausedReason;
164         if (mIsPaused) {
165             // Continue to be paused until moving at a fast speed.
166             isPaused = speed < mSpeedFast || previousSpeed < mSpeedFast;
167             isPausedReason = new ActiveGestureLog.CompoundString(
168                     "Was paused, but started moving at a fast speed");
169         } else {
170             if (velocity < 0 != prevVelocity < 0) {
171                 // We're just changing directions, not necessarily stopping.
172                 isPaused = false;
173                 isPausedReason = new ActiveGestureLog.CompoundString("Velocity changed directions");
174             } else {
175                 isPaused = speed < mSpeedVerySlow && previousSpeed < mSpeedVerySlow;
176                 isPausedReason = new ActiveGestureLog.CompoundString(
177                         "Pause requires back to back slow speeds");
178                 if (!isPaused && !mHasEverBeenPaused) {
179                     // We want to be more aggressive about detecting the first pause to ensure it
180                     // feels as responsive as possible; getting two very slow speeds back to back
181                     // takes too long, so also check for a rapid deceleration.
182                     boolean isRapidDeceleration = speed < previousSpeed * RAPID_DECELERATION_FACTOR;
183                     isPaused = isRapidDeceleration && speed < mSpeedSomewhatFast;
184                     isPausedReason = new ActiveGestureLog.CompoundString(
185                             "Didn't have back to back slow speeds, checking for rapid ")
186                             .append(" deceleration on first pause only");
187                 }
188                 if (mMakePauseHarderToTrigger) {
189                     if (speed < mSpeedSlow) {
190                         if (mSlowStartTime == 0) {
191                             mSlowStartTime = time;
192                         }
193                         isPaused = time - mSlowStartTime >= HARDER_TRIGGER_TIMEOUT;
194                         isPausedReason = new ActiveGestureLog.CompoundString(
195                                 "Maintained slow speed for sufficient duration when making")
196                                 .append(" pause harder to trigger");
197                     } else {
198                         mSlowStartTime = 0;
199                         isPaused = false;
200                         isPausedReason = new ActiveGestureLog.CompoundString(
201                                 "Intentionally making pause harder to trigger");
202                     }
203                 }
204             }
205         }
206         updatePaused(isPaused, isPausedReason);
207     }
208 
209     private void updatePaused(boolean isPaused, ActiveGestureLog.CompoundString reason) {
210         if (mDisallowPause) {
211             reason = new ActiveGestureLog.CompoundString(
212                     "Disallow pause; otherwise, would have been ")
213                     .append(isPaused)
214                     .append(" due to reason:")
215                     .append(reason);
216             isPaused = false;
217         }
218         if (mIsPaused != isPaused) {
219             mIsPaused = isPaused;
220             addLogs(new ActiveGestureLog.CompoundString("onMotionPauseChanged triggered; paused=")
221                     .append(mIsPaused)
222                     .append(", reason=")
223                     .append(reason));
224             boolean isFirstDetectedPause = !mHasEverBeenPaused && mIsPaused;
225             if (mIsPaused) {
226                 AccessibilityManagerCompat.sendTestProtocolEventToTest(mContext,
227                         PAUSE_DETECTED_MESSAGE);
228                 mHasEverBeenPaused = true;
229             }
230             if (mOnMotionPauseListener != null) {
231                 if (isFirstDetectedPause) {
232                     mOnMotionPauseListener.onMotionPauseDetected();
233                 }
234                 // Null check again as onMotionPauseDetected() maybe have called clear().
235                 if (mOnMotionPauseListener != null) {
236                     mOnMotionPauseListener.onMotionPauseChanged(mIsPaused);
237                 }
238             }
239         }
240     }
241 
242     private void addLogs(ActiveGestureLog.CompoundString compoundString) {
243         ActiveGestureLog.CompoundString logString =
244                 new ActiveGestureLog.CompoundString("MotionPauseDetector: ")
245                         .append(compoundString);
246         if (Utilities.isRunningInTestHarness()) {
247             Log.d(TAG, logString.toString());
248         }
249         ActiveGestureLog.INSTANCE.addLog(logString);
250     }
251 
252     public void clear() {
253         mVelocityProvider.clear();
254         mPreviousVelocity = null;
255         setOnMotionPauseListener(null);
256         mIsPaused = mHasEverBeenPaused = false;
257         mSlowStartTime = 0;
258         mForcePauseTimeout.cancelAlarm();
259     }
260 
261     public boolean isPaused() {
262         return mIsPaused;
263     }
264 
265     public interface OnMotionPauseListener {
266         /** Called only the first time motion pause is detected. */
267         void onMotionPauseDetected();
268         /** Called every time motion changes from paused to not paused and vice versa. */
269         default void onMotionPauseChanged(boolean isPaused) { }
270     }
271 
272     private static class SystemVelocityProvider {
273 
274         private final VelocityTracker mVelocityTracker;
275         private final int mAxis;
276 
277         SystemVelocityProvider(int axis) {
278             mVelocityTracker = VelocityTracker.obtain();
279             mAxis = axis;
280         }
281 
282         /**
283          * Adds a new motion events, and returns the velocity at this point, or null if
284          * the velocity is not available
285          */
286         public float addMotionEvent(MotionEvent ev, int pointer) {
287             mVelocityTracker.addMovement(ev);
288             mVelocityTracker.computeCurrentVelocity(1); // px / ms
289             return mAxis == MotionEvent.AXIS_X
290                     ? mVelocityTracker.getXVelocity(pointer)
291                     : mVelocityTracker.getYVelocity(pointer);
292         }
293 
294         /**
295          * Clears all stored motion event records
296          */
297         public void clear() {
298             mVelocityTracker.clear();
299         }
300     }
301 }
302