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