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