1 /* 2 * Copyright (C) 2018 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.view.autofillable; 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.text.TextUtils; 26 import android.text.TextWatcher; 27 import android.util.ArrayMap; 28 import android.util.AttributeSet; 29 import android.util.Log; 30 import android.util.SparseArray; 31 import android.view.MotionEvent; 32 import android.view.View; 33 import android.view.accessibility.AccessibilityNodeInfo; 34 import android.view.autofill.AutofillValue; 35 import android.widget.EditText; 36 import android.widget.TextView; 37 import android.widget.Toast; 38 39 import com.example.android.autofill.app.R; 40 import com.example.android.autofill.app.Util; 41 import com.google.common.base.Preconditions; 42 43 import java.util.ArrayList; 44 import java.util.Arrays; 45 46 /** 47 * Base class for a custom view that manages its own virtual structure, i.e., this is a leaf 48 * {@link View} in the activity's structure, and it draws its own child UI elements. 49 * 50 * <p>This class only draws the views and provides hooks to integrate them with Android APIs such 51 * as Autofill and Accessibility—its up to the subclass to implement these integration points. 52 */ 53 abstract class AbstractCustomVirtualView 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 * <p> 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 = "AbstractCustomVirtualView"; 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 ArrayList<Line> mVirtualViewGroups = new ArrayList<>(); 74 protected final SparseArray<Item> mVirtualViews = new SparseArray<>(); 75 private final ArrayMap<String, Partition> mPartitionsByName = new ArrayMap<>(); 76 protected Line mFocusedLine; 77 protected int mTopMargin; 78 protected int mLeftMargin; 79 private Paint mTextPaint; 80 private int mTextHeight; 81 private int mLineLength; 82 AbstractCustomVirtualView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)83 protected AbstractCustomVirtualView(Context context, AttributeSet attrs, int defStyleAttr, 84 int defStyleRes) { 85 super(context, attrs, defStyleAttr, defStyleRes); 86 mTextPaint = new Paint(); 87 TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.CustomVirtualView, 88 defStyleAttr, defStyleRes); 89 int defaultHeight = 90 (int) (DEFAULT_TEXT_HEIGHT_DP * getResources().getDisplayMetrics().density); 91 mTextHeight = typedArray.getDimensionPixelSize( 92 R.styleable.CustomVirtualView_internalTextSize, defaultHeight); 93 typedArray.recycle(); 94 resetCoordinates(); 95 } 96 getItem(int id)97 protected Item getItem(int id) { 98 final Item item = mVirtualViews.get(id); 99 Preconditions.checkArgument(item != null, "No item for id %s: %s", id, mVirtualViews); 100 return item; 101 } 102 resetCoordinates()103 protected void resetCoordinates() { 104 mTextPaint.setStyle(Style.FILL); 105 mTextPaint.setTextSize(mTextHeight); 106 mTopMargin = getPaddingTop(); 107 mLeftMargin = getPaddingStart(); 108 mLineLength = mTextHeight + VERTICAL_GAP; 109 } 110 111 @Override onDraw(Canvas canvas)112 protected void onDraw(Canvas canvas) { 113 super.onDraw(canvas); 114 115 if (VERBOSE) { 116 Log.v(TAG, "onDraw(): " + mVirtualViewGroups.size() + " lines; canvas:" + canvas); 117 } 118 float x; 119 float y = mTopMargin + mLineLength; 120 for (int i = 0; i < mVirtualViewGroups.size(); i++) { 121 Line line = mVirtualViewGroups.get(i); 122 x = mLeftMargin; 123 if (VERBOSE) Log.v(TAG, "Drawing '" + line + "' at " + x + "x" + y); 124 mTextPaint.setColor(line.mFieldTextItem.focused ? FOCUSED_COLOR : UNFOCUSED_COLOR); 125 String readOnlyText = line.mLabelItem.text + ": ["; 126 String writeText = line.mFieldTextItem.text + "]"; 127 // Paints the label first... 128 canvas.drawText(readOnlyText, x, y, mTextPaint); 129 // ...then paints the edit text and sets the proper boundary 130 float deltaX = mTextPaint.measureText(readOnlyText); 131 x += deltaX; 132 line.mBounds.set((int) x, (int) (y - mLineLength), 133 (int) (x + mTextPaint.measureText(writeText)), (int) y); 134 if (VERBOSE) Log.v(TAG, "setBounds(" + x + ", " + y + "): " + line.mBounds); 135 canvas.drawText(writeText, x, y, mTextPaint); 136 y += mLineLength; 137 138 if (DRAW_AUTOFILL_UI_AFTER_SCROLL) { 139 line.notifyFocusChanged(); 140 } 141 } 142 } 143 144 @Override onTouchEvent(MotionEvent event)145 public boolean onTouchEvent(MotionEvent event) { 146 int y = (int) event.getY(); 147 onMotion(y); 148 return super.onTouchEvent(event); 149 } 150 151 /** 152 * Handles a motion event. 153 * 154 * @param y y coordinate. 155 */ onMotion(int y)156 protected void onMotion(int y) { 157 if (DEBUG) { 158 Log.d(TAG, "onMotion(): y=" + y + ", range=" + mLineLength + ", top=" + mTopMargin); 159 } 160 int lowerY = mTopMargin; 161 int upperY = -1; 162 for (int i = 0; i < mVirtualViewGroups.size(); i++) { 163 Line line = mVirtualViewGroups.get(i); 164 upperY = lowerY + mLineLength; 165 if (DEBUG) Log.d(TAG, "Line " + i + " ranges from " + lowerY + " to " + upperY); 166 if (lowerY <= y && y <= upperY) { 167 if (mFocusedLine != null) { 168 Log.d(TAG, "Removing focus from " + mFocusedLine); 169 mFocusedLine.changeFocus(false); 170 } 171 Log.d(TAG, "Changing focus to " + line); 172 mFocusedLine = line; 173 mFocusedLine.changeFocus(true); 174 invalidate(); 175 break; 176 } 177 lowerY += mLineLength; 178 } 179 } 180 181 /** 182 * Creates a new partition with the given name. 183 * 184 * @throws IllegalArgumentException if such partition already exists. 185 */ addPartition(String name)186 public Partition addPartition(String name) { 187 Preconditions.checkNotNull(name, "Name cannot be null."); 188 Preconditions.checkArgument(!mPartitionsByName.containsKey(name), 189 "Partition with such name already exists."); 190 Partition partition = new Partition(name); 191 mPartitionsByName.put(name, partition); 192 return partition; 193 } 194 195 notifyFocusGained(int virtualId, Rect bounds)196 protected abstract void notifyFocusGained(int virtualId, Rect bounds); 197 notifyFocusLost(int virtualId)198 protected abstract void notifyFocusLost(int virtualId); 199 onLineAdded(int id, Partition partition)200 protected void onLineAdded(int id, Partition partition) { 201 if (VERBOSE) Log.v(TAG, "onLineAdded: id=" + id + ", partition=" + partition); 202 } 203 showError(String message)204 protected void showError(String message) { 205 showMessage(true, message); 206 } 207 showMessage(String message)208 protected void showMessage(String message) { 209 showMessage(false, message); 210 } 211 showMessage(boolean warning, String message)212 private void showMessage(boolean warning, String message) { 213 if (warning) { 214 Log.w(TAG, message); 215 } else { 216 Log.i(TAG, message); 217 } 218 Toast.makeText(getContext(), message, Toast.LENGTH_LONG).show(); 219 } 220 221 protected static final class Item { 222 public final int id; 223 public final String idEntry; 224 public final Line line; 225 public final boolean editable; 226 public final boolean sanitized; 227 public final String[] hints; 228 public final int type; 229 public CharSequence text; 230 public boolean focused = false; 231 public long date; 232 private TextWatcher mListener; 233 Item(Line line, int id, String idEntry, String[] hints, int type, CharSequence text, boolean editable, boolean sanitized)234 Item(Line line, int id, String idEntry, String[] hints, int type, CharSequence text, 235 boolean editable, boolean sanitized) { 236 this.line = line; 237 this.id = id; 238 this.idEntry = idEntry; 239 this.text = text; 240 this.editable = editable; 241 this.sanitized = sanitized; 242 this.hints = hints; 243 this.type = type; 244 } 245 246 @Override toString()247 public String toString() { 248 return id + "/" + idEntry + ": " 249 + (type == AUTOFILL_TYPE_DATE ? date : text) // TODO: use DateFormat for date 250 + " (" + Util.getAutofillTypeAsString(type) + ")" 251 + (editable ? " (editable)" : " (read-only)" 252 + (sanitized ? " (sanitized)" : " (sensitive")) 253 + (hints == null ? " (no hints)" : " ( " + Arrays.toString(hints) + ")"); 254 } 255 getClassName()256 protected String getClassName() { 257 return editable ? EditText.class.getName() : TextView.class.getName(); 258 } 259 getAutofillValue()260 protected AutofillValue getAutofillValue() { 261 switch (type) { 262 case AUTOFILL_TYPE_TEXT: 263 return (TextUtils.getTrimmedLength(text) > 0) 264 ? AutofillValue.forText(text) 265 : null; 266 case AUTOFILL_TYPE_DATE: 267 return AutofillValue.forDate(date); 268 default: 269 return null; 270 } 271 } 272 provideAccessibilityNodeInfo(View parent, Context context)273 protected AccessibilityNodeInfo provideAccessibilityNodeInfo(View parent, Context context) { 274 final AccessibilityNodeInfo node = AccessibilityNodeInfo.obtain(); 275 node.setSource(parent, id); 276 node.setPackageName(context.getPackageName()); 277 node.setClassName(getClassName()); 278 node.setEditable(editable); 279 node.setViewIdResourceName(idEntry); 280 node.setVisibleToUser(true); 281 final Rect absBounds = line.getAbsCoordinates(); 282 if (absBounds != null) { 283 node.setBoundsInScreen(absBounds); 284 } 285 if (TextUtils.getTrimmedLength(text) > 0) { 286 // TODO: Must checked trimmed length because input fields use 8 empty spaces to 287 // set width 288 node.setText(text); 289 } 290 return node; 291 } 292 setText(CharSequence value)293 protected void setText(CharSequence value) { 294 if (!editable) { 295 Log.w(TAG, "Item for id " + id + " is not editable: " + this); 296 return; 297 } 298 text = value; 299 if (mListener != null) { 300 Log.d(TAG, "Notify listener: " + text); 301 mListener.onTextChanged(text, 0, 0, 0); 302 } 303 } 304 305 } 306 307 /** 308 * A partition represents a logical group of items, such as credit card info. 309 */ 310 public final class Partition { 311 protected final String mName; 312 protected final SparseArray<Line> mLines = new SparseArray<>(); 313 Partition(String name)314 private Partition(String name) { 315 mName = name; 316 } 317 318 /** 319 * Adds a new line (containining a label and an input field) to the view. 320 * 321 * @param idEntryPrefix id prefix used to identify the line - label node will be suffixed 322 * with {@code Label} and editable node with {@code Field}. 323 * @param autofillType {@link View#getAutofillType() autofill type} of the field. 324 * @param label text used in the label. 325 * @param text initial text used in the input field. 326 * @param sensitive whether the input is considered sensitive. 327 * @param autofillHints list of autofill hints. 328 * @return the new line. 329 */ addLine(String idEntryPrefix, int autofillType, String label, String text, boolean sensitive, String... autofillHints)330 public Line addLine(String idEntryPrefix, int autofillType, String label, String text, 331 boolean sensitive, String... autofillHints) { 332 Preconditions.checkArgument(autofillType == AUTOFILL_TYPE_TEXT 333 || autofillType == AUTOFILL_TYPE_DATE, "Unsupported type: " + autofillType); 334 Line line = new Line(idEntryPrefix, autofillType, label, autofillHints, text, 335 !sensitive); 336 mVirtualViewGroups.add(line); 337 int id = line.mFieldTextItem.id; 338 mLines.put(id, line); 339 mVirtualViews.put(line.mLabelItem.id, line.mLabelItem); 340 mVirtualViews.put(id, line.mFieldTextItem); 341 onLineAdded(id, this); 342 343 return line; 344 } 345 346 /** 347 * Resets the value of all items in the partition. 348 */ reset()349 public void reset() { 350 for (int i = 0; i < mLines.size(); i++) { 351 mLines.valueAt(i).reset(); 352 } 353 } 354 355 @Override toString()356 public String toString() { 357 return mName; 358 } 359 } 360 361 /** 362 * A line in the virtual view contains a label and an input field. 363 */ 364 public final class Line { 365 366 protected final Item mFieldTextItem; 367 // Boundaries of the text field, relative to the CustomView 368 protected final Rect mBounds = new Rect(); 369 protected final Item mLabelItem; 370 protected final int mAutofillType; 371 Line(String idEntryPrefix, int autofillType, String label, String[] hints, String text, boolean sanitized)372 private Line(String idEntryPrefix, int autofillType, String label, String[] hints, 373 String text, boolean sanitized) { 374 this.mAutofillType = autofillType; 375 this.mLabelItem = new Item(this, ++sNextId, idEntryPrefix + "Label", null, 376 AUTOFILL_TYPE_NONE, label, false, true); 377 this.mFieldTextItem = new Item(this, ++sNextId, idEntryPrefix + "Field", hints, 378 autofillType, text, true, sanitized); 379 } 380 changeFocus(boolean focused)381 private void changeFocus(boolean focused) { 382 mFieldTextItem.focused = focused; 383 notifyFocusChanged(); 384 } 385 notifyFocusChanged()386 void notifyFocusChanged() { 387 if (mFieldTextItem.focused) { 388 Rect absBounds = getAbsCoordinates(); 389 if (DEBUG) { 390 Log.d(TAG, "focus gained on " + mFieldTextItem.id + "; absBounds=" + absBounds); 391 } 392 notifyFocusGained(mFieldTextItem.id, absBounds); 393 } else { 394 if (DEBUG) Log.d(TAG, "focus lost on " + mFieldTextItem.id); 395 notifyFocusLost(mFieldTextItem.id); 396 } 397 } 398 getAbsCoordinates()399 private Rect getAbsCoordinates() { 400 // Must offset the boundaries so they're relative to the CustomView. 401 int[] offset = new int[2]; 402 getLocationOnScreen(offset); 403 Rect absBounds = new Rect(mBounds.left + offset[0], 404 mBounds.top + offset[1], 405 mBounds.right + offset[0], mBounds.bottom + offset[1]); 406 if (VERBOSE) { 407 Log.v(TAG, "getAbsCoordinates() for " + mFieldTextItem.id + ": bounds=" + mBounds 408 + " offset: " + Arrays.toString(offset) + " absBounds: " + absBounds); 409 } 410 return absBounds; 411 } 412 413 /** 414 * Gets the value of the input field text. 415 */ getText()416 public CharSequence getText() { 417 return mFieldTextItem.text; 418 } 419 420 /** 421 * Resets the value of the input field text. 422 */ reset()423 public void reset() { 424 mFieldTextItem.text = " "; 425 } 426 427 @Override toString()428 public String toString() { 429 return "Label: " + mLabelItem + " Text: " + mFieldTextItem 430 + " Focused: " + mFieldTextItem.focused + " Type: " + mAutofillType; 431 } 432 } 433 } 434