1/*
2 * Copyright 2017 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 androidx.slice.widget;
18
19import android.animation.Animator;
20import android.app.PendingIntent;
21import android.app.RemoteInput;
22import android.content.Context;
23import android.content.Intent;
24import android.graphics.Rect;
25import android.graphics.drawable.Drawable;
26import android.os.Build;
27import android.os.Bundle;
28import android.text.Editable;
29import android.text.TextWatcher;
30import android.util.AttributeSet;
31import android.util.Log;
32import android.view.KeyEvent;
33import android.view.LayoutInflater;
34import android.view.MotionEvent;
35import android.view.View;
36import android.view.ViewAnimationUtils;
37import android.view.ViewGroup;
38import android.view.accessibility.AccessibilityEvent;
39import android.view.inputmethod.CompletionInfo;
40import android.view.inputmethod.EditorInfo;
41import android.view.inputmethod.InputConnection;
42import android.view.inputmethod.InputMethodManager;
43import android.widget.EditText;
44import android.widget.ImageButton;
45import android.widget.LinearLayout;
46import android.widget.ProgressBar;
47import android.widget.TextView;
48import android.widget.Toast;
49
50import androidx.annotation.RequiresApi;
51import androidx.annotation.RestrictTo;
52import androidx.core.content.ContextCompat;
53import androidx.slice.SliceItem;
54import androidx.slice.view.R;
55
56/**
57 * Host for the remote input.
58 *
59 * @hide
60 */
61// TODO this should be unified with SystemUI RemoteInputView (b/67527720)
62@RestrictTo(RestrictTo.Scope.LIBRARY)
63@RequiresApi(21)
64public class RemoteInputView extends LinearLayout implements View.OnClickListener, TextWatcher {
65
66    private static final String TAG = "RemoteInput";
67
68    /**
69     * A marker object that let's us easily find views of this class.
70     */
71    public static final Object VIEW_TAG = new Object();
72
73    private RemoteEditText mEditText;
74    private ImageButton mSendButton;
75    private ProgressBar mProgressBar;
76    private SliceItem mAction;
77    private RemoteInput[] mRemoteInputs;
78    private RemoteInput mRemoteInput;
79
80    private int mRevealCx;
81    private int mRevealCy;
82    private int mRevealR;
83    private boolean mResetting;
84
85    public RemoteInputView(Context context, AttributeSet attrs) {
86        super(context, attrs);
87    }
88
89    @Override
90    protected void onFinishInflate() {
91        super.onFinishInflate();
92
93        mProgressBar = findViewById(R.id.remote_input_progress);
94        mSendButton = findViewById(R.id.remote_input_send);
95        mSendButton.setOnClickListener(this);
96
97        mEditText = (RemoteEditText) getChildAt(0);
98        mEditText.setOnEditorActionListener(new TextView.OnEditorActionListener() {
99            @Override
100            public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
101                final boolean isSoftImeEvent = event == null
102                        && (actionId == EditorInfo.IME_ACTION_DONE
103                                || actionId == EditorInfo.IME_ACTION_NEXT
104                                || actionId == EditorInfo.IME_ACTION_SEND);
105                final boolean isKeyboardEnterKey = event != null
106                        && isConfirmKey(event.getKeyCode())
107                        && event.getAction() == KeyEvent.ACTION_DOWN;
108
109                if (isSoftImeEvent || isKeyboardEnterKey) {
110                    if (mEditText.length() > 0) {
111                        sendRemoteInput();
112                    }
113                    // Consume action to prevent IME from closing.
114                    return true;
115                }
116                return false;
117            }
118        });
119        mEditText.addTextChangedListener(this);
120        mEditText.setInnerFocusable(false);
121        mEditText.mRemoteInputView = this;
122    }
123
124    private void sendRemoteInput() {
125        Bundle results = new Bundle();
126        results.putString(mRemoteInput.getResultKey(), mEditText.getText().toString());
127        Intent fillInIntent = new Intent().addFlags(Intent.FLAG_RECEIVER_FOREGROUND);
128        RemoteInput.addResultsToIntent(mRemoteInputs, fillInIntent,
129                results);
130
131        mEditText.setEnabled(false);
132        mSendButton.setVisibility(INVISIBLE);
133        mProgressBar.setVisibility(VISIBLE);
134        mEditText.mShowImeOnInputConnection = false;
135
136        // TODO: Figure out API for telling the system about slice interaction.
137        // Tell ShortcutManager that this package has been "activated".  ShortcutManager
138        // will reset the throttling for this package.
139        // Strictly speaking, the intent receiver may be different from the intent creator,
140        // but that's an edge case, and also because we can't always know which package will receive
141        // an intent, so we just reset for the creator.
142        //getContext().getSystemService(ShortcutManager.class).onApplicationActive(
143        //        mAction.getCreatorPackage(),
144        //        getContext().getUserId());
145
146        try {
147            mAction.fireAction(getContext(), fillInIntent);
148            reset();
149        } catch (PendingIntent.CanceledException e) {
150            Log.i(TAG, "Unable to send remote input result", e);
151            Toast.makeText(getContext(), "Failure sending pending intent for inline reply :(",
152                    Toast.LENGTH_SHORT).show();
153            reset();
154        }
155    }
156
157    /**
158     * Creates a remote input view.
159     */
160    public static RemoteInputView inflate(Context context, ViewGroup root) {
161        RemoteInputView v = (RemoteInputView) LayoutInflater.from(context).inflate(
162                R.layout.abc_slice_remote_input, root, false);
163        v.setTag(VIEW_TAG);
164        return v;
165    }
166
167    @Override
168    public void onClick(View v) {
169        if (v == mSendButton) {
170            sendRemoteInput();
171        }
172    }
173
174    @Override
175    public boolean onTouchEvent(MotionEvent event) {
176        super.onTouchEvent(event);
177
178        // We never want for a touch to escape to an outer view or one we covered.
179        return true;
180    }
181
182    private void onDefocus() {
183        setVisibility(INVISIBLE);
184    }
185
186    /**
187     * Set the pending intent for remote input.
188     */
189    public void setAction(SliceItem action) {
190        mAction = action;
191    }
192
193    /**
194     * Set the remote inputs for this view.
195     */
196    public void setRemoteInput(RemoteInput[] remoteInputs, RemoteInput remoteInput) {
197        mRemoteInputs = remoteInputs;
198        mRemoteInput = remoteInput;
199        mEditText.setHint(mRemoteInput.getLabel());
200    }
201
202    /**
203     * Focuses the remote input view.
204     */
205    public void focusAnimated() {
206        if (getVisibility() != VISIBLE) {
207            Animator animator = ViewAnimationUtils.createCircularReveal(
208                    this, mRevealCx, mRevealCy, 0, mRevealR);
209            animator.setDuration(200);
210            animator.start();
211        }
212        focus();
213    }
214
215    private void focus() {
216        setVisibility(VISIBLE);
217        mEditText.setInnerFocusable(true);
218        mEditText.mShowImeOnInputConnection = true;
219        mEditText.setSelection(mEditText.getText().length());
220        mEditText.requestFocus();
221        updateSendButton();
222    }
223
224    private void reset() {
225        mResetting = true;
226
227        mEditText.getText().clear();
228        mEditText.setEnabled(true);
229        mSendButton.setVisibility(VISIBLE);
230        mProgressBar.setVisibility(INVISIBLE);
231        updateSendButton();
232        onDefocus();
233
234        mResetting = false;
235    }
236
237    @Override
238    public boolean onRequestSendAccessibilityEvent(View child, AccessibilityEvent event) {
239        if (mResetting && child == mEditText) {
240            // Suppress text events if it happens during resetting. Ideally this would be
241            // suppressed by the text view not being shown, but that doesn't work here because it
242            // needs to stay visible for the animation.
243            return false;
244        }
245        return super.onRequestSendAccessibilityEvent(child, event);
246    }
247
248    private void updateSendButton() {
249        mSendButton.setEnabled(mEditText.getText().length() != 0);
250    }
251
252    @Override
253    public void beforeTextChanged(CharSequence s, int start, int count, int after) {
254    }
255
256    @Override
257    public void onTextChanged(CharSequence s, int start, int before, int count) {
258    }
259
260    @Override
261    public void afterTextChanged(Editable s) {
262        updateSendButton();
263    }
264
265    /**
266     * @hide
267     */
268    @RestrictTo(RestrictTo.Scope.LIBRARY)
269    public void setRevealParameters(int cx, int cy, int r) {
270        mRevealCx = cx;
271        mRevealCy = cy;
272        mRevealR = r;
273    }
274
275    @Override
276    public void dispatchStartTemporaryDetach() {
277        super.dispatchStartTemporaryDetach();
278        // Detach the EditText temporarily such that it doesn't get onDetachedFromWindow and
279        // won't lose IME focus.
280        detachViewFromParent(mEditText);
281    }
282
283    @Override
284    public void dispatchFinishTemporaryDetach() {
285        if (isAttachedToWindow()) {
286            attachViewToParent(mEditText, 0, mEditText.getLayoutParams());
287        } else {
288            removeDetachedView(mEditText, false /* animate */);
289        }
290        super.dispatchFinishTemporaryDetach();
291    }
292
293    /**
294     * An EditText that changes appearance based on whether it's focusable and becomes un-focusable
295     * whenever the user navigates away from it or it becomes invisible.
296     */
297    public static class RemoteEditText extends EditText {
298
299        private final Drawable mBackground;
300        private RemoteInputView mRemoteInputView;
301        boolean mShowImeOnInputConnection;
302
303        public RemoteEditText(Context context, AttributeSet attrs) {
304            super(context, attrs);
305            mBackground = getBackground();
306        }
307
308        private void defocusIfNeeded(boolean animate) {
309            if (mRemoteInputView != null || isTemporarilyDetachedCompat()) {
310                if (isTemporarilyDetachedCompat()) {
311                    // We might get reattached but then the other one of HUN / expanded might steal
312                    // our focus, so we'll need to save our text here.
313                }
314                return;
315            }
316            if (isFocusable() && isEnabled()) {
317                setInnerFocusable(false);
318                if (mRemoteInputView != null) {
319                    mRemoteInputView.onDefocus();
320                }
321                mShowImeOnInputConnection = false;
322            }
323        }
324
325        private boolean isTemporarilyDetachedCompat() {
326            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
327                return isTemporarilyDetached();
328            }
329            return false;
330        }
331
332        @Override
333        protected void onVisibilityChanged(View changedView, int visibility) {
334            super.onVisibilityChanged(changedView, visibility);
335
336            if (!isShown()) {
337                defocusIfNeeded(false /* animate */);
338            }
339        }
340
341        @Override
342        protected void onFocusChanged(boolean focused, int direction, Rect previouslyFocusedRect) {
343            super.onFocusChanged(focused, direction, previouslyFocusedRect);
344            if (!focused) {
345                defocusIfNeeded(true /* animate */);
346            }
347        }
348
349        @Override
350        public void getFocusedRect(Rect r) {
351            super.getFocusedRect(r);
352            r.top = getScrollY();
353            r.bottom = getScrollY() + (getBottom() - getTop());
354        }
355
356        @Override
357        public boolean onKeyDown(int keyCode, KeyEvent event) {
358            if (keyCode == KeyEvent.KEYCODE_BACK) {
359                // Eat the DOWN event here to prevent any default behavior.
360                return true;
361            }
362            return super.onKeyDown(keyCode, event);
363        }
364
365        @Override
366        public boolean onKeyUp(int keyCode, KeyEvent event) {
367            if (keyCode == KeyEvent.KEYCODE_BACK) {
368                defocusIfNeeded(true /* animate */);
369                return true;
370            }
371            return super.onKeyUp(keyCode, event);
372        }
373
374        @Override
375        public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
376            final InputConnection inputConnection = super.onCreateInputConnection(outAttrs);
377
378            if (mShowImeOnInputConnection && inputConnection != null) {
379                final InputMethodManager imm = ContextCompat.getSystemService(getContext(),
380                        InputMethodManager.class);
381                if (imm != null) {
382                    // onCreateInputConnection is called by InputMethodManager in the middle of
383                    // setting up the connection to the IME; wait with requesting the IME until that
384                    // work has completed.
385                    post(new Runnable() {
386                        @Override
387                        public void run() {
388                            imm.viewClicked(RemoteEditText.this);
389                            imm.showSoftInput(RemoteEditText.this, 0);
390                        }
391                    });
392                }
393            }
394
395            return inputConnection;
396        }
397
398        @Override
399        public void onCommitCompletion(CompletionInfo text) {
400            clearComposingText();
401            setText(text.getText());
402            setSelection(getText().length());
403        }
404
405        void setInnerFocusable(boolean focusable) {
406            setFocusableInTouchMode(focusable);
407            setFocusable(focusable);
408            setCursorVisible(focusable);
409
410            if (focusable) {
411                requestFocus();
412                setBackground(mBackground);
413            } else {
414                setBackground(null);
415            }
416
417        }
418    }
419
420    /** Whether key will, by default, trigger a click on the focused view.
421     * @hide
422     */
423    @RestrictTo(RestrictTo.Scope.LIBRARY)
424    public static final boolean isConfirmKey(int keyCode) {
425        switch (keyCode) {
426            case KeyEvent.KEYCODE_DPAD_CENTER:
427            case KeyEvent.KEYCODE_ENTER:
428            case KeyEvent.KEYCODE_SPACE:
429            case KeyEvent.KEYCODE_NUMPAD_ENTER:
430                return true;
431            default:
432                return false;
433        }
434    }
435}
436