| /* |
| * Copyright (C) 2015 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 com.android.systemui.statusbar.policy; |
| |
| import static android.view.WindowInsetsAnimation.Callback.DISPATCH_MODE_STOP; |
| |
| import static com.android.systemui.statusbar.notification.stack.StackStateAnimator.ANIMATION_DURATION_STANDARD; |
| |
| import android.app.ActivityManager; |
| import android.app.Notification; |
| import android.content.Context; |
| import android.content.pm.PackageManager; |
| import android.content.res.ColorStateList; |
| import android.content.res.TypedArray; |
| import android.graphics.BlendMode; |
| import android.graphics.Color; |
| import android.graphics.PorterDuff; |
| import android.graphics.Rect; |
| import android.graphics.drawable.GradientDrawable; |
| import android.os.UserHandle; |
| import android.text.Editable; |
| import android.text.SpannedString; |
| import android.text.TextWatcher; |
| import android.util.ArraySet; |
| import android.util.AttributeSet; |
| import android.util.Log; |
| import android.util.Pair; |
| import android.view.ContentInfo; |
| import android.view.KeyEvent; |
| import android.view.LayoutInflater; |
| import android.view.MotionEvent; |
| import android.view.OnReceiveContentListener; |
| import android.view.View; |
| import android.view.ViewAnimationUtils; |
| import android.view.ViewGroup; |
| import android.view.ViewGroupOverlay; |
| import android.view.ViewRootImpl; |
| import android.view.WindowInsets; |
| import android.view.WindowInsetsAnimation; |
| import android.view.WindowInsetsController; |
| import android.view.accessibility.AccessibilityEvent; |
| import android.view.inputmethod.CompletionInfo; |
| import android.view.inputmethod.EditorInfo; |
| import android.view.inputmethod.InputConnection; |
| import android.view.inputmethod.InputMethodManager; |
| import android.widget.EditText; |
| import android.widget.ImageButton; |
| import android.widget.ImageView; |
| import android.widget.LinearLayout; |
| import android.widget.ProgressBar; |
| import android.widget.TextView; |
| import android.window.OnBackInvokedCallback; |
| import android.window.OnBackInvokedDispatcher; |
| |
| import androidx.annotation.NonNull; |
| import androidx.annotation.Nullable; |
| import androidx.core.animation.Animator; |
| import androidx.core.animation.AnimatorListenerAdapter; |
| import androidx.core.animation.AnimatorSet; |
| import androidx.core.animation.ObjectAnimator; |
| import androidx.core.animation.ValueAnimator; |
| |
| import com.android.app.animation.InterpolatorsAndroidX; |
| import com.android.internal.annotations.VisibleForTesting; |
| import com.android.internal.graphics.ColorUtils; |
| import com.android.internal.logging.UiEvent; |
| import com.android.internal.logging.UiEventLogger; |
| import com.android.internal.util.ContrastColorUtil; |
| import com.android.systemui.Dependency; |
| import com.android.systemui.R; |
| import com.android.systemui.statusbar.RemoteInputController; |
| import com.android.systemui.statusbar.notification.collection.NotificationEntry; |
| import com.android.systemui.statusbar.notification.row.wrapper.NotificationViewWrapper; |
| import com.android.systemui.statusbar.notification.stack.StackStateAnimator; |
| import com.android.systemui.statusbar.phone.LightBarController; |
| import com.android.wm.shell.animation.Interpolators; |
| |
| import java.util.ArrayList; |
| import java.util.Collection; |
| import java.util.List; |
| import java.util.function.Consumer; |
| |
| /** |
| * Host for the remote input. |
| */ |
| public class RemoteInputView extends LinearLayout implements View.OnClickListener { |
| |
| private static final boolean DEBUG = false; |
| private static final String TAG = "RemoteInput"; |
| |
| // A marker object that let's us easily find views of this class. |
| public static final Object VIEW_TAG = new Object(); |
| |
| private static final long FOCUS_ANIMATION_TOTAL_DURATION = ANIMATION_DURATION_STANDARD; |
| private static final long FOCUS_ANIMATION_CROSSFADE_DURATION = 50; |
| private static final long FOCUS_ANIMATION_FADE_IN_DELAY = 33; |
| private static final long FOCUS_ANIMATION_FADE_IN_DURATION = 83; |
| private static final float FOCUS_ANIMATION_MIN_SCALE = 0.5f; |
| private static final long DEFOCUS_ANIMATION_FADE_OUT_DELAY = 120; |
| private static final long DEFOCUS_ANIMATION_CROSSFADE_DELAY = 180; |
| |
| public final Object mToken = new Object(); |
| |
| private final SendButtonTextWatcher mTextWatcher; |
| private final TextView.OnEditorActionListener mEditorActionHandler; |
| private final ArrayList<Runnable> mOnSendListeners = new ArrayList<>(); |
| private final ArrayList<Consumer<Boolean>> mOnVisibilityChangedListeners = new ArrayList<>(); |
| private final ArrayList<OnFocusChangeListener> mEditTextFocusChangeListeners = |
| new ArrayList<>(); |
| |
| private RemoteEditText mEditText; |
| private ImageButton mSendButton; |
| private LinearLayout mContentView; |
| private GradientDrawable mContentBackground; |
| private ProgressBar mProgressBar; |
| private ImageView mDelete; |
| private ImageView mDeleteBg; |
| private boolean mColorized; |
| private int mTint; |
| private boolean mResetting; |
| @Nullable |
| private RevealParams mRevealParams; |
| private Rect mContentBackgroundBounds; |
| private boolean mIsFocusAnimationFlagActive; |
| private boolean mIsAnimatingAppearance = false; |
| |
| // TODO(b/193539698): move these to a Controller |
| private RemoteInputController mController; |
| private final UiEventLogger mUiEventLogger; |
| private NotificationEntry mEntry; |
| private boolean mRemoved; |
| private boolean mSending; |
| private NotificationViewWrapper mWrapper; |
| |
| // TODO(b/193539698): remove this; views shouldn't have access to their controller, and places |
| // that need the controller shouldn't have access to the view |
| private RemoteInputViewController mViewController; |
| private ViewRootImpl mTestableViewRootImpl; |
| |
| /** |
| * Enum for logged notification remote input UiEvents. |
| */ |
| enum NotificationRemoteInputEvent implements UiEventLogger.UiEventEnum { |
| @UiEvent(doc = "Notification remote input view was displayed") |
| NOTIFICATION_REMOTE_INPUT_OPEN(795), |
| @UiEvent(doc = "Notification remote input view was closed") |
| NOTIFICATION_REMOTE_INPUT_CLOSE(796), |
| @UiEvent(doc = "User sent data through the notification remote input view") |
| NOTIFICATION_REMOTE_INPUT_SEND(797), |
| @UiEvent(doc = "Failed attempt to send data through the notification remote input view") |
| NOTIFICATION_REMOTE_INPUT_FAILURE(798), |
| @UiEvent(doc = "User attached an image to the remote input view") |
| NOTIFICATION_REMOTE_INPUT_ATTACH_IMAGE(825); |
| |
| private final int mId; |
| NotificationRemoteInputEvent(int id) { |
| mId = id; |
| } |
| @Override public int getId() { |
| return mId; |
| } |
| } |
| |
| public RemoteInputView(Context context, AttributeSet attrs) { |
| super(context, attrs); |
| mTextWatcher = new SendButtonTextWatcher(); |
| mEditorActionHandler = new EditorActionHandler(); |
| mUiEventLogger = Dependency.get(UiEventLogger.class); |
| TypedArray ta = getContext().getTheme().obtainStyledAttributes(new int[]{ |
| com.android.internal.R.attr.colorAccent, |
| com.android.internal.R.attr.colorSurface, |
| }); |
| mTint = ta.getColor(0, 0); |
| ta.recycle(); |
| } |
| |
| // TODO(b/193539698): move to Controller, since we're just directly accessing a system service |
| /** Hide the IME, if visible. */ |
| public void hideIme() { |
| mEditText.hideIme(); |
| } |
| |
| private ColorStateList colorStateListWithDisabledAlpha(int color, int disabledAlpha) { |
| return new ColorStateList(new int[][]{ |
| new int[]{-com.android.internal.R.attr.state_enabled}, // disabled |
| new int[]{}, |
| }, new int[]{ |
| ColorUtils.setAlphaComponent(color, disabledAlpha), |
| color |
| }); |
| } |
| |
| /** |
| * The remote view needs to adapt to colorized notifications when set |
| * It overrides the background of itself as well as all of its childern |
| * @param backgroundColor colorized notification color |
| */ |
| public void setBackgroundTintColor(final int backgroundColor, boolean colorized) { |
| if (colorized == mColorized && backgroundColor == mTint) return; |
| mColorized = colorized; |
| mTint = backgroundColor; |
| final int editBgColor; |
| final int deleteBgColor; |
| final int deleteFgColor; |
| final ColorStateList accentColor; |
| final ColorStateList textColor; |
| final int hintColor; |
| final int stroke = colorized ? mContext.getResources().getDimensionPixelSize( |
| R.dimen.remote_input_view_text_stroke) : 0; |
| if (colorized) { |
| final boolean dark = ContrastColorUtil.isColorDark(backgroundColor); |
| final int foregroundColor = dark ? Color.WHITE : Color.BLACK; |
| final int inverseColor = dark ? Color.BLACK : Color.WHITE; |
| editBgColor = backgroundColor; |
| deleteBgColor = foregroundColor; |
| deleteFgColor = inverseColor; |
| accentColor = colorStateListWithDisabledAlpha(foregroundColor, 0x4D); // 30% |
| textColor = colorStateListWithDisabledAlpha(foregroundColor, 0x99); // 60% |
| hintColor = ColorUtils.setAlphaComponent(foregroundColor, 0x99); |
| } else { |
| accentColor = mContext.getColorStateList(R.color.remote_input_send); |
| textColor = mContext.getColorStateList(R.color.remote_input_text); |
| hintColor = mContext.getColor(R.color.remote_input_hint); |
| deleteFgColor = textColor.getDefaultColor(); |
| try (TypedArray ta = getContext().getTheme().obtainStyledAttributes(new int[]{ |
| com.android.internal.R.attr.colorSurfaceHighlight, |
| com.android.internal.R.attr.colorSurfaceVariant |
| })) { |
| editBgColor = ta.getColor(0, backgroundColor); |
| deleteBgColor = ta.getColor(1, Color.GRAY); |
| } |
| } |
| |
| mEditText.setTextColor(textColor); |
| mEditText.setHintTextColor(hintColor); |
| if (mEditText.getTextCursorDrawable() != null) { |
| mEditText.getTextCursorDrawable().setColorFilter( |
| accentColor.getDefaultColor(), PorterDuff.Mode.SRC_IN); |
| } |
| mContentBackground.setColor(editBgColor); |
| mContentBackground.setStroke(stroke, accentColor); |
| mDelete.setImageTintList(ColorStateList.valueOf(deleteFgColor)); |
| mDeleteBg.setImageTintList(ColorStateList.valueOf(deleteBgColor)); |
| mSendButton.setImageTintList(accentColor); |
| mProgressBar.setProgressTintList(accentColor); |
| mProgressBar.setIndeterminateTintList(accentColor); |
| mProgressBar.setSecondaryProgressTintList(accentColor); |
| setBackgroundColor(backgroundColor); |
| } |
| |
| @Override |
| protected void onFinishInflate() { |
| super.onFinishInflate(); |
| |
| mProgressBar = findViewById(R.id.remote_input_progress); |
| mSendButton = findViewById(R.id.remote_input_send); |
| mSendButton.setOnClickListener(this); |
| mContentBackground = (GradientDrawable) |
| mContext.getDrawable(R.drawable.remote_input_view_text_bg).mutate(); |
| mDelete = findViewById(R.id.remote_input_delete); |
| mDeleteBg = findViewById(R.id.remote_input_delete_bg); |
| mDeleteBg.setImageTintBlendMode(BlendMode.SRC_IN); |
| mDelete.setImageTintBlendMode(BlendMode.SRC_IN); |
| mDelete.setOnClickListener(v -> setAttachment(null)); |
| mContentView = findViewById(R.id.remote_input_content); |
| mContentView.setBackground(mContentBackground); |
| mEditText = findViewById(R.id.remote_input_text); |
| mEditText.setInnerFocusable(false); |
| // TextView initializes the spell checked when the view is attached to a window. |
| // This causes a couple of IPCs that can jank, especially during animations. |
| // By default the text view should be disabled, to avoid the unnecessary initialization. |
| mEditText.setEnabled(false); |
| mEditText.setWindowInsetsAnimationCallback( |
| new WindowInsetsAnimation.Callback(DISPATCH_MODE_STOP) { |
| @NonNull |
| @Override |
| public WindowInsets onProgress(@NonNull WindowInsets insets, |
| @NonNull List<WindowInsetsAnimation> runningAnimations) { |
| return insets; |
| } |
| @Override |
| public void onEnd(@NonNull WindowInsetsAnimation animation) { |
| super.onEnd(animation); |
| if (animation.getTypeMask() == WindowInsets.Type.ime()) { |
| mEntry.mRemoteEditImeAnimatingAway = false; |
| WindowInsets editTextRootWindowInsets = mEditText.getRootWindowInsets(); |
| if (editTextRootWindowInsets == null) { |
| Log.w(TAG, "onEnd called on detached view", new Exception()); |
| } |
| mEntry.mRemoteEditImeVisible = editTextRootWindowInsets != null |
| && editTextRootWindowInsets.isVisible(WindowInsets.Type.ime()); |
| if (!mEntry.mRemoteEditImeVisible && !mEditText.mShowImeOnInputConnection) { |
| // Pass null to ensure all inputs are cleared for this entry b/227115380 |
| mController.removeRemoteInput(mEntry, null); |
| } |
| } |
| } |
| }); |
| } |
| |
| /** |
| * @deprecated TODO(b/193539698): views shouldn't have access to their controller, and places |
| * that need the controller shouldn't have access to the view |
| */ |
| @Deprecated |
| public void setController(RemoteInputViewController controller) { |
| mViewController = controller; |
| } |
| |
| /** |
| * @deprecated TODO(b/193539698): views shouldn't have access to their controller, and places |
| * that need the controller shouldn't have access to the view |
| */ |
| @Deprecated |
| public RemoteInputViewController getController() { |
| return mViewController; |
| } |
| |
| /** Clear the attachment, if present. */ |
| public void clearAttachment() { |
| setAttachment(null); |
| } |
| |
| @VisibleForTesting |
| protected void setAttachment(ContentInfo item) { |
| if (mEntry.remoteInputAttachment != null && mEntry.remoteInputAttachment != item) { |
| // We need to release permissions when sending the attachment to the target |
| // app or if it is deleted by the user. When sending to the target app, we |
| // can safely release permissions as soon as the call to |
| // `mController.grantInlineReplyUriPermission` is made (ie, after the grant |
| // to the target app has been created). |
| mEntry.remoteInputAttachment.releasePermissions(); |
| } |
| mEntry.remoteInputAttachment = item; |
| if (item != null) { |
| mEntry.remoteInputUri = item.getClip().getItemAt(0).getUri(); |
| mEntry.remoteInputMimeType = item.getClip().getDescription().getMimeType(0); |
| } |
| |
| View attachment = findViewById(R.id.remote_input_content_container); |
| ImageView iconView = findViewById(R.id.remote_input_attachment_image); |
| iconView.setImageDrawable(null); |
| if (item == null) { |
| attachment.setVisibility(GONE); |
| return; |
| } |
| iconView.setImageURI(item.getClip().getItemAt(0).getUri()); |
| if (iconView.getDrawable() == null) { |
| attachment.setVisibility(GONE); |
| } else { |
| attachment.setVisibility(VISIBLE); |
| mUiEventLogger.logWithInstanceId( |
| NotificationRemoteInputEvent.NOTIFICATION_REMOTE_INPUT_ATTACH_IMAGE, |
| mEntry.getSbn().getUid(), mEntry.getSbn().getPackageName(), |
| mEntry.getSbn().getInstanceId()); |
| } |
| updateSendButton(); |
| } |
| |
| /** Show the "sending in-progress" UI. */ |
| public void startSending() { |
| mEditText.setEnabled(false); |
| mSending = true; |
| mSendButton.setVisibility(INVISIBLE); |
| mProgressBar.setVisibility(VISIBLE); |
| mEditText.mShowImeOnInputConnection = false; |
| } |
| |
| private void sendRemoteInput() { |
| for (Runnable listener : new ArrayList<>(mOnSendListeners)) { |
| listener.run(); |
| } |
| } |
| |
| public CharSequence getText() { |
| return mEditText.getText(); |
| } |
| |
| public static RemoteInputView inflate(Context context, ViewGroup root, |
| NotificationEntry entry, |
| RemoteInputController controller) { |
| RemoteInputView v = (RemoteInputView) |
| LayoutInflater.from(context).inflate(R.layout.remote_input, root, false); |
| v.mController = controller; |
| v.mEntry = entry; |
| UserHandle user = computeTextOperationUser(entry.getSbn().getUser()); |
| v.mEditText.mUser = user; |
| v.mEditText.setTextOperationUser(user); |
| v.setTag(VIEW_TAG); |
| |
| return v; |
| } |
| |
| @Override |
| public void onClick(View v) { |
| if (v == mSendButton) { |
| sendRemoteInput(); |
| } |
| } |
| |
| @Override |
| public boolean onTouchEvent(MotionEvent event) { |
| super.onTouchEvent(event); |
| |
| // We never want for a touch to escape to an outer view or one we covered. |
| return true; |
| } |
| |
| public boolean isAnimatingAppearance() { |
| return mIsAnimatingAppearance; |
| } |
| |
| @VisibleForTesting |
| void onDefocus(boolean animate, boolean logClose, @Nullable Runnable doAfterDefocus) { |
| mController.removeRemoteInput(mEntry, mToken); |
| mEntry.remoteInputText = mEditText.getText(); |
| |
| // During removal, we get reattached and lose focus. Not hiding in that |
| // case to prevent flicker. |
| if (!mRemoved) { |
| ViewGroup parent = (ViewGroup) getParent(); |
| if (animate && parent != null && mIsFocusAnimationFlagActive) { |
| |
| ViewGroup grandParent = (ViewGroup) parent.getParent(); |
| ViewGroupOverlay overlay = parent.getOverlay(); |
| View actionsContainer = getActionsContainerLayout(); |
| int actionsContainerHeight = |
| actionsContainer != null ? actionsContainer.getHeight() : 0; |
| |
| // After adding this RemoteInputView to the overlay of the parent (and thus removing |
| // it from the parent itself), the parent will shrink in height. This causes the |
| // overlay to be moved. To correct the position of the overlay we need to offset it. |
| int overlayOffsetY = actionsContainerHeight - getHeight(); |
| overlay.add(this); |
| if (grandParent != null) grandParent.setClipChildren(false); |
| |
| Animator animator = getDefocusAnimator(actionsContainer, overlayOffsetY); |
| View self = this; |
| animator.addListener(new AnimatorListenerAdapter() { |
| @Override |
| public void onAnimationEnd(Animator animation) { |
| overlay.remove(self); |
| parent.addView(self); |
| if (grandParent != null) grandParent.setClipChildren(true); |
| setVisibility(GONE); |
| if (mWrapper != null) { |
| mWrapper.setRemoteInputVisible(false); |
| } |
| if (doAfterDefocus != null) { |
| doAfterDefocus.run(); |
| } |
| } |
| }); |
| if (actionsContainer != null) actionsContainer.setAlpha(0f); |
| animator.start(); |
| |
| } else if (animate && mRevealParams != null && mRevealParams.radius > 0) { |
| android.animation.Animator reveal = mRevealParams.createCircularHideAnimator(this); |
| reveal.setInterpolator(Interpolators.FAST_OUT_LINEAR_IN); |
| reveal.setDuration(StackStateAnimator.ANIMATION_DURATION_CLOSE_REMOTE_INPUT); |
| reveal.addListener(new android.animation.AnimatorListenerAdapter() { |
| @Override |
| public void onAnimationEnd(android.animation.Animator animation) { |
| setVisibility(GONE); |
| if (mWrapper != null) { |
| mWrapper.setRemoteInputVisible(false); |
| } |
| } |
| }); |
| reveal.start(); |
| } else { |
| setVisibility(GONE); |
| if (doAfterDefocus != null) doAfterDefocus.run(); |
| if (mWrapper != null) { |
| mWrapper.setRemoteInputVisible(false); |
| } |
| } |
| } |
| |
| if (logClose) { |
| mUiEventLogger.logWithInstanceId( |
| NotificationRemoteInputEvent.NOTIFICATION_REMOTE_INPUT_CLOSE, |
| mEntry.getSbn().getUid(), mEntry.getSbn().getPackageName(), |
| mEntry.getSbn().getInstanceId()); |
| } |
| } |
| |
| @VisibleForTesting |
| protected void setViewRootImpl(ViewRootImpl viewRoot) { |
| mTestableViewRootImpl = viewRoot; |
| } |
| |
| @VisibleForTesting |
| protected void setEditTextReferenceToSelf() { |
| mEditText.mRemoteInputView = this; |
| } |
| |
| @Override |
| protected void onAttachedToWindow() { |
| super.onAttachedToWindow(); |
| setEditTextReferenceToSelf(); |
| mEditText.setOnEditorActionListener(mEditorActionHandler); |
| mEditText.addTextChangedListener(mTextWatcher); |
| if (mEntry.getRow().isChangingPosition()) { |
| if (getVisibility() == VISIBLE && mEditText.isFocusable()) { |
| mEditText.requestFocus(); |
| } |
| } |
| } |
| |
| @Override |
| protected void onDetachedFromWindow() { |
| super.onDetachedFromWindow(); |
| mEditText.removeTextChangedListener(mTextWatcher); |
| mEditText.setOnEditorActionListener(null); |
| mEditText.mRemoteInputView = null; |
| if (mEntry.getRow().isChangingPosition() || isTemporarilyDetached()) { |
| return; |
| } |
| mController.removeRemoteInput(mEntry, mToken); |
| mController.removeSpinning(mEntry.getKey(), mToken); |
| } |
| |
| @Override |
| public ViewRootImpl getViewRootImpl() { |
| if (mTestableViewRootImpl != null) { |
| return mTestableViewRootImpl; |
| } |
| return super.getViewRootImpl(); |
| } |
| |
| private void registerBackCallback() { |
| ViewRootImpl viewRoot = getViewRootImpl(); |
| if (viewRoot == null) { |
| if (DEBUG) { |
| Log.d(TAG, "ViewRoot was null, NOT registering Predictive Back callback"); |
| } |
| return; |
| } |
| if (DEBUG) { |
| Log.d(TAG, "registering Predictive Back callback"); |
| } |
| viewRoot.getOnBackInvokedDispatcher().registerOnBackInvokedCallback( |
| OnBackInvokedDispatcher.PRIORITY_OVERLAY, mEditText.mOnBackInvokedCallback); |
| } |
| |
| private void unregisterBackCallback() { |
| ViewRootImpl viewRoot = getViewRootImpl(); |
| if (viewRoot == null) { |
| if (DEBUG) { |
| Log.d(TAG, "ViewRoot was null, NOT unregistering Predictive Back callback"); |
| } |
| return; |
| } |
| if (DEBUG) { |
| Log.d(TAG, "unregistering Predictive Back callback"); |
| } |
| viewRoot.getOnBackInvokedDispatcher().unregisterOnBackInvokedCallback( |
| mEditText.mOnBackInvokedCallback); |
| } |
| |
| @Override |
| public void onVisibilityAggregated(boolean isVisible) { |
| if (isVisible) { |
| registerBackCallback(); |
| } else { |
| unregisterBackCallback(); |
| } |
| super.onVisibilityAggregated(isVisible); |
| mEditText.setEnabled(isVisible && !mSending); |
| } |
| |
| public void setHintText(CharSequence hintText) { |
| mEditText.setHint(hintText); |
| } |
| |
| public void setSupportedMimeTypes(Collection<String> mimeTypes) { |
| mEditText.setSupportedMimeTypes(mimeTypes); |
| } |
| |
| /** Populates the text field of the remote input with the given content. */ |
| public void setEditTextContent(@Nullable CharSequence editTextContent) { |
| mEditText.setText(editTextContent); |
| } |
| |
| /** |
| * Sets whether the feature flag for the revised inline reply animation is active or not. |
| * @param active |
| */ |
| public void setIsFocusAnimationFlagActive(boolean active) { |
| mIsFocusAnimationFlagActive = active; |
| } |
| |
| /** |
| * Focuses the RemoteInputView and animates its appearance |
| */ |
| public void focusAnimated() { |
| if (!mIsFocusAnimationFlagActive && getVisibility() != VISIBLE |
| && mRevealParams != null) { |
| android.animation.Animator animator = mRevealParams.createCircularRevealAnimator(this); |
| animator.setDuration(StackStateAnimator.ANIMATION_DURATION_STANDARD); |
| animator.setInterpolator(Interpolators.LINEAR_OUT_SLOW_IN); |
| animator.start(); |
| } else if (mIsFocusAnimationFlagActive && getVisibility() != VISIBLE) { |
| mIsAnimatingAppearance = true; |
| setAlpha(0f); |
| Animator focusAnimator = getFocusAnimator(getActionsContainerLayout()); |
| focusAnimator.addListener(new AnimatorListenerAdapter() { |
| @Override |
| public void onAnimationEnd(Animator animation, boolean isReverse) { |
| mIsAnimatingAppearance = false; |
| } |
| }); |
| focusAnimator.start(); |
| } |
| focus(); |
| } |
| |
| private static UserHandle computeTextOperationUser(UserHandle notificationUser) { |
| return UserHandle.ALL.equals(notificationUser) |
| ? UserHandle.of(ActivityManager.getCurrentUser()) : notificationUser; |
| } |
| |
| public void focus() { |
| mUiEventLogger.logWithInstanceId( |
| NotificationRemoteInputEvent.NOTIFICATION_REMOTE_INPUT_OPEN, |
| mEntry.getSbn().getUid(), mEntry.getSbn().getPackageName(), |
| mEntry.getSbn().getInstanceId()); |
| |
| setVisibility(VISIBLE); |
| if (mWrapper != null) { |
| mWrapper.setRemoteInputVisible(true); |
| } |
| mEditText.setInnerFocusable(true); |
| mEditText.mShowImeOnInputConnection = true; |
| mEditText.setText(mEntry.remoteInputText); |
| mEditText.setSelection(mEditText.length()); |
| mEditText.requestFocus(); |
| mController.addRemoteInput(mEntry, mToken); |
| setAttachment(mEntry.remoteInputAttachment); |
| |
| updateSendButton(); |
| } |
| |
| public void onNotificationUpdateOrReset() { |
| boolean sending = mProgressBar.getVisibility() == VISIBLE; |
| |
| if (sending) { |
| // Update came in after we sent the reply, time to reset. |
| reset(); |
| } |
| |
| if (isActive() && mWrapper != null) { |
| mWrapper.setRemoteInputVisible(true); |
| } |
| } |
| |
| private void reset() { |
| if (mIsFocusAnimationFlagActive) { |
| mProgressBar.setVisibility(INVISIBLE); |
| mResetting = true; |
| mSending = false; |
| onDefocus(true /* animate */, false /* logClose */, () -> { |
| mEntry.remoteInputTextWhenReset = SpannedString.valueOf(mEditText.getText()); |
| mEditText.getText().clear(); |
| mEditText.setEnabled(isAggregatedVisible()); |
| mSendButton.setVisibility(VISIBLE); |
| mController.removeSpinning(mEntry.getKey(), mToken); |
| updateSendButton(); |
| setAttachment(null); |
| mResetting = false; |
| }); |
| return; |
| } |
| |
| mResetting = true; |
| mSending = false; |
| mEntry.remoteInputTextWhenReset = SpannedString.valueOf(mEditText.getText()); |
| |
| mEditText.getText().clear(); |
| mEditText.setEnabled(isAggregatedVisible()); |
| mSendButton.setVisibility(VISIBLE); |
| mProgressBar.setVisibility(INVISIBLE); |
| mController.removeSpinning(mEntry.getKey(), mToken); |
| updateSendButton(); |
| onDefocus(false /* animate */, false /* logClose */, null /* doAfterDefocus */); |
| setAttachment(null); |
| |
| mResetting = false; |
| } |
| |
| @Override |
| public boolean onRequestSendAccessibilityEvent(View child, AccessibilityEvent event) { |
| if (mResetting && child == mEditText) { |
| // Suppress text events if it happens during resetting. Ideally this would be |
| // suppressed by the text view not being shown, but that doesn't work here because it |
| // needs to stay visible for the animation. |
| return false; |
| } |
| return super.onRequestSendAccessibilityEvent(child, event); |
| } |
| |
| private void updateSendButton() { |
| mSendButton.setEnabled(mEditText.length() != 0 || mEntry.remoteInputAttachment != null); |
| } |
| |
| public void close() { |
| mEditText.defocusIfNeeded(false /* animated */); |
| } |
| |
| @Override |
| public boolean onInterceptTouchEvent(MotionEvent ev) { |
| if (ev.getAction() == MotionEvent.ACTION_DOWN) { |
| mController.requestDisallowLongPressAndDismiss(); |
| } |
| return super.onInterceptTouchEvent(ev); |
| } |
| |
| public boolean requestScrollTo() { |
| mController.lockScrollTo(mEntry); |
| return true; |
| } |
| |
| public boolean isActive() { |
| return mEditText.isFocused() && mEditText.isEnabled(); |
| } |
| |
| public void setRemoved() { |
| mRemoved = true; |
| } |
| |
| public void setRevealParameters(@Nullable RevealParams revealParams) { |
| mRevealParams = revealParams; |
| } |
| |
| @Override |
| public void dispatchStartTemporaryDetach() { |
| super.dispatchStartTemporaryDetach(); |
| // Detach the EditText temporarily such that it doesn't get onDetachedFromWindow and |
| // won't lose IME focus. |
| final int iEditText = indexOfChild(mEditText); |
| if (iEditText != -1) { |
| detachViewFromParent(iEditText); |
| } |
| } |
| |
| @Override |
| public void dispatchFinishTemporaryDetach() { |
| if (isAttachedToWindow()) { |
| attachViewToParent(mEditText, 0, mEditText.getLayoutParams()); |
| } else { |
| removeDetachedView(mEditText, false /* animate */); |
| } |
| super.dispatchFinishTemporaryDetach(); |
| } |
| |
| public void setWrapper(NotificationViewWrapper wrapper) { |
| mWrapper = wrapper; |
| } |
| |
| /** |
| * Register a listener to be notified when this view's visibility changes. |
| * |
| * Specifically, the passed {@link Consumer} will receive {@code true} when |
| * {@link #getVisibility()} would return {@link View#VISIBLE}, and {@code false} it would return |
| * any other value. |
| */ |
| public void addOnVisibilityChangedListener(Consumer<Boolean> listener) { |
| mOnVisibilityChangedListeners.add(listener); |
| } |
| |
| /** |
| * Unregister a listener previously registered via |
| * {@link #addOnVisibilityChangedListener(Consumer)}. |
| */ |
| public void removeOnVisibilityChangedListener(Consumer<Boolean> listener) { |
| mOnVisibilityChangedListeners.remove(listener); |
| } |
| |
| @Override |
| protected void onVisibilityChanged(View changedView, int visibility) { |
| super.onVisibilityChanged(changedView, visibility); |
| if (changedView == this) { |
| for (Consumer<Boolean> listener : new ArrayList<>(mOnVisibilityChangedListeners)) { |
| listener.accept(visibility == VISIBLE); |
| } |
| // Hide soft-keyboard when the input view became invisible |
| // (i.e. The notification shade collapsed by pressing the home key) |
| if (visibility != VISIBLE && !mController.isRemoteInputActive()) { |
| mEditText.hideIme(); |
| } |
| } |
| } |
| |
| public boolean isSending() { |
| return getVisibility() == VISIBLE && mController.isSpinning(mEntry.getKey(), mToken); |
| } |
| |
| /** Registers a listener for focus-change events on the EditText */ |
| public void addOnEditTextFocusChangedListener(View.OnFocusChangeListener listener) { |
| mEditTextFocusChangeListeners.add(listener); |
| } |
| |
| /** Removes a previously-added listener for focus-change events on the EditText */ |
| public void removeOnEditTextFocusChangedListener(View.OnFocusChangeListener listener) { |
| mEditTextFocusChangeListeners.remove(listener); |
| } |
| |
| /** Determines if the EditText has focus. */ |
| public boolean editTextHasFocus() { |
| return mEditText != null && mEditText.hasFocus(); |
| } |
| |
| private void onEditTextFocusChanged(RemoteEditText remoteEditText, boolean focused) { |
| for (View.OnFocusChangeListener listener : new ArrayList<>(mEditTextFocusChangeListeners)) { |
| listener.onFocusChange(remoteEditText, focused); |
| } |
| } |
| |
| /** Registers a listener for send events on this RemoteInputView */ |
| public void addOnSendRemoteInputListener(Runnable listener) { |
| mOnSendListeners.add(listener); |
| } |
| |
| /** Removes a previously-added listener for send events on this RemoteInputView */ |
| public void removeOnSendRemoteInputListener(Runnable listener) { |
| mOnSendListeners.remove(listener); |
| } |
| |
| @Override |
| protected void onLayout(boolean changed, int l, int t, int r, int b) { |
| super.onLayout(changed, l, t, r, b); |
| if (mIsFocusAnimationFlagActive) setPivotY(getMeasuredHeight()); |
| if (mContentBackgroundBounds != null) { |
| mContentBackground.setBounds(mContentBackgroundBounds); |
| } |
| } |
| |
| /** |
| * @return action button container view (i.e. ViewGroup containing Reply button etc.) |
| */ |
| public View getActionsContainerLayout() { |
| ViewGroup parentView = (ViewGroup) getParent(); |
| if (parentView == null) return null; |
| return parentView.findViewById(com.android.internal.R.id.actions_container_layout); |
| } |
| |
| /** |
| * Creates an animator for the focus animation. |
| * |
| * @param fadeOutView View that will be faded out during the focus animation. |
| */ |
| private Animator getFocusAnimator(@Nullable View fadeOutView) { |
| final AnimatorSet animatorSet = new AnimatorSet(); |
| |
| final Animator alphaAnimator = ObjectAnimator.ofFloat(this, View.ALPHA, 0f, 1f); |
| alphaAnimator.setStartDelay(FOCUS_ANIMATION_FADE_IN_DELAY); |
| alphaAnimator.setDuration(FOCUS_ANIMATION_FADE_IN_DURATION); |
| alphaAnimator.setInterpolator(InterpolatorsAndroidX.LINEAR); |
| |
| ValueAnimator scaleAnimator = ValueAnimator.ofFloat(FOCUS_ANIMATION_MIN_SCALE, 1f); |
| scaleAnimator.addUpdateListener(valueAnimator -> { |
| setFocusAnimationScaleY((float) scaleAnimator.getAnimatedValue(), 0); |
| }); |
| scaleAnimator.setDuration(FOCUS_ANIMATION_TOTAL_DURATION); |
| scaleAnimator.setInterpolator(InterpolatorsAndroidX.FAST_OUT_SLOW_IN); |
| |
| if (fadeOutView == null) { |
| animatorSet.playTogether(alphaAnimator, scaleAnimator); |
| } else { |
| final Animator fadeOutViewAlphaAnimator = |
| ObjectAnimator.ofFloat(fadeOutView, View.ALPHA, 1f, 0f); |
| fadeOutViewAlphaAnimator.setDuration(FOCUS_ANIMATION_CROSSFADE_DURATION); |
| fadeOutViewAlphaAnimator.setInterpolator(InterpolatorsAndroidX.LINEAR); |
| animatorSet.addListener(new AnimatorListenerAdapter() { |
| @Override |
| public void onAnimationEnd(Animator animation, boolean isReverse) { |
| fadeOutView.setAlpha(1f); |
| } |
| }); |
| animatorSet.playTogether(alphaAnimator, scaleAnimator, fadeOutViewAlphaAnimator); |
| } |
| return animatorSet; |
| } |
| |
| /** |
| * Creates an animator for the defocus animation. |
| * |
| * @param fadeInView View that will be faded in during the defocus animation. |
| * @param offsetY The RemoteInputView will be offset by offsetY during the animation |
| */ |
| private Animator getDefocusAnimator(@Nullable View fadeInView, int offsetY) { |
| final AnimatorSet animatorSet = new AnimatorSet(); |
| |
| final Animator alphaAnimator = ObjectAnimator.ofFloat(this, View.ALPHA, 1f, 0f); |
| alphaAnimator.setDuration(FOCUS_ANIMATION_FADE_IN_DURATION); |
| alphaAnimator.setStartDelay(DEFOCUS_ANIMATION_FADE_OUT_DELAY); |
| alphaAnimator.setInterpolator(InterpolatorsAndroidX.LINEAR); |
| |
| ValueAnimator scaleAnimator = ValueAnimator.ofFloat(1f, FOCUS_ANIMATION_MIN_SCALE); |
| scaleAnimator.addUpdateListener(valueAnimator -> { |
| setFocusAnimationScaleY((float) scaleAnimator.getAnimatedValue(), offsetY); |
| }); |
| scaleAnimator.setDuration(FOCUS_ANIMATION_TOTAL_DURATION); |
| scaleAnimator.setInterpolator(InterpolatorsAndroidX.FAST_OUT_SLOW_IN); |
| scaleAnimator.addListener(new AnimatorListenerAdapter() { |
| @Override |
| public void onAnimationEnd(Animator animation, boolean isReverse) { |
| setFocusAnimationScaleY(1f /* scaleY */, 0 /* verticalOffset */); |
| } |
| }); |
| |
| if (fadeInView == null) { |
| animatorSet.playTogether(alphaAnimator, scaleAnimator); |
| } else { |
| fadeInView.forceHasOverlappingRendering(false); |
| Animator fadeInViewAlphaAnimator = |
| ObjectAnimator.ofFloat(fadeInView, View.ALPHA, 0f, 1f); |
| fadeInViewAlphaAnimator.setDuration(FOCUS_ANIMATION_FADE_IN_DURATION); |
| fadeInViewAlphaAnimator.setInterpolator(InterpolatorsAndroidX.LINEAR); |
| fadeInViewAlphaAnimator.setStartDelay(DEFOCUS_ANIMATION_CROSSFADE_DELAY); |
| animatorSet.playTogether(alphaAnimator, scaleAnimator, fadeInViewAlphaAnimator); |
| } |
| return animatorSet; |
| } |
| |
| /** |
| * Sets affected view properties for a vertical scale animation |
| * |
| * @param scaleY desired vertical view scale |
| * @param verticalOffset vertical offset to apply to the RemoteInputView during the animation |
| */ |
| private void setFocusAnimationScaleY(float scaleY, int verticalOffset) { |
| int verticalBoundOffset = (int) ((1f - scaleY) * 0.5f * mContentView.getHeight()); |
| Rect contentBackgroundBounds = new Rect(0, verticalBoundOffset, mContentView.getWidth(), |
| mContentView.getHeight() - verticalBoundOffset); |
| mContentBackground.setBounds(contentBackgroundBounds); |
| mContentView.setBackground(mContentBackground); |
| if (scaleY == 1f) { |
| mContentBackgroundBounds = null; |
| } else { |
| mContentBackgroundBounds = contentBackgroundBounds; |
| } |
| setTranslationY(verticalBoundOffset + verticalOffset); |
| } |
| |
| /** Handler for button click on send action in IME. */ |
| private class EditorActionHandler implements TextView.OnEditorActionListener { |
| |
| @Override |
| public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { |
| final boolean isSoftImeEvent = event == null |
| && (actionId == EditorInfo.IME_ACTION_DONE |
| || actionId == EditorInfo.IME_ACTION_NEXT |
| || actionId == EditorInfo.IME_ACTION_SEND); |
| final boolean isKeyboardEnterKey = event != null |
| && KeyEvent.isConfirmKey(event.getKeyCode()) |
| && event.getAction() == KeyEvent.ACTION_DOWN; |
| |
| if (isSoftImeEvent || isKeyboardEnterKey) { |
| if (mEditText.length() > 0 || mEntry.remoteInputAttachment != null) { |
| sendRemoteInput(); |
| } |
| // Consume action to prevent IME from closing. |
| return true; |
| } |
| return false; |
| } |
| } |
| |
| /** Observes text change events and updates the visibility of the send button accordingly. */ |
| private class SendButtonTextWatcher implements TextWatcher { |
| |
| @Override |
| public void beforeTextChanged(CharSequence s, int start, int count, int after) {} |
| |
| @Override |
| public void onTextChanged(CharSequence s, int start, int before, int count) {} |
| |
| @Override |
| public void afterTextChanged(Editable s) { |
| updateSendButton(); |
| } |
| } |
| |
| /** |
| * An EditText that changes appearance based on whether it's focusable and becomes |
| * un-focusable whenever the user navigates away from it or it becomes invisible. |
| */ |
| public static class RemoteEditText extends EditText { |
| |
| private final OnReceiveContentListener mOnReceiveContentListener = this::onReceiveContent; |
| |
| private RemoteInputView mRemoteInputView; |
| boolean mShowImeOnInputConnection; |
| private LightBarController mLightBarController; |
| private InputMethodManager mInputMethodManager; |
| private ArraySet<String> mSupportedMimes = new ArraySet<>(); |
| UserHandle mUser; |
| |
| public RemoteEditText(Context context, AttributeSet attrs) { |
| super(context, attrs); |
| mLightBarController = Dependency.get(LightBarController.class); |
| } |
| |
| void setSupportedMimeTypes(@Nullable Collection<String> mimeTypes) { |
| String[] types = null; |
| OnReceiveContentListener listener = null; |
| if (mimeTypes != null && !mimeTypes.isEmpty()) { |
| types = mimeTypes.toArray(new String[0]); |
| listener = mOnReceiveContentListener; |
| } |
| setOnReceiveContentListener(types, listener); |
| mSupportedMimes.clear(); |
| mSupportedMimes.addAll(mimeTypes); |
| } |
| |
| private void hideIme() { |
| final WindowInsetsController insetsController = getWindowInsetsController(); |
| if (insetsController != null) { |
| insetsController.hide(WindowInsets.Type.ime()); |
| } |
| } |
| |
| private void defocusIfNeeded(boolean animate) { |
| if (mRemoteInputView != null && mRemoteInputView.mEntry.getRow().isChangingPosition() |
| || isTemporarilyDetached()) { |
| if (isTemporarilyDetached()) { |
| // We might get reattached but then the other one of HUN / expanded might steal |
| // our focus, so we'll need to save our text here. |
| if (mRemoteInputView != null) { |
| mRemoteInputView.mEntry.remoteInputText = getText(); |
| } |
| } |
| return; |
| } |
| if (isFocusable() && isEnabled()) { |
| setInnerFocusable(false); |
| if (mRemoteInputView != null) { |
| mRemoteInputView |
| .onDefocus(animate, true /* logClose */, null /* doAfterDefocus */); |
| } |
| mShowImeOnInputConnection = false; |
| } |
| } |
| |
| @Override |
| protected void onVisibilityChanged(View changedView, int visibility) { |
| super.onVisibilityChanged(changedView, visibility); |
| |
| if (!isShown()) { |
| defocusIfNeeded(false /* animate */); |
| } |
| } |
| |
| @Override |
| protected void onFocusChanged(boolean focused, int direction, Rect previouslyFocusedRect) { |
| super.onFocusChanged(focused, direction, previouslyFocusedRect); |
| if (mRemoteInputView != null) { |
| mRemoteInputView.onEditTextFocusChanged(this, focused); |
| } |
| if (!focused) { |
| defocusIfNeeded(true /* animate */); |
| } |
| if (mRemoteInputView != null && !mRemoteInputView.mRemoved) { |
| mLightBarController.setDirectReplying(focused); |
| } |
| } |
| |
| @Override |
| public void getFocusedRect(Rect r) { |
| super.getFocusedRect(r); |
| r.top = mScrollY; |
| r.bottom = mScrollY + (mBottom - mTop); |
| } |
| |
| @Override |
| public boolean requestRectangleOnScreen(Rect rectangle) { |
| return mRemoteInputView.requestScrollTo(); |
| } |
| |
| @Override |
| public boolean onKeyDown(int keyCode, KeyEvent event) { |
| if (keyCode == KeyEvent.KEYCODE_BACK) { |
| // Eat the DOWN event here to prevent any default behavior. |
| return true; |
| } |
| return super.onKeyDown(keyCode, event); |
| } |
| |
| private final OnBackInvokedCallback mOnBackInvokedCallback = () -> { |
| if (DEBUG) { |
| Log.d(TAG, "Predictive Back Callback dispatched"); |
| } |
| respondToKeycodeBack(); |
| }; |
| |
| private void respondToKeycodeBack() { |
| defocusIfNeeded(true /* animate */); |
| } |
| |
| @Override |
| public boolean onKeyUp(int keyCode, KeyEvent event) { |
| if (keyCode == KeyEvent.KEYCODE_BACK) { |
| respondToKeycodeBack(); |
| return true; |
| } |
| return super.onKeyUp(keyCode, event); |
| } |
| |
| @Override |
| public boolean onKeyPreIme(int keyCode, KeyEvent event) { |
| // When BACK key is pressed, this method would be invoked twice. |
| if (event.getKeyCode() == KeyEvent.KEYCODE_BACK && |
| event.getAction() == KeyEvent.ACTION_UP) { |
| defocusIfNeeded(true /* animate */); |
| } |
| return super.onKeyPreIme(keyCode, event); |
| } |
| |
| @Override |
| public boolean onCheckIsTextEditor() { |
| // Stop being editable while we're being removed. During removal, we get reattached, |
| // and editable views get their spellchecking state re-evaluated which is too costly |
| // during the removal animation. |
| boolean flyingOut = mRemoteInputView != null && mRemoteInputView.mRemoved; |
| return !flyingOut && super.onCheckIsTextEditor(); |
| } |
| |
| @Override |
| public InputConnection onCreateInputConnection(EditorInfo outAttrs) { |
| final InputConnection ic = super.onCreateInputConnection(outAttrs); |
| Context userContext = null; |
| try { |
| userContext = mContext.createPackageContextAsUser( |
| mContext.getPackageName(), 0, mUser); |
| } catch (PackageManager.NameNotFoundException e) { |
| Log.e(TAG, "Unable to create user context:" + e.getMessage(), e); |
| } |
| |
| if (mShowImeOnInputConnection && ic != null) { |
| Context targetContext = userContext != null ? userContext : getContext(); |
| mInputMethodManager = targetContext.getSystemService(InputMethodManager.class); |
| if (mInputMethodManager != null) { |
| // onCreateInputConnection is called by InputMethodManager in the middle of |
| // setting up the connection to the IME; wait with requesting the IME until that |
| // work has completed. |
| post(new Runnable() { |
| @Override |
| public void run() { |
| mInputMethodManager.viewClicked(RemoteEditText.this); |
| mInputMethodManager.showSoftInput(RemoteEditText.this, 0); |
| } |
| }); |
| } |
| } |
| |
| return ic; |
| } |
| |
| @Override |
| public void onCommitCompletion(CompletionInfo text) { |
| clearComposingText(); |
| setText(text.getText()); |
| setSelection(getText().length()); |
| } |
| |
| void setInnerFocusable(boolean focusable) { |
| setFocusableInTouchMode(focusable); |
| setFocusable(focusable); |
| setCursorVisible(focusable); |
| |
| if (focusable) { |
| requestFocus(); |
| } |
| } |
| |
| private ContentInfo onReceiveContent(View view, ContentInfo payload) { |
| Pair<ContentInfo, ContentInfo> split = |
| payload.partition(item -> item.getUri() != null); |
| ContentInfo uriItems = split.first; |
| ContentInfo remainingItems = split.second; |
| if (uriItems != null) { |
| mRemoteInputView.setAttachment(uriItems); |
| } |
| return remainingItems; |
| } |
| |
| } |
| |
| public static class RevealParams { |
| final int centerX; |
| final int centerY; |
| final int radius; |
| |
| public RevealParams(int centerX, int centerY, int radius) { |
| this.centerX = centerX; |
| this.centerY = centerY; |
| this.radius = radius; |
| } |
| |
| android.animation.Animator createCircularHideAnimator(View view) { |
| return ViewAnimationUtils.createCircularReveal(view, centerX, centerY, radius, 0); |
| } |
| |
| android.animation.Animator createCircularRevealAnimator(View view) { |
| return ViewAnimationUtils.createCircularReveal(view, centerX, centerY, 0, radius); |
| } |
| } |
| } |