| /* |
| * Copyright 2017 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 androidx.slice.widget; |
| |
| import android.animation.Animator; |
| import android.app.PendingIntent; |
| import android.app.RemoteInput; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.graphics.Rect; |
| import android.graphics.drawable.Drawable; |
| import android.os.Build; |
| import android.os.Bundle; |
| import android.text.Editable; |
| 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 android.widget.Toast; |
| |
| import androidx.annotation.RequiresApi; |
| import androidx.annotation.RestrictTo; |
| import androidx.core.content.ContextCompat; |
| import androidx.slice.SliceItem; |
| import androidx.slice.view.R; |
| |
| /** |
| * Host for the remote input. |
| * |
| * @hide |
| */ |
| // TODO this should be unified with SystemUI RemoteInputView (b/67527720) |
| @RestrictTo(RestrictTo.Scope.LIBRARY) |
| @RequiresApi(21) |
| 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(); |
| |
| private RemoteEditText mEditText; |
| private ImageButton mSendButton; |
| private ProgressBar mProgressBar; |
| private SliceItem mAction; |
| private RemoteInput[] mRemoteInputs; |
| private RemoteInput mRemoteInput; |
| |
| private int mRevealCx; |
| private int mRevealCy; |
| private int mRevealR; |
| private boolean mResetting; |
| |
| public RemoteInputView(Context context, AttributeSet attrs) { |
| super(context, attrs); |
| } |
| |
| @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 |
| && isConfirmKey(event.getKeyCode()) |
| && event.getAction() == KeyEvent.ACTION_DOWN; |
| |
| if (isSoftImeEvent || isKeyboardEnterKey) { |
| if (mEditText.length() > 0) { |
| sendRemoteInput(); |
| } |
| // Consume action to prevent IME from closing. |
| return true; |
| } |
| return false; |
| } |
| }); |
| mEditText.addTextChangedListener(this); |
| mEditText.setInnerFocusable(false); |
| mEditText.mRemoteInputView = this; |
| } |
| |
| private void sendRemoteInput() { |
| 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); |
| |
| mEditText.setEnabled(false); |
| mSendButton.setVisibility(INVISIBLE); |
| mProgressBar.setVisibility(VISIBLE); |
| mEditText.mShowImeOnInputConnection = false; |
| |
| // TODO: Figure out API for telling the system about slice interaction. |
| // 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 intent creator, |
| // 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 creator. |
| //getContext().getSystemService(ShortcutManager.class).onApplicationActive( |
| // mAction.getCreatorPackage(), |
| // getContext().getUserId()); |
| |
| try { |
| mAction.fireAction(getContext(), fillInIntent); |
| reset(); |
| } catch (PendingIntent.CanceledException e) { |
| Log.i(TAG, "Unable to send remote input result", e); |
| Toast.makeText(getContext(), "Failure sending pending intent for inline reply :(", |
| Toast.LENGTH_SHORT).show(); |
| reset(); |
| } |
| } |
| |
| /** |
| * Creates a remote input view. |
| */ |
| public static RemoteInputView inflate(Context context, ViewGroup root) { |
| RemoteInputView v = (RemoteInputView) LayoutInflater.from(context).inflate( |
| R.layout.abc_slice_remote_input, root, false); |
| v.setTag(VIEW_TAG); |
| return v; |
| } |
| |
| @Override |
| public void onClick(View v) { |
| if (v == mSendButton) { |
| sendRemoteInput(); |
| } |
| } |
| |
| @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() { |
| setVisibility(INVISIBLE); |
| } |
| |
| /** |
| * Set the pending intent for remote input. |
| */ |
| public void setAction(SliceItem action) { |
| mAction = action; |
| } |
| |
| /** |
| * Set the remote inputs for this view. |
| */ |
| public void setRemoteInput(RemoteInput[] remoteInputs, RemoteInput remoteInput) { |
| mRemoteInputs = remoteInputs; |
| mRemoteInput = remoteInput; |
| mEditText.setHint(mRemoteInput.getLabel()); |
| } |
| |
| /** |
| * Focuses the remote input view. |
| */ |
| public void focusAnimated() { |
| if (getVisibility() != VISIBLE) { |
| Animator animator = ViewAnimationUtils.createCircularReveal( |
| this, mRevealCx, mRevealCy, 0, mRevealR); |
| animator.setDuration(200); |
| animator.start(); |
| } |
| focus(); |
| } |
| |
| private void focus() { |
| setVisibility(VISIBLE); |
| mEditText.setInnerFocusable(true); |
| mEditText.mShowImeOnInputConnection = true; |
| mEditText.setSelection(mEditText.getText().length()); |
| mEditText.requestFocus(); |
| updateSendButton(); |
| } |
| |
| private void reset() { |
| mResetting = true; |
| |
| mEditText.getText().clear(); |
| mEditText.setEnabled(true); |
| mSendButton.setVisibility(VISIBLE); |
| mProgressBar.setVisibility(INVISIBLE); |
| updateSendButton(); |
| onDefocus(); |
| |
| 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(); |
| } |
| |
| /** |
| * @hide |
| */ |
| @RestrictTo(RestrictTo.Scope.LIBRARY) |
| 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(); |
| } |
| |
| /** |
| * 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; |
| |
| public RemoteEditText(Context context, AttributeSet attrs) { |
| super(context, attrs); |
| mBackground = getBackground(); |
| } |
| |
| private void defocusIfNeeded(boolean animate) { |
| if (mRemoteInputView != null || isTemporarilyDetachedCompat()) { |
| if (isTemporarilyDetachedCompat()) { |
| // 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. |
| } |
| return; |
| } |
| if (isFocusable() && isEnabled()) { |
| setInnerFocusable(false); |
| if (mRemoteInputView != null) { |
| mRemoteInputView.onDefocus(); |
| } |
| mShowImeOnInputConnection = false; |
| } |
| } |
| |
| private boolean isTemporarilyDetachedCompat() { |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { |
| return isTemporarilyDetached(); |
| } |
| return 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 */); |
| } |
| } |
| |
| @Override |
| public void getFocusedRect(Rect r) { |
| super.getFocusedRect(r); |
| r.top = getScrollY(); |
| r.bottom = getScrollY() + (getBottom() - getTop()); |
| } |
| |
| @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 InputConnection onCreateInputConnection(EditorInfo outAttrs) { |
| final InputConnection inputConnection = super.onCreateInputConnection(outAttrs); |
| |
| if (mShowImeOnInputConnection && inputConnection != null) { |
| final InputMethodManager imm = ContextCompat.getSystemService(getContext(), |
| 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 inputConnection; |
| } |
| |
| @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); |
| } |
| |
| } |
| } |
| |
| /** Whether key will, by default, trigger a click on the focused view. |
| * @hide |
| */ |
| @RestrictTo(RestrictTo.Scope.LIBRARY) |
| public static final boolean isConfirmKey(int keyCode) { |
| switch (keyCode) { |
| case KeyEvent.KEYCODE_DPAD_CENTER: |
| case KeyEvent.KEYCODE_ENTER: |
| case KeyEvent.KEYCODE_SPACE: |
| case KeyEvent.KEYCODE_NUMPAD_ENTER: |
| return true; |
| default: |
| return false; |
| } |
| } |
| } |