blob: 53ac65700a05141b33987ef90ec7ee63fe60d1d3 [file] [log] [blame]
/*
* 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 android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.annotation.Nullable;
import android.app.ActivityManager;
import android.app.Notification;
import android.app.PendingIntent;
import android.app.RemoteInput;
import android.content.ClipDescription;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.pm.ShortcutManager;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Bundle;
import android.os.ServiceManager;
import android.os.SystemClock;
import android.os.UserHandle;
import android.text.Editable;
import android.text.SpannedString;
import android.text.TextWatcher;
import android.util.AttributeSet;
import android.util.Log;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewAnimationUtils;
import android.view.ViewGroup;
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.LinearLayout;
import android.widget.ProgressBar;
import android.widget.TextView;
import androidx.core.view.inputmethod.InputConnectionCompat;
import androidx.core.view.inputmethod.InputContentInfoCompat;
import com.android.internal.logging.MetricsLogger;
import com.android.internal.logging.nano.MetricsProto;
import com.android.internal.statusbar.IStatusBarService;
import com.android.systemui.Dependency;
import com.android.systemui.Interpolators;
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.collection.NotificationEntry.EditedSuggestionInfo;
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 java.util.HashMap;
import java.util.function.Consumer;
/**
* Host for the remote input.
*/
public class RemoteInputView extends LinearLayout implements View.OnClickListener, TextWatcher {
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();
public final Object mToken = new Object();
private RemoteEditText mEditText;
private ImageButton mSendButton;
private ProgressBar mProgressBar;
private PendingIntent mPendingIntent;
private RemoteInput[] mRemoteInputs;
private RemoteInput mRemoteInput;
private RemoteInputController mController;
private RemoteInputQuickSettingsDisabler mRemoteInputQuickSettingsDisabler;
private IStatusBarService mStatusBarManagerService;
private NotificationEntry mEntry;
private boolean mRemoved;
private int mRevealCx;
private int mRevealCy;
private int mRevealR;
private boolean mResetting;
private NotificationViewWrapper mWrapper;
private Consumer<Boolean> mOnVisibilityChangedListener;
public RemoteInputView(Context context, AttributeSet attrs) {
super(context, attrs);
mRemoteInputQuickSettingsDisabler = Dependency.get(RemoteInputQuickSettingsDisabler.class);
mStatusBarManagerService = IStatusBarService.Stub.asInterface(
ServiceManager.getService(Context.STATUS_BAR_SERVICE));
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
mProgressBar = findViewById(R.id.remote_input_progress);
mSendButton = findViewById(R.id.remote_input_send);
mSendButton.setOnClickListener(this);
mEditText = (RemoteEditText) getChildAt(0);
mEditText.setOnEditorActionListener(new 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) {
sendRemoteInput(prepareRemoteInputFromText());
}
// Consume action to prevent IME from closing.
return true;
}
return false;
}
});
mEditText.addTextChangedListener(this);
mEditText.setInnerFocusable(false);
mEditText.mRemoteInputView = this;
}
protected Intent prepareRemoteInputFromText() {
Bundle results = new Bundle();
results.putString(mRemoteInput.getResultKey(), mEditText.getText().toString());
Intent fillInIntent = new Intent().addFlags(Intent.FLAG_RECEIVER_FOREGROUND);
RemoteInput.addResultsToIntent(mRemoteInputs, fillInIntent,
results);
mEntry.remoteInputText = mEditText.getText();
mEntry.remoteInputUri = null;
mEntry.remoteInputMimeType = null;
if (mEntry.editedSuggestionInfo == null) {
RemoteInput.setResultsSource(fillInIntent, RemoteInput.SOURCE_FREE_FORM_INPUT);
} else {
RemoteInput.setResultsSource(fillInIntent, RemoteInput.SOURCE_CHOICE);
}
return fillInIntent;
}
protected Intent prepareRemoteInputFromData(String contentType, Uri data) {
HashMap<String, Uri> results = new HashMap<>();
results.put(contentType, data);
mController.grantInlineReplyUriPermission(mEntry.getSbn(), data);
Intent fillInIntent = new Intent().addFlags(Intent.FLAG_RECEIVER_FOREGROUND);
RemoteInput.addDataResultToIntent(mRemoteInput, fillInIntent, results);
mEntry.remoteInputText = mContext.getString(R.string.remote_input_image_insertion_text);
mEntry.remoteInputMimeType = contentType;
mEntry.remoteInputUri = data;
return fillInIntent;
}
private void sendRemoteInput(Intent intent) {
mEditText.setEnabled(false);
mSendButton.setVisibility(INVISIBLE);
mProgressBar.setVisibility(VISIBLE);
mEntry.lastRemoteInputSent = SystemClock.elapsedRealtime();
mController.addSpinning(mEntry.getKey(), mToken);
mController.removeRemoteInput(mEntry, mToken);
mEditText.mShowImeOnInputConnection = false;
mController.remoteInputSent(mEntry);
mEntry.setHasSentReply();
// Tell ShortcutManager that this package has been "activated". ShortcutManager
// will reset the throttling for this package.
// Strictly speaking, the intent receiver may be different from the notification publisher,
// but that's an edge case, and also because we can't always know which package will receive
// an intent, so we just reset for the publisher.
getContext().getSystemService(ShortcutManager.class).onApplicationActive(
mEntry.getSbn().getPackageName(),
mEntry.getSbn().getUser().getIdentifier());
MetricsLogger.action(mContext, MetricsProto.MetricsEvent.ACTION_REMOTE_INPUT_SEND,
mEntry.getSbn().getPackageName());
try {
mPendingIntent.send(mContext, 0, intent);
} catch (PendingIntent.CanceledException e) {
Log.i(TAG, "Unable to send remote input result", e);
MetricsLogger.action(mContext, MetricsProto.MetricsEvent.ACTION_REMOTE_INPUT_FAIL,
mEntry.getSbn().getPackageName());
}
}
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(prepareRemoteInputFromText());
}
}
@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;
}
private void onDefocus(boolean animate) {
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) {
if (animate && mRevealR > 0) {
Animator reveal = ViewAnimationUtils.createCircularReveal(
this, mRevealCx, mRevealCy, mRevealR, 0);
reveal.setInterpolator(Interpolators.FAST_OUT_LINEAR_IN);
reveal.setDuration(StackStateAnimator.ANIMATION_DURATION_CLOSE_REMOTE_INPUT);
reveal.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
setVisibility(INVISIBLE);
if (mWrapper != null) {
mWrapper.setRemoteInputVisible(false);
}
}
});
reveal.start();
} else {
setVisibility(INVISIBLE);
if (mWrapper != null) {
mWrapper.setRemoteInputVisible(false);
}
}
}
mRemoteInputQuickSettingsDisabler.setRemoteInputActive(false);
MetricsLogger.action(mContext, MetricsProto.MetricsEvent.ACTION_REMOTE_INPUT_CLOSE,
mEntry.getSbn().getPackageName());
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
if (mEntry.getRow().isChangingPosition()) {
if (getVisibility() == VISIBLE && mEditText.isFocusable()) {
mEditText.requestFocus();
}
}
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
if (mEntry.getRow().isChangingPosition() || isTemporarilyDetached()) {
return;
}
mController.removeRemoteInput(mEntry, mToken);
mController.removeSpinning(mEntry.getKey(), mToken);
}
public void setPendingIntent(PendingIntent pendingIntent) {
mPendingIntent = pendingIntent;
}
/**
* Sets the remote input for this view.
*
* @param remoteInputs The remote inputs that need to be sent to the app.
* @param remoteInput The remote input that needs to be activated.
* @param editedSuggestionInfo The smart reply that should be inserted in the remote input, or
* {@code null} if the user is not editing a smart reply.
*/
public void setRemoteInput(RemoteInput[] remoteInputs, RemoteInput remoteInput,
@Nullable EditedSuggestionInfo editedSuggestionInfo) {
mRemoteInputs = remoteInputs;
mRemoteInput = remoteInput;
mEditText.setHint(mRemoteInput.getLabel());
mEntry.editedSuggestionInfo = editedSuggestionInfo;
if (editedSuggestionInfo != null) {
mEntry.remoteInputText = editedSuggestionInfo.originalText;
}
}
public void focusAnimated() {
if (getVisibility() != VISIBLE) {
Animator animator = ViewAnimationUtils.createCircularReveal(
this, mRevealCx, mRevealCy, 0, mRevealR);
animator.setDuration(StackStateAnimator.ANIMATION_DURATION_STANDARD);
animator.setInterpolator(Interpolators.LINEAR_OUT_SLOW_IN);
animator.start();
}
focus();
}
private static UserHandle computeTextOperationUser(UserHandle notificationUser) {
return UserHandle.ALL.equals(notificationUser)
? UserHandle.of(ActivityManager.getCurrentUser()) : notificationUser;
}
public void focus() {
MetricsLogger.action(mContext, MetricsProto.MetricsEvent.ACTION_REMOTE_INPUT_OPEN,
mEntry.getSbn().getPackageName());
setVisibility(VISIBLE);
if (mWrapper != null) {
mWrapper.setRemoteInputVisible(true);
}
mEditText.setInnerFocusable(true);
mEditText.mShowImeOnInputConnection = true;
mEditText.setText(mEntry.remoteInputText);
mEditText.setSelection(mEditText.getText().length());
mEditText.requestFocus();
mController.addRemoteInput(mEntry, mToken);
mRemoteInputQuickSettingsDisabler.setRemoteInputActive(true);
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() {
mResetting = true;
mEntry.remoteInputTextWhenReset = SpannedString.valueOf(mEditText.getText());
mEditText.getText().clear();
mEditText.setEnabled(true);
mSendButton.setVisibility(VISIBLE);
mProgressBar.setVisibility(INVISIBLE);
mController.removeSpinning(mEntry.getKey(), mToken);
updateSendButton();
onDefocus(false /* animate */);
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.getText().length() != 0);
}
@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();
}
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 stealFocusFrom(RemoteInputView other) {
other.close();
setPendingIntent(other.mPendingIntent);
setRemoteInput(other.mRemoteInputs, other.mRemoteInput, mEntry.editedSuggestionInfo);
setRevealParameters(other.mRevealCx, other.mRevealCy, other.mRevealR);
focus();
}
/**
* Tries to find an action in {@param actions} that matches the current pending intent
* of this view and updates its state to that of the found action
*
* @return true if a matching action was found, false otherwise
*/
public boolean updatePendingIntentFromActions(Notification.Action[] actions) {
if (mPendingIntent == null || actions == null) {
return false;
}
Intent current = mPendingIntent.getIntent();
if (current == null) {
return false;
}
for (Notification.Action a : actions) {
RemoteInput[] inputs = a.getRemoteInputs();
if (a.actionIntent == null || inputs == null) {
continue;
}
Intent candidate = a.actionIntent.getIntent();
if (!current.filterEquals(candidate)) {
continue;
}
RemoteInput input = null;
for (RemoteInput i : inputs) {
if (i.getAllowFreeFormInput()) {
input = i;
}
}
if (input == null) {
continue;
}
setPendingIntent(a.actionIntent);
setRemoteInput(inputs, input, null /* editedSuggestionInfo*/);
return true;
}
return false;
}
public PendingIntent getPendingIntent() {
return mPendingIntent;
}
public void setRemoved() {
mRemoved = true;
}
public void setRevealParameters(int cx, int cy, int r) {
mRevealCx = cx;
mRevealCy = cy;
mRevealR = r;
}
@Override
public void dispatchStartTemporaryDetach() {
super.dispatchStartTemporaryDetach();
// Detach the EditText temporarily such that it doesn't get onDetachedFromWindow and
// won't lose IME focus.
detachViewFromParent(mEditText);
}
@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;
}
public void setOnVisibilityChangedListener(Consumer<Boolean> visibilityChangedListener) {
mOnVisibilityChangedListener = visibilityChangedListener;
}
@Override
protected void onVisibilityChanged(View changedView, int visibility) {
super.onVisibilityChanged(changedView, visibility);
if (changedView == this && mOnVisibilityChangedListener != null) {
mOnVisibilityChangedListener.accept(visibility == VISIBLE);
}
}
public boolean isSending() {
return getVisibility() == VISIBLE && mController.isSpinning(mEntry.getKey(), mToken);
}
/**
* 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 Drawable mBackground;
private RemoteInputView mRemoteInputView;
boolean mShowImeOnInputConnection;
private LightBarController mLightBarController;
UserHandle mUser;
public RemoteEditText(Context context, AttributeSet attrs) {
super(context, attrs);
mBackground = getBackground();
mLightBarController = Dependency.get(LightBarController.class);
}
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);
}
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 (!focused) {
defocusIfNeeded(true /* animate */);
}
if (!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);
}
@Override
public boolean onKeyUp(int keyCode, KeyEvent event) {
if (keyCode == KeyEvent.KEYCODE_BACK) {
defocusIfNeeded(true /* animate */);
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) {
// TODO: Pass RemoteInput data types to allow image insertion.
// String[] allowedDataTypes = mRemoteInputView.mRemoteInput.getAllowedDataTypes()
// .toArray(new String[0]);
// EditorInfoCompat.setContentMimeTypes(outAttrs, allowedDataTypes);
final InputConnection inputConnection = super.onCreateInputConnection(outAttrs);
final InputConnectionCompat.OnCommitContentListener callback =
new InputConnectionCompat.OnCommitContentListener() {
@Override
public boolean onCommitContent(
InputContentInfoCompat inputContentInfoCompat, int i,
Bundle bundle) {
Uri contentUri = inputContentInfoCompat.getContentUri();
ClipDescription description = inputContentInfoCompat.getDescription();
String mimeType = null;
if (description != null && description.getMimeTypeCount() > 0) {
mimeType = description.getMimeType(0);
}
if (mimeType != null) {
Intent dataIntent = mRemoteInputView.prepareRemoteInputFromData(
mimeType, contentUri);
mRemoteInputView.sendRemoteInput(dataIntent);
}
return true;
}
};
InputConnection ic = inputConnection == null ? null :
InputConnectionCompat.createWrapper(inputConnection, outAttrs, callback);
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();
final InputMethodManager imm =
targetContext.getSystemService(InputMethodManager.class);
if (imm != 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() {
imm.viewClicked(RemoteEditText.this);
imm.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();
setBackground(mBackground);
} else {
setBackground(null);
}
}
}
}