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