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
17package com.android.systemui.statusbar.policy;
18
19import android.animation.Animator;
20import android.animation.AnimatorListenerAdapter;
21import android.app.Notification;
22import android.app.PendingIntent;
23import android.app.RemoteInput;
24import android.content.Context;
25import android.content.Intent;
26import android.content.pm.ShortcutManager;
27import android.graphics.Rect;
28import android.graphics.drawable.Drawable;
29import android.os.Bundle;
30import android.os.SystemClock;
31import android.text.Editable;
32import android.text.SpannedString;
33import android.text.TextWatcher;
34import android.util.AttributeSet;
35import android.util.Log;
36import android.view.KeyEvent;
37import android.view.LayoutInflater;
38import android.view.MotionEvent;
39import android.view.View;
40import android.view.ViewAnimationUtils;
41import android.view.ViewGroup;
42import android.view.ViewParent;
43import android.view.accessibility.AccessibilityEvent;
44import android.view.inputmethod.CompletionInfo;
45import android.view.inputmethod.EditorInfo;
46import android.view.inputmethod.InputConnection;
47import android.view.inputmethod.InputMethodManager;
48import android.widget.EditText;
49import android.widget.ImageButton;
50import android.widget.LinearLayout;
51import android.widget.ProgressBar;
52import android.widget.TextView;
53
54import com.android.internal.logging.MetricsLogger;
55import com.android.internal.logging.nano.MetricsProto;
56import com.android.systemui.Dependency;
57import com.android.systemui.Interpolators;
58import com.android.systemui.R;
59import com.android.systemui.statusbar.NotificationData;
60import com.android.systemui.statusbar.RemoteInputController;
61import com.android.systemui.statusbar.notification.NotificationViewWrapper;
62import com.android.systemui.statusbar.stack.StackStateAnimator;
63
64import java.util.function.Consumer;
65
66/**
67 * Host for the remote input.
68 */
69public 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
99    public RemoteInputView(Context context, AttributeSet attrs) {
100        super(context, attrs);
101        mRemoteInputQuickSettingsDisabler = Dependency.get(RemoteInputQuickSettingsDisabler.class);
102    }
103
104    @Override
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
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
179    public CharSequence getText() {
180        return mEditText.getText();
181    }
182
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
196    public void onClick(View v) {
197        if (v == mSendButton) {
198            sendRemoteInput();
199        }
200    }
201
202    @Override
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
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
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
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
266    public void setPendingIntent(PendingIntent pendingIntent) {
267        mPendingIntent = pendingIntent;
268    }
269
270    public void setRemoteInput(RemoteInput[] remoteInputs, RemoteInput remoteInput) {
271        mRemoteInputs = remoteInputs;
272        mRemoteInput = remoteInput;
273        mEditText.setHint(mRemoteInput.getLabel());
274    }
275
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
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
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
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
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
346    private void updateSendButton() {
347        mSendButton.setEnabled(mEditText.getText().length() != 0);
348    }
349
350    @Override
351    public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
352
353    @Override
354    public void onTextChanged(CharSequence s, int start, int before, int count) {}
355
356    @Override
357    public void afterTextChanged(Editable s) {
358        updateSendButton();
359    }
360
361    public void close() {
362        mEditText.defocusIfNeeded(false /* animated */);
363    }
364
365    @Override
366    public boolean onInterceptTouchEvent(MotionEvent ev) {
367        if (ev.getAction() == MotionEvent.ACTION_DOWN) {
368            mController.requestDisallowLongPressAndDismiss();
369        }
370        return super.onInterceptTouchEvent(ev);
371    }
372
373    public boolean requestScrollTo() {
374        mController.lockScrollTo(mEntry);
375        return true;
376    }
377
378    public boolean isActive() {
379        return mEditText.isFocused() && mEditText.isEnabled();
380    }
381
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     */
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
431    public PendingIntent getPendingIntent() {
432        return mPendingIntent;
433    }
434
435    public void setRemoved() {
436        mRemoved = true;
437    }
438
439    public void setRevealParameters(int cx, int cy, int r) {
440        mRevealCx = cx;
441        mRevealCy = cy;
442        mRevealR = r;
443    }
444
445    @Override
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
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
463    public void setWrapper(NotificationViewWrapper wrapper) {
464        mWrapper = wrapper;
465    }
466
467    public void setOnVisibilityChangedListener(Consumer<Boolean> visibilityChangedListener) {
468        mOnVisibilityChangedListener = visibilityChangedListener;
469    }
470
471    @Override
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
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
493        public RemoteEditText(Context context, AttributeSet attrs) {
494            super(context, attrs);
495            mBackground = getBackground();
496        }
497
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
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
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
537        public void getFocusedRect(Rect r) {
538            super.getFocusedRect(r);
539            r.top = mScrollY;
540            r.bottom = mScrollY + (mBottom - mTop);
541        }
542
543        @Override
544        public boolean requestRectangleOnScreen(Rect rectangle) {
545            return mRemoteInputView.requestScrollTo();
546        }
547
548        @Override
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
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
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
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
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
609        public void onCommitCompletion(CompletionInfo text) {
610            clearComposingText();
611            setText(text.getText());
612            setSelection(getText().length());
613        }
614
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