Home | History | Annotate | Download | only in autofillable
      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&mdash;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 
     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 
     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 
    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
    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
    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      */
    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      */
    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 
    196     protected abstract void notifyFocusGained(int virtualId, Rect bounds);
    197 
    198     protected abstract void notifyFocusLost(int virtualId);
    199 
    200     protected void onLineAdded(int id, Partition partition) {
    201         if (VERBOSE) Log.v(TAG, "onLineAdded: id=" + id + ", partition=" + partition);
    202     }
    203 
    204     protected void showError(String message) {
    205         showMessage(true, message);
    206     }
    207 
    208     protected void showMessage(String message) {
    209         showMessage(false, message);
    210     }
    211 
    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 
    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
    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 
    256         protected String getClassName() {
    257             return editable ? EditText.class.getName() : TextView.class.getName();
    258         }
    259 
    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 
    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 
    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 
    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          */
    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          */
    349         public void reset() {
    350             for (int i = 0; i < mLines.size(); i++) {
    351                 mLines.valueAt(i).reset();
    352             }
    353         }
    354 
    355         @Override
    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 
    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 
    381         private void changeFocus(boolean focused) {
    382             mFieldTextItem.focused = focused;
    383             notifyFocusChanged();
    384         }
    385 
    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 
    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          */
    416         public CharSequence getText() {
    417             return mFieldTextItem.text;
    418         }
    419 
    420         /**
    421          * Resets the value of the input field text.
    422          */
    423         public void reset() {
    424             mFieldTextItem.text = "        ";
    425         }
    426 
    427         @Override
    428         public String toString() {
    429             return "Label: " + mLabelItem + " Text: " + mFieldTextItem
    430                     + " Focused: " + mFieldTextItem.focused + " Type: " + mAutofillType;
    431         }
    432     }
    433 }
    434