Home | History | Annotate | Download | only in app
      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 }