1 /* 2 * Copyright (C) 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 package com.example.android.autofill.app; 17 18 import android.content.Context; 19 import android.content.res.TypedArray; 20 import android.graphics.Canvas; 21 import android.graphics.Color; 22 import android.graphics.Paint; 23 import android.graphics.Paint.Style; 24 import android.graphics.Rect; 25 import android.support.annotation.Nullable; 26 import android.text.TextUtils; 27 import android.util.ArrayMap; 28 import android.util.ArraySet; 29 import android.util.AttributeSet; 30 import android.util.Log; 31 import android.util.SparseArray; 32 import android.view.MotionEvent; 33 import android.view.View; 34 import android.view.ViewStructure; 35 import android.view.autofill.AutofillManager; 36 import android.view.autofill.AutofillValue; 37 import android.widget.EditText; 38 import android.widget.TextView; 39 import android.widget.Toast; 40 41 import com.google.common.base.Preconditions; 42 43 import java.text.DateFormat; 44 import java.util.ArrayList; 45 import java.util.Arrays; 46 import java.util.Date; 47 48 import static com.example.android.autofill.app.CommonUtil.bundleToString; 49 50 /** 51 * A custom View with a virtual structure for fields supporting {@link View#getAutofillHints()} 52 */ 53 public class CustomVirtualView extends View { 54 55 protected static final boolean DEBUG = true; 56 protected static final boolean VERBOSE = false; 57 58 /** 59 * When set, it notifies AutofillManager of focus change as the view scrolls, so the 60 * autofill UI is continually drawn. 61 * 62 * <p>This is janky and incompatible with the way the autofill UI works on native views, but 63 * it's a cool experiment! 64 */ 65 private static final boolean DRAW_AUTOFILL_UI_AFTER_SCROLL = false; 66 67 private static final String TAG = "CustomView"; 68 private static final int DEFAULT_TEXT_HEIGHT_DP = 34; 69 private static final int VERTICAL_GAP = 10; 70 private static final int UNFOCUSED_COLOR = Color.BLACK; 71 private static final int FOCUSED_COLOR = Color.RED; 72 private static int sNextId; 73 protected final AutofillManager mAutofillManager; 74 private final ArrayList<Line> mVirtualViewGroups = new ArrayList<>(); 75 private final SparseArray<Item> mVirtualViews = new SparseArray<>(); 76 private final SparseArray<Partition> mPartitionsByAutofillId = new SparseArray<>(); 77 private final ArrayMap<String, Partition> mPartitionsByName = new ArrayMap<>(); 78 protected Line mFocusedLine; 79 protected int mTopMargin; 80 protected int mLeftMargin; 81 private Paint mTextPaint; 82 private int mTextHeight; 83 private int mLineLength; 84 85 public CustomVirtualView(Context context) { 86 this(context, null); 87 } 88 89 public CustomVirtualView(Context context, AttributeSet attrs) { 90 this(context, attrs, 0); 91 } 92 93 public CustomVirtualView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { 94 this(context, attrs, defStyleAttr, 0); 95 } 96 97 public CustomVirtualView(Context context, @Nullable AttributeSet attrs, int defStyleAttr, 98 int defStyleRes) { 99 super(context, attrs, defStyleAttr, defStyleRes); 100 mAutofillManager = context.getSystemService(AutofillManager.class); 101 mTextPaint = new Paint(); 102 TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.CustomVirtualView, 103 defStyleAttr, defStyleRes); 104 int defaultHeight = 105 (int) (DEFAULT_TEXT_HEIGHT_DP * getResources().getDisplayMetrics().density); 106 mTextHeight = typedArray.getDimensionPixelSize( 107 R.styleable.CustomVirtualView_internalTextSize, defaultHeight); 108 typedArray.recycle(); 109 resetCoordinates(); 110 } 111 112 protected void resetCoordinates() { 113 mTextPaint.setStyle(Style.FILL); 114 mTextPaint.setTextSize(mTextHeight); 115 mTopMargin = getPaddingTop(); 116 mLeftMargin = getPaddingStart(); 117 mLineLength = mTextHeight + VERTICAL_GAP; 118 } 119 120 @Override 121 public void autofill(SparseArray<AutofillValue> values) { 122 Context context = getContext(); 123 124 // User has just selected a Dataset from the list of autofill suggestions. 125 // The Dataset is comprised of a list of AutofillValues, with each AutofillValue meant 126 // to fill a specific autofillable view. Now we have to update the UI based on the 127 // AutofillValues in the list, but first we make sure all autofilled values belong to the 128 // same partition 129 if (DEBUG) Log.d(TAG, "autofill(): " + values); 130 131 // First get the name of all partitions in the values 132 ArraySet<String> partitions = new ArraySet<>(); 133 for (int i = 0; i < values.size(); i++) { 134 int id = values.keyAt(i); 135 Partition partition = mPartitionsByAutofillId.get(id); 136 if (partition == null) { 137 showError(context.getString(R.string.message_autofill_no_partitions, id, 138 mPartitionsByAutofillId)); 139 return; 140 } 141 partitions.add(partition.mName); 142 } 143 144 // Then make sure they follow the Highlander rule (There can be only one) 145 if (partitions.size() != 1) { 146 showError(context.getString(R.string.message_autofill_blocked, partitions)); 147 return; 148 } 149 150 // Finally, autofill it. 151 DateFormat df = android.text.format.DateFormat.getDateFormat(context); 152 for (int i = 0; i < values.size(); i++) { 153 int id = values.keyAt(i); 154 AutofillValue value = values.valueAt(i); 155 Item item = mVirtualViews.get(id); 156 157 if (item == null) { 158 Log.w(TAG, "No item for id " + id); 159 continue; 160 } 161 162 if (!item.editable) { 163 showError(context.getString(R.string.message_autofill_readonly, item.text)); 164 continue; 165 } 166 167 // Check if the type was properly set by the autofill service 168 if (DEBUG) { 169 Log.d(TAG, "Validating " + i 170 + ": expectedType=" + CommonUtil.getTypeAsString(item.type) 171 + "(" + item.type + "), value=" + value); 172 } 173 boolean valid = false; 174 if (value.isText() && item.type == AUTOFILL_TYPE_TEXT) { 175 item.text = value.getTextValue(); 176 valid = true; 177 } else if (value.isDate() && item.type == AUTOFILL_TYPE_DATE) { 178 item.text = df.format(new Date(value.getDateValue())); 179 valid = true; 180 } else { 181 Log.w(TAG, "Unsupported type: " + value); 182 } 183 if (!valid) { 184 item.text = context.getString(R.string.message_autofill_invalid); 185 } 186 } 187 postInvalidate(); 188 showMessage(context.getString(R.string.message_autofill_ok, partitions.valueAt(0))); 189 } 190 191 @Override 192 public void onProvideAutofillVirtualStructure(ViewStructure structure, int flags) { 193 // Build a ViewStructure that will get passed to the AutofillService by the framework 194 // when it is time to find autofill suggestions. 195 structure.setClassName(getClass().getName()); 196 int childrenSize = mVirtualViews.size(); 197 if (DEBUG) { 198 Log.d(TAG, "onProvideAutofillVirtualStructure(): flags = " + flags + ", items = " 199 + childrenSize + ", extras: " + bundleToString(structure.getExtras())); 200 } 201 int index = structure.addChildCount(childrenSize); 202 // Traverse through the view hierarchy, including virtual child views. For each view, we 203 // need to set the relevant autofill metadata and add it to the ViewStructure. 204 for (int i = 0; i < childrenSize; i++) { 205 Item item = mVirtualViews.valueAt(i); 206 if (DEBUG) Log.d(TAG, "Adding new child at index " + index + ": " + item); 207 ViewStructure child = structure.newChild(index); 208 child.setAutofillId(structure.getAutofillId(), item.id); 209 child.setAutofillHints(item.hints); 210 child.setAutofillType(item.type); 211 child.setAutofillValue(item.getAutofillValue()); 212 child.setDataIsSensitive(!item.sanitized); 213 child.setFocused(item.focused); 214 child.setVisibility(View.VISIBLE); 215 child.setDimens(item.line.mBounds.left, item.line.mBounds.top, 0, 0, 216 item.line.mBounds.width(), item.line.mBounds.height()); 217 child.setId(item.id, getContext().getPackageName(), null, item.idEntry); 218 child.setClassName(item.getClassName()); 219 child.setDimens(item.line.mBounds.left, item.line.mBounds.top, 0, 0, 220 item.line.mBounds.width(), item.line.mBounds.height()); 221 index++; 222 } 223 } 224 225 @Override 226 protected void onDraw(Canvas canvas) { 227 super.onDraw(canvas); 228 229 if (VERBOSE) { 230 Log.v(TAG, "onDraw(): " + mVirtualViewGroups.size() + " lines; canvas:" + canvas); 231 } 232 float x; 233 float y = mTopMargin + mLineLength; 234 for (int i = 0; i < mVirtualViewGroups.size(); i++) { 235 Line line = mVirtualViewGroups.get(i); 236 x = mLeftMargin; 237 if (VERBOSE) Log.v(TAG, "Drawing '" + line + "' at " + x + "x" + y); 238 mTextPaint.setColor(line.mFieldTextItem.focused ? FOCUSED_COLOR : UNFOCUSED_COLOR); 239 String readOnlyText = line.mLabelItem.text + ": ["; 240 String writeText = line.mFieldTextItem.text + "]"; 241 // Paints the label first... 242 canvas.drawText(readOnlyText, x, y, mTextPaint); 243 // ...then paints the edit text and sets the proper boundary 244 float deltaX = mTextPaint.measureText(readOnlyText); 245 x += deltaX; 246 line.mBounds.set((int) x, (int) (y - mLineLength), 247 (int) (x + mTextPaint.measureText(writeText)), (int) y); 248 if (VERBOSE) Log.v(TAG, "setBounds(" + x + ", " + y + "): " + line.mBounds); 249 canvas.drawText(writeText, x, y, mTextPaint); 250 y += mLineLength; 251 252 if (DRAW_AUTOFILL_UI_AFTER_SCROLL) { 253 line.notifyFocusChanged(); 254 } 255 } 256 } 257 258 @Override 259 public boolean onTouchEvent(MotionEvent event) { 260 int y = (int) event.getY(); 261 onMotion(y); 262 return super.onTouchEvent(event); 263 } 264 265 /** 266 * Handles a motion event. 267 * 268 * @param y y coordinate. 269 */ 270 protected void onMotion(int y) { 271 if (DEBUG) { 272 Log.d(TAG, "onMotion(): y=" + y + ", range=" + mLineLength + ", top=" + mTopMargin); 273 } 274 int lowerY = mTopMargin; 275 int upperY = -1; 276 for (int i = 0; i < mVirtualViewGroups.size(); i++) { 277 Line line = mVirtualViewGroups.get(i); 278 upperY = lowerY + mLineLength; 279 if (DEBUG) Log.d(TAG, "Line " + i + " ranges from " + lowerY + " to " + upperY); 280 if (lowerY <= y && y <= upperY) { 281 if (mFocusedLine != null) { 282 Log.d(TAG, "Removing focus from " + mFocusedLine); 283 mFocusedLine.changeFocus(false); 284 } 285 Log.d(TAG, "Changing focus to " + line); 286 mFocusedLine = line; 287 mFocusedLine.changeFocus(true); 288 invalidate(); 289 break; 290 } 291 lowerY += mLineLength; 292 } 293 } 294 295 /** 296 * Creates a new partition with the given name. 297 * 298 * @throws IllegalArgumentException if such partition already exists. 299 */ 300 public Partition addPartition(String name) { 301 Preconditions.checkNotNull(name, "Name cannot be null."); 302 Preconditions.checkArgument(!mPartitionsByName.containsKey(name), 303 "Partition with such name already exists."); 304 Partition partition = new Partition(name); 305 mPartitionsByName.put(name, partition); 306 return partition; 307 } 308 309 private void showError(String message) { 310 showMessage(true, message); 311 } 312 313 private void showMessage(String message) { 314 showMessage(false, message); 315 } 316 317 private void showMessage(boolean warning, String message) { 318 if (warning) { 319 Log.w(TAG, message); 320 } else { 321 Log.i(TAG, message); 322 } 323 Toast.makeText(getContext(), message, Toast.LENGTH_LONG).show(); 324 } 325 326 327 protected static final class Item { 328 protected final int id; 329 private final String idEntry; 330 private final Line line; 331 private final boolean editable; 332 private final boolean sanitized; 333 private final String[] hints; 334 private final int type; 335 private CharSequence text; 336 private boolean focused = false; 337 private long date; 338 339 Item(Line line, int id, String idEntry, String[] hints, int type, CharSequence text, 340 boolean editable, boolean sanitized) { 341 this.line = line; 342 this.id = id; 343 this.idEntry = idEntry; 344 this.text = text; 345 this.editable = editable; 346 this.sanitized = sanitized; 347 this.hints = hints; 348 this.type = type; 349 } 350 351 @Override 352 public String toString() { 353 return id + "/" + idEntry + ": " 354 + (type == AUTOFILL_TYPE_DATE ? date : text) // TODO: use DateFormat for date 355 + " (" + CommonUtil.getTypeAsString(type) + ")" 356 + (editable ? " (editable)" : " (read-only)" 357 + (sanitized ? " (sanitized)" : " (sensitive")) 358 + (hints == null ? " (no hints)" : " ( " + Arrays.toString(hints) + ")"); 359 } 360 361 public String getClassName() { 362 return editable ? EditText.class.getName() : TextView.class.getName(); 363 } 364 365 public AutofillValue getAutofillValue() { 366 switch (type) { 367 case AUTOFILL_TYPE_TEXT: 368 return (TextUtils.getTrimmedLength(text) > 0) 369 ? AutofillValue.forText(text) 370 : null; 371 case AUTOFILL_TYPE_DATE: 372 return AutofillValue.forDate(date); 373 default: 374 return null; 375 } 376 } 377 } 378 379 /** 380 * A partition represents a logical group of items, such as credit card info. 381 */ 382 public final class Partition { 383 private final String mName; 384 private final SparseArray<Line> mLines = new SparseArray<>(); 385 386 private Partition(String name) { 387 mName = name; 388 } 389 390 /** 391 * Adds a new line (containining a label and an input field) to the view. 392 * 393 * @param idEntryPrefix id prefix used to identify the line - label node will be suffixed 394 * with {@code Label} and editable node with {@code Field}. 395 * @param autofillType {@link View#getAutofillType() autofill type} of the field. 396 * @param label text used in the label. 397 * @param text initial text used in the input field. 398 * @param sensitive whether the input is considered sensitive. 399 * @param autofillHints list of autofill hints. 400 * @return the new line. 401 */ 402 public Line addLine(String idEntryPrefix, int autofillType, String label, String text, 403 boolean sensitive, String... autofillHints) { 404 Preconditions.checkArgument(autofillType == AUTOFILL_TYPE_TEXT || 405 autofillType == AUTOFILL_TYPE_DATE, "Unsupported type: " + autofillType); 406 Line line = new Line(idEntryPrefix, autofillType, label, autofillHints, text, 407 !sensitive); 408 mVirtualViewGroups.add(line); 409 int id = line.mFieldTextItem.id; 410 mLines.put(id, line); 411 mVirtualViews.put(line.mLabelItem.id, line.mLabelItem); 412 mVirtualViews.put(id, line.mFieldTextItem); 413 mPartitionsByAutofillId.put(id, this); 414 415 return line; 416 } 417 418 /** 419 * Resets the value of all items in the partition. 420 */ 421 public void reset() { 422 for (int i = 0; i < mLines.size(); i++) { 423 mLines.valueAt(i).reset(); 424 } 425 } 426 427 @Override 428 public String toString() { 429 return mName; 430 } 431 } 432 433 /** 434 * A line in the virtual view contains a label and an input field. 435 */ 436 public final class Line { 437 438 protected final Item mFieldTextItem; 439 // Boundaries of the text field, relative to the CustomView 440 private final Rect mBounds = new Rect(); 441 private final Item mLabelItem; 442 private final int mAutofillType; 443 444 private Line(String idEntryPrefix, int autofillType, String label, String[] hints, 445 String text, boolean sanitized) { 446 this.mAutofillType = autofillType; 447 this.mLabelItem = new Item(this, ++sNextId, idEntryPrefix + "Label", null, 448 AUTOFILL_TYPE_NONE, label, false, true); 449 this.mFieldTextItem = new Item(this, ++sNextId, idEntryPrefix + "Field", hints, 450 autofillType, text, true, sanitized); 451 } 452 453 private void changeFocus(boolean focused) { 454 mFieldTextItem.focused = focused; 455 notifyFocusChanged(); 456 } 457 458 void notifyFocusChanged() { 459 if (mFieldTextItem.focused) { 460 Rect absBounds = getAbsCoordinates(); 461 if (DEBUG) { 462 Log.d(TAG, "focus gained on " + mFieldTextItem.id + "; absBounds=" + absBounds); 463 } 464 mAutofillManager.notifyViewEntered(CustomVirtualView.this, mFieldTextItem.id, 465 absBounds); 466 } else { 467 if (DEBUG) Log.d(TAG, "focus lost on " + mFieldTextItem.id); 468 mAutofillManager.notifyViewExited(CustomVirtualView.this, mFieldTextItem.id); 469 } 470 } 471 472 private Rect getAbsCoordinates() { 473 // Must offset the boundaries so they're relative to the CustomView. 474 int offset[] = new int[2]; 475 getLocationOnScreen(offset); 476 Rect absBounds = new Rect(mBounds.left + offset[0], 477 mBounds.top + offset[1], 478 mBounds.right + offset[0], mBounds.bottom + offset[1]); 479 if (VERBOSE) { 480 Log.v(TAG, "getAbsCoordinates() for " + mFieldTextItem.id + ": bounds=" + mBounds 481 + " offset: " + Arrays.toString(offset) + " absBounds: " + absBounds); 482 } 483 return absBounds; 484 } 485 486 /** 487 * Gets the value of the input field text. 488 */ 489 public CharSequence getText() { 490 return mFieldTextItem.text; 491 } 492 493 /** 494 * Resets the value of the input field text. 495 */ 496 public void reset() { 497 mFieldTextItem.text = " "; 498 } 499 500 @Override 501 public String toString() { 502 return "Label: " + mLabelItem + " Text: " + mFieldTextItem + " Focused: " + 503 mFieldTextItem.focused + " Type: " + mAutofillType; 504 } 505 } 506 }