/* * Copyright (C) 2021 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package android.window; import android.annotation.NonNull; import android.annotation.Nullable; import android.app.Activity; import android.content.Context; import android.content.ContextWrapper; import android.content.pm.ActivityInfo; import android.content.pm.ApplicationInfo; import android.content.res.Configuration; import android.content.res.Resources; import android.content.res.TypedArray; import android.os.Handler; import android.os.Looper; import android.os.RemoteException; import android.os.SystemProperties; import android.text.TextUtils; import android.util.Log; import android.util.TypedValue; import android.view.IWindow; import android.view.IWindowSession; import android.view.ImeBackAnimationController; import android.view.MotionEvent; import androidx.annotation.VisibleForTesting; import com.android.internal.R; import com.android.internal.annotations.GuardedBy; import java.io.PrintWriter; import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.HashMap; import java.util.Objects; import java.util.TreeMap; import java.util.function.Supplier; /** * Provides window based implementation of {@link OnBackInvokedDispatcher}. *

* Callbacks with higher priorities receive back dispatching first. * Within the same priority, callbacks receive back dispatching in the reverse order * in which they are added. *

* When the top priority callback is updated, the new callback is propagated to the Window Manager * if the window the instance is associated with has been attached. It is allowed to register / * unregister {@link OnBackInvokedCallback}s before the window is attached, although * callbacks will not receive dispatches until window attachment. * * @hide */ public class WindowOnBackInvokedDispatcher implements OnBackInvokedDispatcher { private IWindowSession mWindowSession; private IWindow mWindow; @VisibleForTesting public final BackTouchTracker mTouchTracker = new BackTouchTracker(); @VisibleForTesting public final BackProgressAnimator mProgressAnimator = new BackProgressAnimator(); // The handler to run callbacks on. // This should be on the same thread the ViewRootImpl holding this instance is created on. @NonNull private final Handler mHandler; private static final String TAG = "WindowOnBackDispatcher"; private static final boolean ENABLE_PREDICTIVE_BACK = SystemProperties .getInt("persist.wm.debug.predictive_back", 1) != 0; private static final boolean ALWAYS_ENFORCE_PREDICTIVE_BACK = SystemProperties .getInt("persist.wm.debug.predictive_back_always_enforce", 0) != 0; private static final boolean PREDICTIVE_BACK_FALLBACK_WINDOW_ATTRIBUTE = SystemProperties.getInt("persist.wm.debug.predictive_back_fallback_window_attribute", 0) != 0; @Nullable private ImeOnBackInvokedDispatcher mImeDispatcher; @Nullable private ImeBackAnimationController mImeBackAnimationController; @GuardedBy("mLock") /** Convenience hashmap to quickly decide if a callback has been added. */ private final HashMap mAllCallbacks = new HashMap<>(); /** Holds all callbacks by priorities. */ @VisibleForTesting @GuardedBy("mLock") public final TreeMap> mOnBackInvokedCallbacks = new TreeMap<>(); private Checker mChecker; private final Object mLock = new Object(); // The threshold for back swipe full progress. private float mBackSwipeLinearThreshold; private float mNonLinearProgressFactor; public WindowOnBackInvokedDispatcher(@NonNull Context context, Looper looper) { mChecker = new Checker(context); mHandler = new Handler(looper); } /** Updates the dispatcher state on a new {@link MotionEvent}. */ public void onMotionEvent(MotionEvent ev) { if (!isBackGestureInProgress() || ev == null || ev.getAction() != MotionEvent.ACTION_MOVE) { return; } mTouchTracker.update(ev.getX(), ev.getY(), Float.NaN, Float.NaN); if (mTouchTracker.shouldUpdateStartLocation()) { // Reset the start location on the first event after starting back, so that // the beginning of the animation feels smooth. mTouchTracker.updateStartLocation(); } if (!mProgressAnimator.isBackAnimationInProgress()) { return; } final BackMotionEvent backEvent = mTouchTracker.createProgressEvent(); mProgressAnimator.onBackProgressed(backEvent); } /** * Sends the pending top callback (if one exists) to WM when the view root * is attached a window. */ public void attachToWindow(@NonNull IWindowSession windowSession, @NonNull IWindow window, @Nullable ImeBackAnimationController imeBackAnimationController) { synchronized (mLock) { mWindowSession = windowSession; mWindow = window; mImeBackAnimationController = imeBackAnimationController; if (!mAllCallbacks.isEmpty()) { setTopOnBackInvokedCallback(getTopCallback()); } } } /** Detaches the dispatcher instance from its window. */ public void detachFromWindow() { synchronized (mLock) { clear(); mWindow = null; mWindowSession = null; mImeBackAnimationController = null; } } // TODO: Take an Executor for the callback to run on. @Override public void registerOnBackInvokedCallback( @Priority int priority, @NonNull OnBackInvokedCallback callback) { if (mChecker.checkApplicationCallbackRegistration(priority, callback)) { registerOnBackInvokedCallbackUnchecked(callback, priority); } } /** * Register a callback bypassing platform checks. This is used to register compatibility * callbacks. */ public void registerOnBackInvokedCallbackUnchecked( @NonNull OnBackInvokedCallback callback, @Priority int priority) { synchronized (mLock) { if (mImeDispatcher != null) { mImeDispatcher.registerOnBackInvokedCallback(priority, callback); return; } if (callback instanceof ImeOnBackInvokedDispatcher.ImeOnBackInvokedCallback) { // Fall back to compat back key injection if legacy back behaviour should be used. if (!isOnBackInvokedCallbackEnabled()) return; if (callback instanceof ImeOnBackInvokedDispatcher.DefaultImeOnBackAnimationCallback && mImeBackAnimationController != null) { // register ImeBackAnimationController instead to play predictive back animation callback = mImeBackAnimationController; } } if (!mOnBackInvokedCallbacks.containsKey(priority)) { mOnBackInvokedCallbacks.put(priority, new ArrayList<>()); } ArrayList callbacks = mOnBackInvokedCallbacks.get(priority); // If callback has already been added, remove it and re-add it. if (mAllCallbacks.containsKey(callback)) { if (DEBUG) { Log.i(TAG, "Callback already added. Removing and re-adding it."); } Integer prevPriority = mAllCallbacks.get(callback); mOnBackInvokedCallbacks.get(prevPriority).remove(callback); } OnBackInvokedCallback previousTopCallback = getTopCallback(); callbacks.add(callback); mAllCallbacks.put(callback, priority); if (previousTopCallback == null || (previousTopCallback != callback && mAllCallbacks.get(previousTopCallback) <= priority)) { setTopOnBackInvokedCallback(callback); } } } @Override public void unregisterOnBackInvokedCallback(@NonNull OnBackInvokedCallback callback) { synchronized (mLock) { if (mImeDispatcher != null) { mImeDispatcher.unregisterOnBackInvokedCallback(callback); return; } if (callback instanceof ImeOnBackInvokedDispatcher.DefaultImeOnBackAnimationCallback) { callback = mImeBackAnimationController; } if (!mAllCallbacks.containsKey(callback)) { if (DEBUG) { Log.i(TAG, "Callback not found. returning..."); } return; } OnBackInvokedCallback previousTopCallback = getTopCallback(); Integer priority = mAllCallbacks.get(callback); ArrayList callbacks = mOnBackInvokedCallbacks.get(priority); callbacks.remove(callback); if (callbacks.isEmpty()) { mOnBackInvokedCallbacks.remove(priority); } mAllCallbacks.remove(callback); // Re-populate the top callback to WM if the removed callback was previously the top // one. if (previousTopCallback == callback) { // We should call onBackCancelled() when an active callback is removed from // dispatcher. sendCancelledIfInProgress(callback); setTopOnBackInvokedCallback(getTopCallback()); } } } /** * Indicates if a user gesture is currently in progress. */ public boolean isBackGestureInProgress() { synchronized (mLock) { return mTouchTracker.isActive(); } } private void sendCancelledIfInProgress(@NonNull OnBackInvokedCallback callback) { boolean isInProgress = mProgressAnimator.isBackAnimationInProgress(); if (isInProgress && callback instanceof OnBackAnimationCallback) { OnBackAnimationCallback animatedCallback = (OnBackAnimationCallback) callback; animatedCallback.onBackCancelled(); if (DEBUG) { Log.d(TAG, "sendCancelIfRunning: callback canceled"); } } else { Log.w(TAG, "sendCancelIfRunning: isInProgress=" + isInProgress + " callback=" + callback); } } @Override public void registerSystemOnBackInvokedCallback(@NonNull OnBackInvokedCallback callback) { registerOnBackInvokedCallbackUnchecked(callback, OnBackInvokedDispatcher.PRIORITY_SYSTEM); } /** Clears all registered callbacks on the instance. */ public void clear() { synchronized (mLock) { if (mImeDispatcher != null) { mImeDispatcher.clear(); mImeDispatcher = null; } if (!mAllCallbacks.isEmpty()) { OnBackInvokedCallback topCallback = getTopCallback(); if (topCallback != null) { sendCancelledIfInProgress(topCallback); } else { // Should not be possible Log.e(TAG, "There is no topCallback, even if mAllCallbacks is not empty"); } // Clear binder references in WM. setTopOnBackInvokedCallback(null); } // We should also stop running animations since all callbacks have been removed. // note: mSpring.skipToEnd(), in ProgressAnimator.reset(), requires the main handler. mHandler.post(mProgressAnimator::reset); mAllCallbacks.clear(); mOnBackInvokedCallbacks.clear(); } } private void setTopOnBackInvokedCallback(@Nullable OnBackInvokedCallback callback) { if (mWindowSession == null || mWindow == null) { return; } try { OnBackInvokedCallbackInfo callbackInfo = null; if (callback != null) { int priority = mAllCallbacks.get(callback); final IOnBackInvokedCallback iCallback = new OnBackInvokedCallbackWrapper( callback, mTouchTracker, mProgressAnimator, mHandler); callbackInfo = new OnBackInvokedCallbackInfo( iCallback, priority, callback instanceof OnBackAnimationCallback); } mWindowSession.setOnBackInvokedCallbackInfo(mWindow, callbackInfo); } catch (RemoteException e) { Log.e(TAG, "Failed to set OnBackInvokedCallback to WM. Error: " + e); } } public OnBackInvokedCallback getTopCallback() { synchronized (mLock) { if (mAllCallbacks.isEmpty()) { return null; } for (Integer priority : mOnBackInvokedCallbacks.descendingKeySet()) { ArrayList callbacks = mOnBackInvokedCallbacks.get(priority); if (!callbacks.isEmpty()) { return callbacks.get(callbacks.size() - 1); } } } return null; } /** * The {@link Context} in ViewRootImp and Activity could be different, this will make sure it * could update the checker condition base on the real context when binding the proxy * dispatcher in PhoneWindow. */ public void updateContext(@NonNull Context context) { mChecker = new Checker(context); // Set swipe threshold values. Resources res = context.getResources(); mBackSwipeLinearThreshold = res.getDimension(R.dimen.navigation_edge_action_progress_threshold); TypedValue typedValue = new TypedValue(); res.getValue(R.dimen.back_progress_non_linear_factor, typedValue, true); mNonLinearProgressFactor = typedValue.getFloat(); onConfigurationChanged(context.getResources().getConfiguration()); } /** Updates the threshold values for computing progress. */ public void onConfigurationChanged(Configuration configuration) { float maxDistance = configuration.windowConfiguration.getMaxBounds().width(); float linearDistance = Math.min(maxDistance, mBackSwipeLinearThreshold); mTouchTracker.setProgressThresholds( linearDistance, maxDistance, mNonLinearProgressFactor); } /** * Returns false if the legacy back behavior should be used. */ public boolean isOnBackInvokedCallbackEnabled() { return isOnBackInvokedCallbackEnabled(mChecker.getContext()); } /** * Dump information about this WindowOnBackInvokedDispatcher * @param prefix the prefix that will be prepended to each line of the produced output * @param writer the writer that will receive the resulting text */ public void dump(String prefix, PrintWriter writer) { String innerPrefix = prefix + " "; writer.println(prefix + "WindowOnBackDispatcher:"); synchronized (mLock) { if (mAllCallbacks.isEmpty()) { writer.println(prefix + ""); return; } writer.println(innerPrefix + "Top Callback: " + getTopCallback()); writer.println(innerPrefix + "Callbacks: "); mAllCallbacks.forEach((callback, priority) -> { writer.println(innerPrefix + " Callback: " + callback + " Priority=" + priority); }); } } private static class OnBackInvokedCallbackWrapper extends IOnBackInvokedCallback.Stub { @NonNull private final WeakReference mCallback; @NonNull private final BackProgressAnimator mProgressAnimator; @NonNull private final BackTouchTracker mTouchTracker; @NonNull private final Handler mHandler; OnBackInvokedCallbackWrapper( @NonNull OnBackInvokedCallback callback, @NonNull BackTouchTracker touchTracker, @NonNull BackProgressAnimator progressAnimator, @NonNull Handler handler) { mCallback = new WeakReference<>(callback); mTouchTracker = touchTracker; mProgressAnimator = progressAnimator; mHandler = handler; } @Override public void onBackStarted(BackMotionEvent backEvent) { mHandler.post(() -> { final OnBackAnimationCallback callback = getBackAnimationCallback(); // reset progress animator before dispatching onBackStarted to callback. This // ensures that onBackCancelled (of a previous gesture) is always dispatched // before onBackStarted if (callback != null && mProgressAnimator.isBackAnimationInProgress()) { mProgressAnimator.reset(); } mTouchTracker.setState(BackTouchTracker.TouchTrackerState.ACTIVE); mTouchTracker.setShouldUpdateStartLocation(true); mTouchTracker.setGestureStartLocation( backEvent.getTouchX(), backEvent.getTouchY(), backEvent.getSwipeEdge()); if (callback != null) { callback.onBackStarted(BackEvent.fromBackMotionEvent(backEvent)); mProgressAnimator.onBackStarted(backEvent, callback::onBackProgressed); } }); } @Override public void onBackProgressed(BackMotionEvent backEvent) { // This is only called in some special cases such as when activity embedding is active // or when the activity is letterboxed. Otherwise mProgressAnimator#onBackProgressed is // called from WindowOnBackInvokedDispatcher#onMotionEvent mHandler.post(() -> { if (getBackAnimationCallback() != null) { mProgressAnimator.onBackProgressed(backEvent); } }); } @Override public void onBackCancelled() { mHandler.post(() -> { final OnBackAnimationCallback callback = getBackAnimationCallback(); mTouchTracker.reset(); if (callback == null) return; mProgressAnimator.onBackCancelled(callback::onBackCancelled); }); } @Override public void onBackInvoked() throws RemoteException { mHandler.post(() -> { mTouchTracker.reset(); boolean isInProgress = mProgressAnimator.isBackAnimationInProgress(); final OnBackInvokedCallback callback = mCallback.get(); if (callback == null) { mProgressAnimator.reset(); Log.d(TAG, "Trying to call onBackInvoked() on a null callback reference."); return; } if (callback instanceof OnBackAnimationCallback && !isInProgress) { Log.w(TAG, "ProgressAnimator was not in progress, skip onBackInvoked()."); return; } OnBackAnimationCallback animationCallback = getBackAnimationCallback(); if (animationCallback != null) { mProgressAnimator.onBackInvoked(callback::onBackInvoked); } else { mProgressAnimator.reset(); callback.onBackInvoked(); } }); } @Override public void setTriggerBack(boolean triggerBack) throws RemoteException { mTouchTracker.setTriggerBack(triggerBack); } @Nullable private OnBackAnimationCallback getBackAnimationCallback() { OnBackInvokedCallback callback = mCallback.get(); return callback instanceof OnBackAnimationCallback ? (OnBackAnimationCallback) callback : null; } } /** * Returns false if the legacy back behavior should be used. *

* Legacy back behavior dispatches KEYCODE_BACK instead of invoking the application registered * {@link OnBackInvokedCallback}. */ public static boolean isOnBackInvokedCallbackEnabled(@NonNull Context context) { final Context originalContext = context; while ((context instanceof ContextWrapper) && !(context instanceof Activity)) { context = ((ContextWrapper) context).getBaseContext(); } final ActivityInfo activityInfo = (context instanceof Activity) ? ((Activity) context).getActivityInfo() : null; final ApplicationInfo applicationInfo = context.getApplicationInfo(); return WindowOnBackInvokedDispatcher .isOnBackInvokedCallbackEnabled(activityInfo, applicationInfo, () -> originalContext); } @Override public void setImeOnBackInvokedDispatcher( @NonNull ImeOnBackInvokedDispatcher imeDispatcher) { mImeDispatcher = imeDispatcher; mImeDispatcher.setHandler(mHandler); } /** Returns true if a non-null {@link ImeOnBackInvokedDispatcher} has been set. **/ public boolean hasImeOnBackInvokedDispatcher() { return mImeDispatcher != null; } /** * Class used to check whether a callback can be registered or not. This is meant to be * shared with {@link ProxyOnBackInvokedDispatcher} which needs to do the same checks. */ public static class Checker { private WeakReference mContext; public Checker(@NonNull Context context) { mContext = new WeakReference<>(context); } /** * Checks whether the given callback can be registered with the given priority. * @return true if the callback can be added. * @throws IllegalArgumentException if the priority is negative. */ public boolean checkApplicationCallbackRegistration(int priority, OnBackInvokedCallback callback) { if (!WindowOnBackInvokedDispatcher.isOnBackInvokedCallbackEnabled(getContext()) && !(callback instanceof CompatOnBackInvokedCallback)) { Log.w(TAG, "OnBackInvokedCallback is not enabled for the application." + "\nSet 'android:enableOnBackInvokedCallback=\"true\"' in the" + " application manifest."); return false; } if (priority < 0) { throw new IllegalArgumentException("Application registered OnBackInvokedCallback " + "cannot have negative priority. Priority: " + priority); } Objects.requireNonNull(callback); return true; } private Context getContext() { return mContext.get(); } } /** * @hide */ public static boolean isOnBackInvokedCallbackEnabled(@Nullable ActivityInfo activityInfo, @NonNull ApplicationInfo applicationInfo, @NonNull Supplier contextSupplier) { // new back is enabled if the feature flag is enabled AND the app does not explicitly // request legacy back. if (!ENABLE_PREDICTIVE_BACK) { return false; } if (ALWAYS_ENFORCE_PREDICTIVE_BACK) { return true; } boolean requestsPredictiveBack; // Activity if (activityInfo != null && activityInfo.hasOnBackInvokedCallbackEnabled()) { requestsPredictiveBack = activityInfo.isOnBackInvokedCallbackEnabled(); if (DEBUG) { Log.d(TAG, TextUtils.formatSimple( "Activity: %s isPredictiveBackEnabled=%s", activityInfo.getComponentName(), requestsPredictiveBack)); } return requestsPredictiveBack; } // Application requestsPredictiveBack = applicationInfo.isOnBackInvokedCallbackEnabled(); if (DEBUG) { Log.d(TAG, TextUtils.formatSimple("App: %s requestsPredictiveBack=%s", applicationInfo.packageName, requestsPredictiveBack)); } if (requestsPredictiveBack) { return true; } if (PREDICTIVE_BACK_FALLBACK_WINDOW_ATTRIBUTE) { // Compatibility check for legacy window style flag used by Wear OS. // Note on compatibility behavior: // 1. windowSwipeToDismiss should be respected for all apps not opted in. // 2. windowSwipeToDismiss should be true for all apps not opted in, which // enables the PB animation for them. // 3. windowSwipeToDismiss=false should be respected for apps not opted in, // which disables PB & onBackPressed caused by BackAnimController's // setTrigger(true) // Use the original context to resolve the styled attribute so that they stay // true to the window. final Context context = contextSupplier.get(); boolean windowSwipeToDismiss = true; if (context != null) { final TypedArray array = context.obtainStyledAttributes( new int[]{android.R.attr.windowSwipeToDismiss}); if (array.getIndexCount() > 0) { windowSwipeToDismiss = array.getBoolean(0, true); } array.recycle(); } if (DEBUG) { Log.i(TAG, "falling back to windowSwipeToDismiss: " + windowSwipeToDismiss); } requestsPredictiveBack = windowSwipeToDismiss; } return requestsPredictiveBack; } }