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