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 
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