1 /* 2 * Copyright (C) 2015 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License 15 */ 16 17 package com.android.systemui.statusbar.policy; 18 19 import android.animation.Animator; 20 import android.animation.AnimatorListenerAdapter; 21 import android.app.Notification; 22 import android.app.PendingIntent; 23 import android.app.RemoteInput; 24 import android.content.Context; 25 import android.content.Intent; 26 import android.content.pm.ShortcutManager; 27 import android.graphics.Rect; 28 import android.graphics.drawable.Drawable; 29 import android.os.Bundle; 30 import android.os.SystemClock; 31 import android.text.Editable; 32 import android.text.SpannedString; 33 import android.text.TextWatcher; 34 import android.util.AttributeSet; 35 import android.util.Log; 36 import android.view.KeyEvent; 37 import android.view.LayoutInflater; 38 import android.view.MotionEvent; 39 import android.view.View; 40 import android.view.ViewAnimationUtils; 41 import android.view.ViewGroup; 42 import android.view.ViewParent; 43 import android.view.accessibility.AccessibilityEvent; 44 import android.view.inputmethod.CompletionInfo; 45 import android.view.inputmethod.EditorInfo; 46 import android.view.inputmethod.InputConnection; 47 import android.view.inputmethod.InputMethodManager; 48 import android.widget.EditText; 49 import android.widget.ImageButton; 50 import android.widget.LinearLayout; 51 import android.widget.ProgressBar; 52 import android.widget.TextView; 53 54 import com.android.internal.logging.MetricsLogger; 55 import com.android.internal.logging.nano.MetricsProto; 56 import com.android.systemui.Dependency; 57 import com.android.systemui.Interpolators; 58 import com.android.systemui.R; 59 import com.android.systemui.statusbar.NotificationData; 60 import com.android.systemui.statusbar.RemoteInputController; 61 import com.android.systemui.statusbar.notification.NotificationViewWrapper; 62 import com.android.systemui.statusbar.stack.StackStateAnimator; 63 64 import java.util.function.Consumer; 65 66 /** 67 * Host for the remote input. 68 */ 69 public class RemoteInputView extends LinearLayout implements View.OnClickListener, TextWatcher { 70 71 private static final String TAG = "RemoteInput"; 72 73 // A marker object that let's us easily find views of this class. 74 public static final Object VIEW_TAG = new Object(); 75 76 public final Object mToken = new Object(); 77 78 private RemoteEditText mEditText; 79 private ImageButton mSendButton; 80 private ProgressBar mProgressBar; 81 private PendingIntent mPendingIntent; 82 private RemoteInput[] mRemoteInputs; 83 private RemoteInput mRemoteInput; 84 private RemoteInputController mController; 85 private RemoteInputQuickSettingsDisabler mRemoteInputQuickSettingsDisabler; 86 87 private NotificationData.Entry mEntry; 88 89 private boolean mRemoved; 90 91 private int mRevealCx; 92 private int mRevealCy; 93 private int mRevealR; 94 95 private boolean mResetting; 96 private NotificationViewWrapper mWrapper; 97 private Consumer<Boolean> mOnVisibilityChangedListener; 98 RemoteInputView(Context context, AttributeSet attrs)99 public RemoteInputView(Context context, AttributeSet attrs) { 100 super(context, attrs); 101 mRemoteInputQuickSettingsDisabler = Dependency.get(RemoteInputQuickSettingsDisabler.class); 102 } 103 104 @Override onFinishInflate()105 protected void onFinishInflate() { 106 super.onFinishInflate(); 107 108 mProgressBar = findViewById(R.id.remote_input_progress); 109 110 mSendButton = findViewById(R.id.remote_input_send); 111 mSendButton.setOnClickListener(this); 112 113 mEditText = (RemoteEditText) getChildAt(0); 114 mEditText.setOnEditorActionListener(new TextView.OnEditorActionListener() { 115 @Override 116 public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { 117 final boolean isSoftImeEvent = event == null 118 && (actionId == EditorInfo.IME_ACTION_DONE 119 || actionId == EditorInfo.IME_ACTION_NEXT 120 || actionId == EditorInfo.IME_ACTION_SEND); 121 final boolean isKeyboardEnterKey = event != null 122 && KeyEvent.isConfirmKey(event.getKeyCode()) 123 && event.getAction() == KeyEvent.ACTION_DOWN; 124 125 if (isSoftImeEvent || isKeyboardEnterKey) { 126 if (mEditText.length() > 0) { 127 sendRemoteInput(); 128 } 129 // Consume action to prevent IME from closing. 130 return true; 131 } 132 return false; 133 } 134 }); 135 mEditText.addTextChangedListener(this); 136 mEditText.setInnerFocusable(false); 137 mEditText.mRemoteInputView = this; 138 } 139 sendRemoteInput()140 private void sendRemoteInput() { 141 Bundle results = new Bundle(); 142 results.putString(mRemoteInput.getResultKey(), mEditText.getText().toString()); 143 Intent fillInIntent = new Intent().addFlags(Intent.FLAG_RECEIVER_FOREGROUND); 144 RemoteInput.addResultsToIntent(mRemoteInputs, fillInIntent, 145 results); 146 RemoteInput.setResultsSource(fillInIntent, RemoteInput.SOURCE_FREE_FORM_INPUT); 147 148 mEditText.setEnabled(false); 149 mSendButton.setVisibility(INVISIBLE); 150 mProgressBar.setVisibility(VISIBLE); 151 mEntry.remoteInputText = mEditText.getText(); 152 mEntry.lastRemoteInputSent = SystemClock.elapsedRealtime(); 153 mController.addSpinning(mEntry.key, mToken); 154 mController.removeRemoteInput(mEntry, mToken); 155 mEditText.mShowImeOnInputConnection = false; 156 mController.remoteInputSent(mEntry); 157 mEntry.setHasSentReply(); 158 159 // Tell ShortcutManager that this package has been "activated". ShortcutManager 160 // will reset the throttling for this package. 161 // Strictly speaking, the intent receiver may be different from the notification publisher, 162 // but that's an edge case, and also because we can't always know which package will receive 163 // an intent, so we just reset for the publisher. 164 getContext().getSystemService(ShortcutManager.class).onApplicationActive( 165 mEntry.notification.getPackageName(), 166 mEntry.notification.getUser().getIdentifier()); 167 168 MetricsLogger.action(mContext, MetricsProto.MetricsEvent.ACTION_REMOTE_INPUT_SEND, 169 mEntry.notification.getPackageName()); 170 try { 171 mPendingIntent.send(mContext, 0, fillInIntent); 172 } catch (PendingIntent.CanceledException e) { 173 Log.i(TAG, "Unable to send remote input result", e); 174 MetricsLogger.action(mContext, MetricsProto.MetricsEvent.ACTION_REMOTE_INPUT_FAIL, 175 mEntry.notification.getPackageName()); 176 } 177 } 178 getText()179 public CharSequence getText() { 180 return mEditText.getText(); 181 } 182 inflate(Context context, ViewGroup root, NotificationData.Entry entry, RemoteInputController controller)183 public static RemoteInputView inflate(Context context, ViewGroup root, 184 NotificationData.Entry entry, 185 RemoteInputController controller) { 186 RemoteInputView v = (RemoteInputView) 187 LayoutInflater.from(context).inflate(R.layout.remote_input, root, false); 188 v.mController = controller; 189 v.mEntry = entry; 190 v.setTag(VIEW_TAG); 191 192 return v; 193 } 194 195 @Override onClick(View v)196 public void onClick(View v) { 197 if (v == mSendButton) { 198 sendRemoteInput(); 199 } 200 } 201 202 @Override onTouchEvent(MotionEvent event)203 public boolean onTouchEvent(MotionEvent event) { 204 super.onTouchEvent(event); 205 206 // We never want for a touch to escape to an outer view or one we covered. 207 return true; 208 } 209 onDefocus(boolean animate)210 private void onDefocus(boolean animate) { 211 mController.removeRemoteInput(mEntry, mToken); 212 mEntry.remoteInputText = mEditText.getText(); 213 214 // During removal, we get reattached and lose focus. Not hiding in that 215 // case to prevent flicker. 216 if (!mRemoved) { 217 if (animate && mRevealR > 0) { 218 Animator reveal = ViewAnimationUtils.createCircularReveal( 219 this, mRevealCx, mRevealCy, mRevealR, 0); 220 reveal.setInterpolator(Interpolators.FAST_OUT_LINEAR_IN); 221 reveal.setDuration(StackStateAnimator.ANIMATION_DURATION_CLOSE_REMOTE_INPUT); 222 reveal.addListener(new AnimatorListenerAdapter() { 223 @Override 224 public void onAnimationEnd(Animator animation) { 225 setVisibility(INVISIBLE); 226 if (mWrapper != null) { 227 mWrapper.setRemoteInputVisible(false); 228 } 229 } 230 }); 231 reveal.start(); 232 } else { 233 setVisibility(INVISIBLE); 234 if (mWrapper != null) { 235 mWrapper.setRemoteInputVisible(false); 236 } 237 } 238 } 239 240 mRemoteInputQuickSettingsDisabler.setRemoteInputActive(false); 241 242 MetricsLogger.action(mContext, MetricsProto.MetricsEvent.ACTION_REMOTE_INPUT_CLOSE, 243 mEntry.notification.getPackageName()); 244 } 245 246 @Override onAttachedToWindow()247 protected void onAttachedToWindow() { 248 super.onAttachedToWindow(); 249 if (mEntry.row.isChangingPosition()) { 250 if (getVisibility() == VISIBLE && mEditText.isFocusable()) { 251 mEditText.requestFocus(); 252 } 253 } 254 } 255 256 @Override onDetachedFromWindow()257 protected void onDetachedFromWindow() { 258 super.onDetachedFromWindow(); 259 if (mEntry.row.isChangingPosition() || isTemporarilyDetached()) { 260 return; 261 } 262 mController.removeRemoteInput(mEntry, mToken); 263 mController.removeSpinning(mEntry.key, mToken); 264 } 265 setPendingIntent(PendingIntent pendingIntent)266 public void setPendingIntent(PendingIntent pendingIntent) { 267 mPendingIntent = pendingIntent; 268 } 269 setRemoteInput(RemoteInput[] remoteInputs, RemoteInput remoteInput)270 public void setRemoteInput(RemoteInput[] remoteInputs, RemoteInput remoteInput) { 271 mRemoteInputs = remoteInputs; 272 mRemoteInput = remoteInput; 273 mEditText.setHint(mRemoteInput.getLabel()); 274 } 275 focusAnimated()276 public void focusAnimated() { 277 if (getVisibility() != VISIBLE) { 278 Animator animator = ViewAnimationUtils.createCircularReveal( 279 this, mRevealCx, mRevealCy, 0, mRevealR); 280 animator.setDuration(StackStateAnimator.ANIMATION_DURATION_STANDARD); 281 animator.setInterpolator(Interpolators.LINEAR_OUT_SLOW_IN); 282 animator.start(); 283 } 284 focus(); 285 } 286 focus()287 public void focus() { 288 MetricsLogger.action(mContext, MetricsProto.MetricsEvent.ACTION_REMOTE_INPUT_OPEN, 289 mEntry.notification.getPackageName()); 290 291 setVisibility(VISIBLE); 292 if (mWrapper != null) { 293 mWrapper.setRemoteInputVisible(true); 294 } 295 mEditText.setInnerFocusable(true); 296 mEditText.mShowImeOnInputConnection = true; 297 mEditText.setText(mEntry.remoteInputText); 298 mEditText.setSelection(mEditText.getText().length()); 299 mEditText.requestFocus(); 300 mController.addRemoteInput(mEntry, mToken); 301 302 mRemoteInputQuickSettingsDisabler.setRemoteInputActive(true); 303 304 updateSendButton(); 305 } 306 onNotificationUpdateOrReset()307 public void onNotificationUpdateOrReset() { 308 boolean sending = mProgressBar.getVisibility() == VISIBLE; 309 310 if (sending) { 311 // Update came in after we sent the reply, time to reset. 312 reset(); 313 } 314 315 if (isActive() && mWrapper != null) { 316 mWrapper.setRemoteInputVisible(true); 317 } 318 } 319 reset()320 private void reset() { 321 mResetting = true; 322 mEntry.remoteInputTextWhenReset = SpannedString.valueOf(mEditText.getText()); 323 324 mEditText.getText().clear(); 325 mEditText.setEnabled(true); 326 mSendButton.setVisibility(VISIBLE); 327 mProgressBar.setVisibility(INVISIBLE); 328 mController.removeSpinning(mEntry.key, mToken); 329 updateSendButton(); 330 onDefocus(false /* animate */); 331 332 mResetting = false; 333 } 334 335 @Override onRequestSendAccessibilityEvent(View child, AccessibilityEvent event)336 public boolean onRequestSendAccessibilityEvent(View child, AccessibilityEvent event) { 337 if (mResetting && child == mEditText) { 338 // Suppress text events if it happens during resetting. Ideally this would be 339 // suppressed by the text view not being shown, but that doesn't work here because it 340 // needs to stay visible for the animation. 341 return false; 342 } 343 return super.onRequestSendAccessibilityEvent(child, event); 344 } 345 updateSendButton()346 private void updateSendButton() { 347 mSendButton.setEnabled(mEditText.getText().length() != 0); 348 } 349 350 @Override beforeTextChanged(CharSequence s, int start, int count, int after)351 public void beforeTextChanged(CharSequence s, int start, int count, int after) {} 352 353 @Override onTextChanged(CharSequence s, int start, int before, int count)354 public void onTextChanged(CharSequence s, int start, int before, int count) {} 355 356 @Override afterTextChanged(Editable s)357 public void afterTextChanged(Editable s) { 358 updateSendButton(); 359 } 360 close()361 public void close() { 362 mEditText.defocusIfNeeded(false /* animated */); 363 } 364 365 @Override onInterceptTouchEvent(MotionEvent ev)366 public boolean onInterceptTouchEvent(MotionEvent ev) { 367 if (ev.getAction() == MotionEvent.ACTION_DOWN) { 368 mController.requestDisallowLongPressAndDismiss(); 369 } 370 return super.onInterceptTouchEvent(ev); 371 } 372 requestScrollTo()373 public boolean requestScrollTo() { 374 mController.lockScrollTo(mEntry); 375 return true; 376 } 377 isActive()378 public boolean isActive() { 379 return mEditText.isFocused() && mEditText.isEnabled(); 380 } 381 stealFocusFrom(RemoteInputView other)382 public void stealFocusFrom(RemoteInputView other) { 383 other.close(); 384 setPendingIntent(other.mPendingIntent); 385 setRemoteInput(other.mRemoteInputs, other.mRemoteInput); 386 setRevealParameters(other.mRevealCx, other.mRevealCy, other.mRevealR); 387 focus(); 388 } 389 390 /** 391 * Tries to find an action in {@param actions} that matches the current pending intent 392 * of this view and updates its state to that of the found action 393 * 394 * @return true if a matching action was found, false otherwise 395 */ updatePendingIntentFromActions(Notification.Action[] actions)396 public boolean updatePendingIntentFromActions(Notification.Action[] actions) { 397 if (mPendingIntent == null || actions == null) { 398 return false; 399 } 400 Intent current = mPendingIntent.getIntent(); 401 if (current == null) { 402 return false; 403 } 404 405 for (Notification.Action a : actions) { 406 RemoteInput[] inputs = a.getRemoteInputs(); 407 if (a.actionIntent == null || inputs == null) { 408 continue; 409 } 410 Intent candidate = a.actionIntent.getIntent(); 411 if (!current.filterEquals(candidate)) { 412 continue; 413 } 414 415 RemoteInput input = null; 416 for (RemoteInput i : inputs) { 417 if (i.getAllowFreeFormInput()) { 418 input = i; 419 } 420 } 421 if (input == null) { 422 continue; 423 } 424 setPendingIntent(a.actionIntent); 425 setRemoteInput(inputs, input); 426 return true; 427 } 428 return false; 429 } 430 getPendingIntent()431 public PendingIntent getPendingIntent() { 432 return mPendingIntent; 433 } 434 setRemoved()435 public void setRemoved() { 436 mRemoved = true; 437 } 438 setRevealParameters(int cx, int cy, int r)439 public void setRevealParameters(int cx, int cy, int r) { 440 mRevealCx = cx; 441 mRevealCy = cy; 442 mRevealR = r; 443 } 444 445 @Override dispatchStartTemporaryDetach()446 public void dispatchStartTemporaryDetach() { 447 super.dispatchStartTemporaryDetach(); 448 // Detach the EditText temporarily such that it doesn't get onDetachedFromWindow and 449 // won't lose IME focus. 450 detachViewFromParent(mEditText); 451 } 452 453 @Override dispatchFinishTemporaryDetach()454 public void dispatchFinishTemporaryDetach() { 455 if (isAttachedToWindow()) { 456 attachViewToParent(mEditText, 0, mEditText.getLayoutParams()); 457 } else { 458 removeDetachedView(mEditText, false /* animate */); 459 } 460 super.dispatchFinishTemporaryDetach(); 461 } 462 setWrapper(NotificationViewWrapper wrapper)463 public void setWrapper(NotificationViewWrapper wrapper) { 464 mWrapper = wrapper; 465 } 466 setOnVisibilityChangedListener(Consumer<Boolean> visibilityChangedListener)467 public void setOnVisibilityChangedListener(Consumer<Boolean> visibilityChangedListener) { 468 mOnVisibilityChangedListener = visibilityChangedListener; 469 } 470 471 @Override onVisibilityChanged(View changedView, int visibility)472 protected void onVisibilityChanged(View changedView, int visibility) { 473 super.onVisibilityChanged(changedView, visibility); 474 if (changedView == this && mOnVisibilityChangedListener != null) { 475 mOnVisibilityChangedListener.accept(visibility == VISIBLE); 476 } 477 } 478 isSending()479 public boolean isSending() { 480 return getVisibility() == VISIBLE && mController.isSpinning(mEntry.key, mToken); 481 } 482 483 /** 484 * An EditText that changes appearance based on whether it's focusable and becomes 485 * un-focusable whenever the user navigates away from it or it becomes invisible. 486 */ 487 public static class RemoteEditText extends EditText { 488 489 private final Drawable mBackground; 490 private RemoteInputView mRemoteInputView; 491 boolean mShowImeOnInputConnection; 492 RemoteEditText(Context context, AttributeSet attrs)493 public RemoteEditText(Context context, AttributeSet attrs) { 494 super(context, attrs); 495 mBackground = getBackground(); 496 } 497 defocusIfNeeded(boolean animate)498 private void defocusIfNeeded(boolean animate) { 499 if (mRemoteInputView != null && mRemoteInputView.mEntry.row.isChangingPosition() 500 || isTemporarilyDetached()) { 501 if (isTemporarilyDetached()) { 502 // We might get reattached but then the other one of HUN / expanded might steal 503 // our focus, so we'll need to save our text here. 504 if (mRemoteInputView != null) { 505 mRemoteInputView.mEntry.remoteInputText = getText(); 506 } 507 } 508 return; 509 } 510 if (isFocusable() && isEnabled()) { 511 setInnerFocusable(false); 512 if (mRemoteInputView != null) { 513 mRemoteInputView.onDefocus(animate); 514 } 515 mShowImeOnInputConnection = false; 516 } 517 } 518 519 @Override onVisibilityChanged(View changedView, int visibility)520 protected void onVisibilityChanged(View changedView, int visibility) { 521 super.onVisibilityChanged(changedView, visibility); 522 523 if (!isShown()) { 524 defocusIfNeeded(false /* animate */); 525 } 526 } 527 528 @Override onFocusChanged(boolean focused, int direction, Rect previouslyFocusedRect)529 protected void onFocusChanged(boolean focused, int direction, Rect previouslyFocusedRect) { 530 super.onFocusChanged(focused, direction, previouslyFocusedRect); 531 if (!focused) { 532 defocusIfNeeded(true /* animate */); 533 } 534 } 535 536 @Override getFocusedRect(Rect r)537 public void getFocusedRect(Rect r) { 538 super.getFocusedRect(r); 539 r.top = mScrollY; 540 r.bottom = mScrollY + (mBottom - mTop); 541 } 542 543 @Override requestRectangleOnScreen(Rect rectangle)544 public boolean requestRectangleOnScreen(Rect rectangle) { 545 return mRemoteInputView.requestScrollTo(); 546 } 547 548 @Override onKeyDown(int keyCode, KeyEvent event)549 public boolean onKeyDown(int keyCode, KeyEvent event) { 550 if (keyCode == KeyEvent.KEYCODE_BACK) { 551 // Eat the DOWN event here to prevent any default behavior. 552 return true; 553 } 554 return super.onKeyDown(keyCode, event); 555 } 556 557 @Override onKeyUp(int keyCode, KeyEvent event)558 public boolean onKeyUp(int keyCode, KeyEvent event) { 559 if (keyCode == KeyEvent.KEYCODE_BACK) { 560 defocusIfNeeded(true /* animate */); 561 return true; 562 } 563 return super.onKeyUp(keyCode, event); 564 } 565 566 @Override onKeyPreIme(int keyCode, KeyEvent event)567 public boolean onKeyPreIme(int keyCode, KeyEvent event) { 568 // When BACK key is pressed, this method would be invoked twice. 569 if (event.getKeyCode() == KeyEvent.KEYCODE_BACK && 570 event.getAction() == KeyEvent.ACTION_UP) { 571 defocusIfNeeded(true /* animate */); 572 } 573 return super.onKeyPreIme(keyCode, event); 574 } 575 576 @Override onCheckIsTextEditor()577 public boolean onCheckIsTextEditor() { 578 // Stop being editable while we're being removed. During removal, we get reattached, 579 // and editable views get their spellchecking state re-evaluated which is too costly 580 // during the removal animation. 581 boolean flyingOut = mRemoteInputView != null && mRemoteInputView.mRemoved; 582 return !flyingOut && super.onCheckIsTextEditor(); 583 } 584 585 @Override onCreateInputConnection(EditorInfo outAttrs)586 public InputConnection onCreateInputConnection(EditorInfo outAttrs) { 587 final InputConnection inputConnection = super.onCreateInputConnection(outAttrs); 588 589 if (mShowImeOnInputConnection && inputConnection != null) { 590 final InputMethodManager imm = InputMethodManager.getInstance(); 591 if (imm != null) { 592 // onCreateInputConnection is called by InputMethodManager in the middle of 593 // setting up the connection to the IME; wait with requesting the IME until that 594 // work has completed. 595 post(new Runnable() { 596 @Override 597 public void run() { 598 imm.viewClicked(RemoteEditText.this); 599 imm.showSoftInput(RemoteEditText.this, 0); 600 } 601 }); 602 } 603 } 604 605 return inputConnection; 606 } 607 608 @Override onCommitCompletion(CompletionInfo text)609 public void onCommitCompletion(CompletionInfo text) { 610 clearComposingText(); 611 setText(text.getText()); 612 setSelection(getText().length()); 613 } 614 setInnerFocusable(boolean focusable)615 void setInnerFocusable(boolean focusable) { 616 setFocusableInTouchMode(focusable); 617 setFocusable(focusable); 618 setCursorVisible(focusable); 619 620 if (focusable) { 621 requestFocus(); 622 setBackground(mBackground); 623 } else { 624 setBackground(null); 625 } 626 627 } 628 } 629 } 630