Home | History | Annotate | Download | only in cts
      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 
     17 package android.autofillservice.cts;
     18 
     19 import static android.autofillservice.cts.Timeouts.FILL_TIMEOUT;
     20 
     21 import static com.google.common.truth.Truth.assertWithMessage;
     22 
     23 import android.app.assist.AssistStructure.ViewNode;
     24 import android.content.Context;
     25 import android.graphics.Canvas;
     26 import android.graphics.Color;
     27 import android.graphics.Paint;
     28 import android.graphics.Paint.Style;
     29 import android.graphics.Rect;
     30 import android.os.Bundle;
     31 import android.text.Editable;
     32 import android.text.TextUtils;
     33 import android.text.TextWatcher;
     34 import android.util.AttributeSet;
     35 import android.util.DisplayMetrics;
     36 import android.util.Log;
     37 import android.util.Pair;
     38 import android.util.SparseArray;
     39 import android.view.MotionEvent;
     40 import android.view.View;
     41 import android.view.ViewStructure;
     42 import android.view.ViewStructure.HtmlInfo;
     43 import android.view.WindowManager;
     44 import android.view.accessibility.AccessibilityEvent;
     45 import android.view.accessibility.AccessibilityManager;
     46 import android.view.accessibility.AccessibilityNodeInfo;
     47 import android.view.accessibility.AccessibilityNodeProvider;
     48 import android.view.autofill.AutofillId;
     49 import android.view.autofill.AutofillManager;
     50 import android.view.autofill.AutofillValue;
     51 
     52 import java.util.ArrayList;
     53 import java.util.Arrays;
     54 import java.util.concurrent.CountDownLatch;
     55 import java.util.concurrent.TimeUnit;
     56 
     57 class VirtualContainerView extends View {
     58 
     59     private static final String TAG = "VirtualContainerView";
     60     private static final int LOGIN_BUTTON_VIRTUAL_ID = 666;
     61 
     62     static final String LABEL_CLASS = "my.readonly.view";
     63     static final String TEXT_CLASS = "my.editable.view";
     64     static final String ID_URL_BAR = "my_url_bar";
     65     static final String ID_URL_BAR2 = "my_url_bar2";
     66 
     67     private final ArrayList<Line> mLines = new ArrayList<>();
     68     private final SparseArray<Item> mItems = new SparseArray<>();
     69     private AutofillManager mAfm;
     70     final AutofillId mLoginButtonId;
     71 
     72     private Line mFocusedLine;
     73     private int mNextChildId;
     74 
     75     private Paint mTextPaint;
     76     private int mTextHeight;
     77     private int mTopMargin;
     78     private int mLeftMargin;
     79     private int mVerticalGap;
     80     private int mLineLength;
     81     private int mFocusedColor;
     82     private int mUnfocusedColor;
     83     private boolean mSync = true;
     84     private boolean mOverrideDispatchProvideAutofillStructure = false;
     85 
     86     private boolean mCompatMode = false;
     87     private AccessibilityDelegate mAccessibilityDelegate;
     88     private AccessibilityNodeProvider mAccessibilityNodeProvider;
     89 
     90     /**
     91      * Enum defining how the view communicate visibility changes to the framework
     92      */
     93     enum VisibilityIntegrationMode {
     94         NOTIFY_AFM,
     95         OVERRIDE_IS_VISIBLE_TO_USER
     96     }
     97 
     98     private VisibilityIntegrationMode mVisibilityIntegrationMode;
     99 
    100     public VirtualContainerView(Context context, AttributeSet attrs) {
    101         super(context, attrs);
    102 
    103         setAutofillManager(context);
    104 
    105         mTextPaint = new Paint();
    106 
    107         mUnfocusedColor = Color.BLACK;
    108         mFocusedColor = Color.RED;
    109         mTextPaint.setStyle(Style.FILL);
    110         DisplayMetrics metrics = new DisplayMetrics();
    111         WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
    112         wm.getDefaultDisplay().getMetrics(metrics);
    113         mTopMargin = metrics.heightPixels * 5 / 100;
    114         mLeftMargin = metrics.widthPixels * 5 / 100;
    115         mTextHeight = metrics.widthPixels * 5 / 100; // adjust text size with display width
    116         mVerticalGap = metrics.heightPixels / 100;
    117 
    118         mLineLength = mTextHeight + mVerticalGap;
    119         mTextPaint.setTextSize(mTextHeight);
    120         Log.d(TAG, "Text height: " + mTextHeight);
    121         mLoginButtonId = new AutofillId(getAutofillId(), LOGIN_BUTTON_VIRTUAL_ID);
    122     }
    123 
    124     public void setAutofillManager(Context context) {
    125         mAfm = context.getSystemService(AutofillManager.class);
    126         Log.d(TAG, "Set AFM from " + context);
    127     }
    128 
    129     @Override
    130     public void autofill(SparseArray<AutofillValue> values) {
    131         Log.d(TAG, "autofill: " + values);
    132         if (mCompatMode) {
    133             Log.v(TAG, "using super.autofill() on compat mode");
    134             super.autofill(values);
    135             return;
    136         }
    137         for (int i = 0; i < values.size(); i++) {
    138             final int id = values.keyAt(i);
    139             final AutofillValue value = values.valueAt(i);
    140             final Item item = getItem(id);
    141             item.autofill(value.getTextValue());
    142         }
    143         postInvalidate();
    144     }
    145 
    146     @Override
    147     protected void onDraw(Canvas canvas) {
    148         super.onDraw(canvas);
    149 
    150         Log.d(TAG, "onDraw: " + mLines.size() + " lines; canvas:" + canvas);
    151         float x;
    152         float y = mTopMargin + mLineLength;
    153         for (int i = 0; i < mLines.size(); i++) {
    154             x = mLeftMargin;
    155             final Line line = mLines.get(i);
    156             if (!line.visible) {
    157                 continue;
    158             }
    159             Log.v(TAG, "Drawing '" + line + "' at " + x + "x" + y);
    160             mTextPaint.setColor(line.focused ? mFocusedColor : mUnfocusedColor);
    161             final String readOnlyText = line.label.text + ":  [";
    162             final String writeText = line.text.text + "]";
    163             // Paints the label first...
    164             canvas.drawText(readOnlyText, x, y, mTextPaint);
    165             // ...then paints the edit text and sets the proper boundary
    166             final float deltaX = mTextPaint.measureText(readOnlyText);
    167             x += deltaX;
    168             line.bounds.set((int) x, (int) (y - mLineLength),
    169                     (int) (x + mTextPaint.measureText(writeText)), (int) y);
    170             Log.d(TAG, "setBounds(" + x + ", " + y + "): " + line.bounds);
    171             canvas.drawText(writeText, x, y, mTextPaint);
    172             y += mLineLength;
    173         }
    174     }
    175 
    176     @Override
    177     public boolean onTouchEvent(MotionEvent event) {
    178         final int y = (int) event.getY();
    179         Log.d(TAG, "You can touch this: y=" + y + ", range=" + mLineLength + ", top=" + mTopMargin);
    180         int lowerY = mTopMargin;
    181         int upperY = -1;
    182         for (int i = 0; i < mLines.size(); i++) {
    183             upperY = lowerY + mLineLength;
    184             final Line line = mLines.get(i);
    185             Log.d(TAG, "Line " + i + " ranges from " + lowerY + " to " + upperY);
    186             if (lowerY <= y && y <= upperY) {
    187                 if (mFocusedLine != null) {
    188                     Log.d(TAG, "Removing focus from " + mFocusedLine);
    189                     mFocusedLine.changeFocus(false);
    190                 }
    191                 Log.d(TAG, "Changing focus to " + line);
    192                 mFocusedLine = line;
    193                 mFocusedLine.changeFocus(true);
    194                 invalidate();
    195                 break;
    196             }
    197             lowerY += mLineLength;
    198         }
    199         return super.onTouchEvent(event);
    200     }
    201 
    202     @Override
    203     public void dispatchProvideAutofillStructure(ViewStructure structure, int flags) {
    204         if (mOverrideDispatchProvideAutofillStructure) {
    205             Log.d(TAG, "Overriding dispatchProvideAutofillStructure()");
    206             structure.setAutofillId(getAutofillId());
    207             onProvideAutofillVirtualStructure(structure, flags);
    208         } else {
    209             super.dispatchProvideAutofillStructure(structure, flags);
    210         }
    211     }
    212 
    213     @Override
    214     public void onProvideAutofillVirtualStructure(ViewStructure structure, int flags) {
    215         Log.d(TAG, "onProvideAutofillVirtualStructure(): flags = " + flags);
    216         super.onProvideAutofillVirtualStructure(structure, flags);
    217 
    218         if (mCompatMode) {
    219             Log.v(TAG, "using super.onProvideAutofillVirtualStructure() on compat mode");
    220             return;
    221         }
    222 
    223         final String packageName = getContext().getPackageName();
    224         structure.setClassName(getClass().getName());
    225         final int childrenSize = mItems.size();
    226         int index = structure.addChildCount(childrenSize);
    227         final String syncMsg = mSync ? "" : " (async)";
    228         for (int i = 0; i < childrenSize; i++) {
    229             final Item item = mItems.valueAt(i);
    230             Log.d(TAG, "Adding new child" + syncMsg + " at index " + index + ": " + item);
    231             final ViewStructure child = mSync
    232                     ? structure.newChild(index)
    233                     : structure.asyncNewChild(index);
    234             child.setAutofillId(structure.getAutofillId(), item.id);
    235             child.setDataIsSensitive(item.sensitive);
    236             if (item.editable) {
    237                 child.setInputType(item.line.inputType);
    238             }
    239             index++;
    240             child.setClassName(item.className);
    241             // Must set "fake" idEntry because that's what the test cases use to find nodes.
    242             child.setId(1000 + index, packageName, "id", item.resourceId);
    243             child.setText(item.text);
    244             if (TextUtils.getTrimmedLength(item.text) > 0) {
    245                 // TODO: Must checked trimmed length because input fields use 8 empty spaces to
    246                 // set width
    247                 child.setAutofillValue(AutofillValue.forText(item.text));
    248             }
    249             child.setFocused(item.line.focused);
    250             child.setHtmlInfo(child.newHtmlInfoBuilder("TAGGY")
    251                     .addAttribute("a1", "v1")
    252                     .addAttribute("a2", "v2")
    253                     .addAttribute("a1", "v2")
    254                     .build());
    255             child.setAutofillHints(new String[] {"c", "a", "a", "b", "a", "a"});
    256 
    257             if (!mSync) {
    258                 Log.d(TAG, "Commiting virtual child");
    259                 child.asyncCommit();
    260             }
    261         }
    262     }
    263 
    264     @Override
    265     public boolean isVisibleToUserForAutofill(int virtualId) {
    266         boolean callSuper = true;
    267         if (mVisibilityIntegrationMode == null) {
    268             Log.w(TAG, "isVisibleToUserForAutofill(): mVisibilityIntegrationMode not set");
    269         } else {
    270             callSuper = mVisibilityIntegrationMode == VisibilityIntegrationMode.NOTIFY_AFM;
    271         }
    272         final boolean isVisible;
    273         if (callSuper) {
    274             isVisible = super.isVisibleToUserForAutofill(virtualId);
    275             Log.d(TAG, "isVisibleToUserForAutofill(" + virtualId + ") using super: " + isVisible);
    276         } else {
    277             final Item item = getItem(virtualId);
    278             isVisible = item.line.visible;
    279             Log.d(TAG, "isVisibleToUserForAutofill(" + virtualId + ") set by test: " + isVisible);
    280         }
    281         return isVisible;
    282     }
    283 
    284     /**
    285      * Emulates clicking the login button.
    286      */
    287     void clickLogin() {
    288         Log.d(TAG, "clickLogin()");
    289         if (mCompatMode) {
    290             sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED, LOGIN_BUTTON_VIRTUAL_ID);
    291         } else {
    292             mAfm.notifyViewClicked(this, LOGIN_BUTTON_VIRTUAL_ID);
    293         }
    294     }
    295 
    296     private Item getItem(int id) {
    297         final Item item = mItems.get(id);
    298         assertWithMessage("No item for id %s", id).that(item).isNotNull();
    299         return item;
    300     }
    301 
    302     private AccessibilityNodeInfo onProvideAutofillCompatModeAccessibilityNodeInfo() {
    303         final AccessibilityNodeInfo node = AccessibilityNodeInfo.obtain();
    304 
    305         final String packageName = getContext().getPackageName();
    306         node.setPackageName(packageName);
    307         node.setClassName(getClass().getName());
    308 
    309         final int childrenSize = mItems.size();
    310         for (int i = 0; i < childrenSize; i++) {
    311             final Item item = mItems.valueAt(i);
    312             final int id = i + 1;
    313             Log.d(TAG, "Adding new A11Y child with id " + id + ": " + item);
    314 
    315             node.addChild(this, id);
    316         }
    317 
    318         return node;
    319     }
    320 
    321     private AccessibilityNodeInfo onProvideAutofillCompatModeAccessibilityNodeInfoForLoginButton() {
    322         final AccessibilityNodeInfo node = AccessibilityNodeInfo.obtain();
    323         node.setSource(this, LOGIN_BUTTON_VIRTUAL_ID);
    324         node.setPackageName(getContext().getPackageName());
    325         // TODO(b/37566627): ideally this button should be visible / drawn in the canvas and contain
    326         // more properties like boundaries, class name, text etc...
    327         return node;
    328     }
    329 
    330     static void assertHtmlInfo(ViewNode node) {
    331         final String name = node.getText().toString();
    332         final HtmlInfo info = node.getHtmlInfo();
    333         assertWithMessage("no HTML info on %s", name).that(info).isNotNull();
    334         assertWithMessage("wrong HTML tag on %s", name).that(info.getTag()).isEqualTo("TAGGY");
    335         assertWithMessage("wrong attributes on %s", name).that(info.getAttributes())
    336                 .containsExactly(
    337                         new Pair<>("a1", "v1"),
    338                         new Pair<>("a2", "v2"),
    339                         new Pair<>("a1", "v2"));
    340     }
    341 
    342     Line addLine(String labelId, String label, String textId, String text, int inputType) {
    343         final Line line = new Line(labelId, label, textId, text, inputType);
    344         Log.d(TAG, "addLine: " + line);
    345         mLines.add(line);
    346         mItems.put(line.label.id, line.label);
    347         mItems.put(line.text.id, line.text);
    348         return line;
    349     }
    350 
    351     void setSync(boolean sync) {
    352         mSync = sync;
    353     }
    354 
    355     void setCompatMode(boolean compatMode) {
    356         mCompatMode = compatMode;
    357 
    358         if (mCompatMode) {
    359             setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES);
    360             mAccessibilityNodeProvider = new AccessibilityNodeProvider() {
    361                 @Override
    362                 public AccessibilityNodeInfo createAccessibilityNodeInfo(int virtualViewId) {
    363                     Log.d(TAG, "createAccessibilityNodeInfo(): id=" + virtualViewId);
    364                     switch (virtualViewId) {
    365                         case AccessibilityNodeProvider.HOST_VIEW_ID:
    366                             return onProvideAutofillCompatModeAccessibilityNodeInfo();
    367                         case LOGIN_BUTTON_VIRTUAL_ID:
    368                             return onProvideAutofillCompatModeAccessibilityNodeInfoForLoginButton();
    369                         default:
    370                             final Item item = getItem(virtualViewId);
    371                             return item.provideAccessibilityNodeInfo(VirtualContainerView.this,
    372                                     getContext());
    373                     }
    374                 }
    375 
    376                 @Override
    377                 public boolean performAction(int virtualViewId, int action, Bundle arguments) {
    378                     if (action == AccessibilityNodeInfo.ACTION_SET_TEXT) {
    379                         final CharSequence text = arguments.getCharSequence(
    380                                 AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE);
    381                         final Item item = getItem(virtualViewId);
    382                         item.autofill(text);
    383                         return true;
    384                     }
    385 
    386                     return false;
    387                 }
    388             };
    389             mAccessibilityDelegate = new AccessibilityDelegate() {
    390                 @Override
    391                 public AccessibilityNodeProvider getAccessibilityNodeProvider(View host) {
    392                     return mAccessibilityNodeProvider;
    393                 }
    394             };
    395 
    396             setAccessibilityDelegate(mAccessibilityDelegate);
    397         }
    398     }
    399 
    400     void setOverrideDispatchProvideAutofillStructure(boolean flag) {
    401         mOverrideDispatchProvideAutofillStructure = flag;
    402     }
    403 
    404     private void sendAccessibilityEvent(int eventType, int virtualId) {
    405         final AccessibilityEvent event = AccessibilityEvent.obtain();
    406         event.setEventType(eventType);
    407         event.setSource(VirtualContainerView.this, virtualId);
    408         event.setEnabled(true);
    409         event.setPackageName(getContext().getPackageName());
    410         Log.v(TAG, "sendAccessibilityEvent(" + eventType + ", " + virtualId + "): " + event);
    411         getContext().getSystemService(AccessibilityManager.class).sendAccessibilityEvent(event);
    412     }
    413 
    414     final class Line {
    415 
    416         final Item label;
    417         final Item text;
    418         // Boundaries of the text field, relative to the CustomView
    419         final Rect bounds = new Rect();
    420         // Boundaries of the text field, relative to the screen
    421         Rect absBounds;
    422 
    423         private boolean focused;
    424         private boolean visible = true;
    425         private final int inputType;
    426 
    427         private Line(String labelId, String label, String textId, String text, int inputType) {
    428             this.label = new Item(this, ++mNextChildId, labelId, label, false, false);
    429             this.text = new Item(this, ++mNextChildId, textId, text, true, true);
    430             this.inputType = inputType;
    431         }
    432 
    433         void changeFocus(boolean focused) {
    434             this.focused = focused;
    435 
    436             if (focused) {
    437                 absBounds = getAbsCoordinates();
    438                 Log.v(TAG, "Setting absBounds for " + text.id + " on focus change: " + absBounds);
    439             }
    440 
    441             if (mCompatMode) {
    442                 sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED, text.id);
    443                 return;
    444             }
    445 
    446             if (focused) {
    447                 Log.d(TAG, "focus gained on " + text.id + "; absBounds=" + absBounds);
    448                 mAfm.notifyViewEntered(VirtualContainerView.this, text.id, absBounds);
    449             } else {
    450                 Log.d(TAG, "focus lost on " + text.id);
    451                 mAfm.notifyViewExited(VirtualContainerView.this, text.id);
    452             }
    453         }
    454 
    455         void setVisibilityIntegrationMode(VisibilityIntegrationMode mode) {
    456             mVisibilityIntegrationMode = mode;
    457         }
    458 
    459         void changeVisibility(boolean visible) {
    460             if (mVisibilityIntegrationMode == null) {
    461                 throw new IllegalStateException("must call setVisibilityIntegrationMode() first");
    462             }
    463             if (this.visible == visible) {
    464                 return;
    465             }
    466             this.visible = visible;
    467             Log.d(TAG, "visibility changed view: " + text.id + "; visible:" + visible
    468                     + "; integrationMode: " + mVisibilityIntegrationMode);
    469             if (mVisibilityIntegrationMode == VisibilityIntegrationMode.NOTIFY_AFM) {
    470                 mAfm.notifyViewVisibilityChanged(VirtualContainerView.this, text.id, visible);
    471             }
    472             invalidate();
    473         }
    474 
    475         Rect getAbsCoordinates() {
    476             // Must offset the boundaries so they're relative to the CustomView.
    477             final int offset[] = new int[2];
    478             getLocationOnScreen(offset);
    479             final Rect absBounds = new Rect(bounds.left + offset[0],
    480                     bounds.top + offset[1],
    481                     bounds.right + offset[0], bounds.bottom + offset[1]);
    482             Log.v(TAG, "getAbsCoordinates() for " + text.id + ": bounds=" + bounds
    483                     + " offset: " + Arrays.toString(offset) + " absBounds: " + absBounds);
    484             return absBounds;
    485         }
    486 
    487         void setText(String value) {
    488             text.text = value;
    489             final AutofillManager autofillManager =
    490                     getContext().getSystemService(AutofillManager.class);
    491             if (mCompatMode) {
    492                 sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED, text.id);
    493             } else {
    494                 if (autofillManager != null) {
    495                     autofillManager.notifyValueChanged(VirtualContainerView.this, text.id,
    496                             AutofillValue.forText(text.text));
    497                 }
    498             }
    499             invalidate();
    500         }
    501 
    502         void setTextChangedListener(TextWatcher listener) {
    503             text.listener = listener;
    504         }
    505 
    506         @Override
    507         public String toString() {
    508             return "Label: " + label + " Text: " + text + " Focused: " + focused
    509                     + " Visible: " + visible;
    510         }
    511 
    512         final class OneTimeLineWatcher implements TextWatcher {
    513             private final CountDownLatch latch;
    514             private final CharSequence expected;
    515 
    516             OneTimeLineWatcher(CharSequence expectedValue) {
    517                 this.expected = expectedValue;
    518                 this.latch = new CountDownLatch(1);
    519             }
    520 
    521             @Override
    522             public void beforeTextChanged(CharSequence s, int start, int count, int after) {
    523             }
    524 
    525             @Override
    526             public void onTextChanged(CharSequence s, int start, int before, int count) {
    527                 latch.countDown();
    528             }
    529 
    530             @Override
    531             public void afterTextChanged(Editable s) {
    532             }
    533 
    534             void assertAutoFilled() throws Exception {
    535                 final boolean set = latch.await(FILL_TIMEOUT.ms(), TimeUnit.MILLISECONDS);
    536                 assertWithMessage("Timeout (%s ms) on Line %s", FILL_TIMEOUT.ms(), label)
    537                         .that(set).isTrue();
    538                 final String actual = text.text.toString();
    539                 assertWithMessage("Wrong auto-fill value on Line %s", label)
    540                         .that(actual).isEqualTo(expected.toString());
    541             }
    542         }
    543     }
    544 
    545     static final class Item {
    546         private final Line line;
    547         final int id;
    548         private final String resourceId;
    549         private CharSequence text;
    550         private final boolean editable;
    551         private final boolean sensitive;
    552         private final String className;
    553         private TextWatcher listener;
    554 
    555         Item(Line line, int id, String resourceId, CharSequence text, boolean editable,
    556                 boolean sensitive) {
    557             this.line = line;
    558             this.id = id;
    559             this.resourceId = resourceId;
    560             this.text = text;
    561             this.editable = editable;
    562             this.sensitive = sensitive;
    563             this.className = editable ? TEXT_CLASS : LABEL_CLASS;
    564         }
    565 
    566         AccessibilityNodeInfo provideAccessibilityNodeInfo(View parent, Context context) {
    567             final AccessibilityNodeInfo node = AccessibilityNodeInfo.obtain();
    568             node.setSource(parent, id);
    569             node.setPackageName(context.getPackageName());
    570             node.setClassName(className);
    571             node.setEditable(editable);
    572             node.setViewIdResourceName(resourceId);
    573             node.setVisibleToUser(true);
    574             node.setInputType(line.inputType);
    575             if (line.absBounds != null) {
    576                 node.setBoundsInScreen(line.absBounds);
    577             }
    578             if (TextUtils.getTrimmedLength(text) > 0) {
    579                 // TODO: Must checked trimmed length because input fields use 8 empty spaces to
    580                 // set width
    581                 node.setText(text);
    582             }
    583             return node;
    584         }
    585 
    586         private void autofill(CharSequence value) {
    587             if (!editable) {
    588                 Log.w(TAG, "Item for id " + id + " is not editable: " + this);
    589                 return;
    590             }
    591             text = value;
    592             if (listener != null) {
    593                 Log.d(TAG, "Notify listener: " + text);
    594                 listener.onTextChanged(text, 0, 0, 0);
    595             }
    596         }
    597 
    598         @Override
    599         public String toString() {
    600             return id + "/" + resourceId + ": " + text + (editable ? " (editable)" : " (read-only)"
    601                     + (sensitive ? " (sensitive)" : " (sanitized"));
    602         }
    603     }
    604 }
    605