/* * 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.internal.annotations.VisibleForTesting; import com.android.internal.graphics.ColorUtils; import com.android.internal.logging.UiEvent; import com.android.internal.logging.UiEventLogger; import com.android.systemui.Dependency; import com.android.systemui.R; import com.android.systemui.animation.InterpolatorsAndroidX; 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 mOnSendListeners = new ArrayList<>(); private final ArrayList> mOnVisibilityChangedListeners = new ArrayList<>(); private final ArrayList 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 = Notification.Builder.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 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 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 listener) { mOnVisibilityChangedListeners.add(listener); } /** * Unregister a listener previously registered via * {@link #addOnVisibilityChangedListener(Consumer)}. */ public void removeOnVisibilityChangedListener(Consumer listener) { mOnVisibilityChangedListeners.remove(listener); } @Override protected void onVisibilityChanged(View changedView, int visibility) { super.onVisibilityChanged(changedView, visibility); if (changedView == this) { for (Consumer 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 mSupportedMimes = new ArraySet<>(); UserHandle mUser; public RemoteEditText(Context context, AttributeSet attrs) { super(context, attrs); mLightBarController = Dependency.get(LightBarController.class); } void setSupportedMimeTypes(@Nullable Collection 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 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); } } }