/*
* 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;
}
}