1 /* 2 * Copyright (C) 2012 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 com.android.tools.lint.checks; 18 19 import static com.android.AndroidConstants.FD_RES_LAYOUT; 20 import static com.android.tools.lint.detector.api.LintConstants.ANDROID_STRING_RESOURCE_PREFIX; 21 import static com.android.tools.lint.detector.api.LintConstants.ANDROID_URI; 22 import static com.android.tools.lint.detector.api.LintConstants.ATTR_ID; 23 import static com.android.tools.lint.detector.api.LintConstants.ATTR_LAYOUT_ALIGN_PARENT_LEFT; 24 import static com.android.tools.lint.detector.api.LintConstants.ATTR_LAYOUT_ALIGN_PARENT_RIGHT; 25 import static com.android.tools.lint.detector.api.LintConstants.ATTR_LAYOUT_TO_LEFT_OF; 26 import static com.android.tools.lint.detector.api.LintConstants.ATTR_LAYOUT_TO_RIGHT_OF; 27 import static com.android.tools.lint.detector.api.LintConstants.ATTR_NAME; 28 import static com.android.tools.lint.detector.api.LintConstants.ATTR_ORIENTATION; 29 import static com.android.tools.lint.detector.api.LintConstants.ATTR_TEXT; 30 import static com.android.tools.lint.detector.api.LintConstants.BUTTON; 31 import static com.android.tools.lint.detector.api.LintConstants.LINEAR_LAYOUT; 32 import static com.android.tools.lint.detector.api.LintConstants.RELATIVE_LAYOUT; 33 import static com.android.tools.lint.detector.api.LintConstants.STRING_RESOURCE_PREFIX; 34 import static com.android.tools.lint.detector.api.LintConstants.TABLE_ROW; 35 import static com.android.tools.lint.detector.api.LintConstants.TAG_STRING; 36 import static com.android.tools.lint.detector.api.LintConstants.VALUE_TRUE; 37 import static com.android.tools.lint.detector.api.LintConstants.VALUE_VERTICAL; 38 39 import com.android.annotations.NonNull; 40 import com.android.resources.ResourceFolderType; 41 import com.android.tools.lint.detector.api.Category; 42 import com.android.tools.lint.detector.api.Context; 43 import com.android.tools.lint.detector.api.Issue; 44 import com.android.tools.lint.detector.api.LintUtils; 45 import com.android.tools.lint.detector.api.Location; 46 import com.android.tools.lint.detector.api.ResourceXmlDetector; 47 import com.android.tools.lint.detector.api.Scope; 48 import com.android.tools.lint.detector.api.Severity; 49 import com.android.tools.lint.detector.api.Speed; 50 import com.android.tools.lint.detector.api.XmlContext; 51 52 import org.w3c.dom.Element; 53 import org.w3c.dom.Node; 54 import org.w3c.dom.NodeList; 55 56 import java.io.File; 57 import java.util.ArrayList; 58 import java.util.Arrays; 59 import java.util.Collection; 60 import java.util.HashMap; 61 import java.util.HashSet; 62 import java.util.List; 63 import java.util.Map; 64 import java.util.Set; 65 66 /** 67 * Check which looks at the order of buttons in dialogs and makes sure that 68 * "the dismissive action of a dialog is always on the left whereas the affirmative actions 69 * are on the right." 70 * <p> 71 * This only looks for the affirmative and dismissive actions named "OK" and "Cancel"; 72 * "Cancel" usually works, but the affirmative action often has many other names -- "Done", 73 * "Send", "Go", etc. 74 * <p> 75 * TODO: Perhaps we should look for Yes/No dialogs and suggested they be rephrased as 76 * Cancel/OK dialogs? Similarly, consider "Abort" a synonym for "Cancel" ? 77 */ 78 public class ButtonDetector extends ResourceXmlDetector { 79 /** Name of cancel value ("Cancel") */ 80 private static final String CANCEL_LABEL = "Cancel"; 81 /** Name of OK value ("Cancel") */ 82 private static final String OK_LABEL = "OK"; 83 /** Name of Back value ("Back") */ 84 private static final String BACK_LABEL = "Back"; 85 86 /** Layout text attribute reference to {@code @android:string/ok} */ 87 private static final String ANDROID_OK_RESOURCE = 88 ANDROID_STRING_RESOURCE_PREFIX + "ok"; //$NON-NLS-1$ 89 /** Layout text attribute reference to {@code @android:string/cancel} */ 90 private static final String ANDROID_CANCEL_RESOURCE = 91 ANDROID_STRING_RESOURCE_PREFIX + "cancel"; //$NON-NLS-1$ 92 93 /** The main issue discovered by this detector */ 94 public static final Issue ORDER = Issue.create( 95 "ButtonOrder", //$NON-NLS-1$ 96 "Ensures the dismissive action of a dialog is on the left and affirmative on " + 97 "the right", 98 99 "According to the Android Design Guide,\n" + 100 "\n" + 101 "\"Action buttons are typically Cancel and/or OK, with OK indicating the preferred " + 102 "or most likely action. However, if the options consist of specific actions such " + 103 "as Close or Wait rather than a confirmation or cancellation of the action " + 104 "described in the content, then all the buttons should be active verbs. As a rule, " + 105 "the dismissive action of a dialog is always on the left whereas the affirmative " + 106 "actions are on the right.\"\n" + 107 "\n" + 108 "This check looks for button bars and buttons which look like cancel buttons, " + 109 "and makes sure that these are on the left.", 110 111 Category.USABILITY, 112 8, 113 Severity.WARNING, 114 ButtonDetector.class, 115 Scope.RESOURCE_FILE_SCOPE) 116 .setMoreInfo( 117 "http://developer.android.com/design/building-blocks/dialogs.html"); //$NON-NLS-1$ 118 119 /** The main issue discovered by this detector */ 120 public static final Issue BACKBUTTON = Issue.create( 121 "BackButton", //$NON-NLS-1$ 122 "Looks for Back buttons, which are not common on the Android platform.", 123 // TODO: Look for ">" as label suffixes as well 124 125 "According to the Android Design Guide,\n" + 126 "\n" + 127 "\"Other platforms use an explicit back button with label to allow the user " + 128 "to navigate up the application's hierarchy. Instead, Android uses the main " + 129 "action bar's app icon for hierarchical navigation and the navigation bar's " + 130 "back button for temporal navigation.\"" + 131 "\n" + 132 "This check is not very sophisticated (it just looks for buttons with the " + 133 "label \"Back\"), so it is disabled by default to not trigger on common " + 134 "scenarios like pairs of Back/Next buttons to paginate through screens.", 135 136 Category.USABILITY, 137 6, 138 Severity.WARNING, 139 ButtonDetector.class, 140 Scope.RESOURCE_FILE_SCOPE) 141 .setEnabledByDefault(false) 142 .setMoreInfo( 143 "http://developer.android.com/design/patterns/pure-android.html"); //$NON-NLS-1$ 144 145 /** The main issue discovered by this detector */ 146 public static final Issue CASE = Issue.create( 147 "ButtonCase", //$NON-NLS-1$ 148 "Ensures that Cancel/OK dialog buttons use the canonical capitalization", 149 150 "The standard capitalization for OK/Cancel dialogs is \"OK\" and \"Cancel\". " + 151 "To ensure that your dialogs use the standard strings, you can use " + 152 "the resource strings @android:string/ok and @android:string/cancel.", 153 154 Category.USABILITY, 155 2, 156 Severity.WARNING, 157 ButtonDetector.class, 158 Scope.RESOURCE_FILE_SCOPE); 159 160 /** Set of resource names whose value was either OK or Cancel */ 161 private Set<String> mApplicableResources; 162 163 /** 164 * Map of resource names we'd like resolved into strings in phase 2. The 165 * values should be filled in with the actual string contents. 166 */ 167 private Map<String, String> mKeyToLabel; 168 169 /** 170 * Set of elements we've already warned about. If we've already complained 171 * about a cancel button, don't also report the OK button (since it's listed 172 * for the warnings on OK buttons). 173 */ 174 private Set<Element> mIgnore; 175 176 /** Constructs a new {@link ButtonDetector} */ ButtonDetector()177 public ButtonDetector() { 178 } 179 180 @Override getSpeed()181 public Speed getSpeed() { 182 return Speed.FAST; 183 } 184 185 @Override getApplicableElements()186 public Collection<String> getApplicableElements() { 187 return Arrays.asList(BUTTON, TAG_STRING); 188 } 189 190 @Override appliesTo(@onNull ResourceFolderType folderType)191 public boolean appliesTo(@NonNull ResourceFolderType folderType) { 192 return folderType == ResourceFolderType.LAYOUT || folderType == ResourceFolderType.VALUES; 193 } 194 195 @Override afterCheckProject(Context context)196 public void afterCheckProject(Context context) { 197 int phase = context.getPhase(); 198 if (phase == 1 && mApplicableResources != null) { 199 // We found resources for the string "Cancel"; perform a second pass 200 // where we check layout text attributes against these strings. 201 context.getDriver().requestRepeat(this, Scope.RESOURCE_FILE_SCOPE); 202 } 203 } 204 stripLabel(String text)205 private String stripLabel(String text) { 206 text = text.trim(); 207 if (text.length() > 2 208 && (text.charAt(0) == '"' || text.charAt(0) == '\'') 209 && (text.charAt(0) == text.charAt(text.length() - 1))) { 210 text = text.substring(1, text.length() - 1); 211 } 212 213 return text; 214 } 215 216 @Override visitElement(XmlContext context, Element element)217 public void visitElement(XmlContext context, Element element) { 218 // This detector works in two passes. 219 // In pass 1, it looks in layout files for hardcoded strings of "Cancel", or 220 // references to @string/cancel or @android:string/cancel. 221 // It also looks in values/ files for strings whose value is "Cancel", 222 // and if found, stores the corresponding keys in a map. (This is necessary 223 // since value files are processed after layout files). 224 // Then, if at the end of phase 1 any "Cancel" string resources were 225 // found in the value files, then it requests a *second* phase, 226 // where it looks only for <Button>'s whose text matches one of the 227 // cancel string resources. 228 int phase = context.getPhase(); 229 String tagName = element.getTagName(); 230 if (phase == 1 && tagName.equals(TAG_STRING)) { 231 NodeList childNodes = element.getChildNodes(); 232 for (int i = 0, n = childNodes.getLength(); i < n; i++) { 233 Node child = childNodes.item(i); 234 if (child.getNodeType() == Node.TEXT_NODE) { 235 String text = child.getNodeValue(); 236 for (int j = 0, len = text.length(); j < len; j++) { 237 char c = text.charAt(j); 238 if (!Character.isWhitespace(c)) { 239 if (c == '"' || c == '\'') { 240 continue; 241 } 242 if (LintUtils.startsWith(text, CANCEL_LABEL, j)) { 243 String label = stripLabel(text); 244 if (label.equalsIgnoreCase(CANCEL_LABEL)) { 245 String name = element.getAttribute(ATTR_NAME); 246 foundResource(context, name, element); 247 248 if (!label.equals(CANCEL_LABEL) 249 && isEnglishResource(context) 250 && context.isEnabled(CASE)) { 251 assert label.equalsIgnoreCase(CANCEL_LABEL); 252 context.report(CASE, context.getLocation(element), 253 String.format( 254 "The standard Android way to capitalize %1$s " + 255 "is \"Cancel\" (tip: use @android:string/ok instead)", 256 label), null); 257 } 258 } 259 } else if (LintUtils.startsWith(text, OK_LABEL, j)) { 260 String label = stripLabel(text); 261 if (label.equalsIgnoreCase(OK_LABEL)) { 262 String name = element.getAttribute(ATTR_NAME); 263 foundResource(context, name, element); 264 265 if (!label.equals(OK_LABEL) 266 && isEnglishResource(context) 267 && context.isEnabled(CASE)) { 268 assert text.equalsIgnoreCase(OK_LABEL); 269 context.report(CASE, context.getLocation(element), 270 String.format( 271 "The standard Android way to capitalize %1$s " + 272 "is \"OK\" (tip: use @android:string/ok instead)", 273 label), null); 274 } 275 } 276 } else if (LintUtils.startsWith(text, BACK_LABEL, j) && 277 stripLabel(text).equalsIgnoreCase(BACK_LABEL)) { 278 String name = element.getAttribute(ATTR_NAME); 279 foundResource(context, name, element); 280 } 281 break; 282 } 283 } 284 } 285 } 286 } else if (tagName.equals(BUTTON)) { 287 String text = element.getAttributeNS(ANDROID_URI, ATTR_TEXT); 288 if (context.getDriver().getPhase() == 2) { 289 if (mApplicableResources.contains(text)) { 290 String key = text; 291 if (key.startsWith(STRING_RESOURCE_PREFIX)) { 292 key = key.substring(STRING_RESOURCE_PREFIX.length()); 293 } 294 String label = mKeyToLabel.get(key); 295 boolean isCancel = CANCEL_LABEL.equalsIgnoreCase(label); 296 if (isCancel) { 297 if (isWrongCancelPosition(element)) { 298 reportCancelPosition(context, element); 299 } 300 } else if (OK_LABEL.equalsIgnoreCase(label)) { 301 if (isWrongOkPosition(element)) { 302 reportOkPosition(context, element); 303 } 304 } else { 305 assert BACK_LABEL.equalsIgnoreCase(label); 306 Location location = context.getLocation(element); 307 if (context.isEnabled(BACKBUTTON)) { 308 context.report(BACKBUTTON, location, 309 "Back buttons are not standard on Android; see design guide's " + 310 "navigation section", null); 311 } 312 } 313 } 314 } else if (text.equals(CANCEL_LABEL) || text.equals(ANDROID_CANCEL_RESOURCE)) { 315 if (isWrongCancelPosition(element)) { 316 reportCancelPosition(context, element); 317 } 318 } else if (text.equals(OK_LABEL) || text.equals(ANDROID_OK_RESOURCE)) { 319 if (isWrongOkPosition(element)) { 320 reportOkPosition(context, element); 321 } 322 } 323 } 324 } 325 326 /** Report the given OK button as being in the wrong position */ reportOkPosition(XmlContext context, Element element)327 private void reportOkPosition(XmlContext context, Element element) { 328 report(context, element, false /*isCancel*/); 329 } 330 331 /** Report the given Cancel button as being in the wrong position */ reportCancelPosition(XmlContext context, Element element)332 private void reportCancelPosition(XmlContext context, Element element) { 333 report(context, element, true /*isCancel*/); 334 } 335 336 337 /** The Ok/Cancel detector only works with default and English locales currently. 338 * TODO: Add in patterns for other languages. We can use the 339 * @android:string/ok and @android:string/cancel localizations to look 340 * up the canonical ones. */ isEnglishResource(XmlContext context)341 private boolean isEnglishResource(XmlContext context) { 342 String folder = context.file.getParentFile().getName(); 343 if (folder.indexOf('-') != -1) { 344 String[] qualifiers = folder.split("-"); //$NON-NLS-1$ 345 for (String qualifier : qualifiers) { 346 if (qualifier.equals("en")) { //$NON-NLS-1$ 347 return true; 348 } 349 } 350 return false; 351 } 352 353 // Default folder ("values") - may not be English but we'll consider matches 354 // on "OK", "Cancel" and "Back" as matches there 355 return true; 356 } 357 358 /** 359 * We've found a resource reference to some label we're interested in ("OK", 360 * "Cancel", "Back", ...). Record the corresponding name such that in the 361 * next pass through the layouts we can check the context (for OK/Cancel the 362 * button order etc). 363 */ foundResource(XmlContext context, String name, Element element)364 private void foundResource(XmlContext context, String name, Element element) { 365 if (!isEnglishResource(context)) { 366 return; 367 } 368 369 if (mApplicableResources == null) { 370 mApplicableResources = new HashSet<String>(); 371 } 372 373 mApplicableResources.add(STRING_RESOURCE_PREFIX + name); 374 375 // ALSO record all the other string resources in this file to pick up other 376 // labels. If you define "OK" in one resource file and "Cancel" in another 377 // this won't work, but that's probably not common and has lower overhead. 378 Node parentNode = element.getParentNode(); 379 380 List<Element> items = LintUtils.getChildren(parentNode); 381 if (mKeyToLabel == null) { 382 mKeyToLabel = new HashMap<String, String>(items.size()); 383 } 384 for (Element item : items) { 385 String itemName = item.getAttribute(ATTR_NAME); 386 NodeList childNodes = item.getChildNodes(); 387 for (int i = 0, n = childNodes.getLength(); i < n; i++) { 388 Node child = childNodes.item(i); 389 if (child.getNodeType() == Node.TEXT_NODE) { 390 String text = stripLabel(child.getNodeValue()); 391 if (text.length() > 0) { 392 mKeyToLabel.put(itemName, text); 393 break; 394 } 395 } 396 } 397 } 398 } 399 400 /** Report the given OK/Cancel button as being in the wrong position */ report(XmlContext context, Element element, boolean isCancel)401 private void report(XmlContext context, Element element, boolean isCancel) { 402 if (!context.isEnabled(ORDER)) { 403 return; 404 } 405 406 if (mIgnore != null && mIgnore.contains(element)) { 407 return; 408 } 409 410 int target = context.getProject().getTargetSdk(); 411 if (target < 14) { 412 // If you're only targeting pre-ICS UI's, this is not an issue 413 return; 414 } 415 416 boolean mustCreateIcsLayout = false; 417 if (context.getProject().getMinSdk() < 14) { 418 // If you're *also* targeting pre-ICS UIs, then this reverse button 419 // order is correct for layouts intended for pre-ICS and incorrect for 420 // ICS layouts. 421 // 422 // Therefore, we need to know if this layout is an ICS layout or 423 // a pre-ICS layout. 424 boolean isIcsLayout = context.getFolderVersion() >= 14; 425 if (!isIcsLayout) { 426 // This layout is not an ICS layout. However, there *must* also be 427 // an ICS layout here, or this button order will be wrong: 428 File res = context.file.getParentFile().getParentFile(); 429 File[] resFolders = res.listFiles(); 430 String fileName = context.file.getName(); 431 if (resFolders != null) { 432 for (File folder : resFolders) { 433 String folderName = folder.getName(); 434 if (folderName.startsWith(FD_RES_LAYOUT) 435 && folderName.contains("-v14")) { //$NON-NLS-1$ 436 File layout = new File(folder, fileName); 437 if (layout.exists()) { 438 // Yes, a v14 specific layout is available so this pre-ICS 439 // layout order is not a problem 440 return; 441 } 442 } 443 } 444 } 445 mustCreateIcsLayout = true; 446 } 447 } 448 449 List<Element> buttons = LintUtils.getChildren(element.getParentNode()); 450 451 if (mIgnore == null) { 452 mIgnore = new HashSet<Element>(); 453 } 454 for (Element button : buttons) { 455 // Mark all the siblings in the ignore list to ensure that we don't 456 // report *both* the Cancel and the OK button in "OK | Cancel" 457 mIgnore.add(button); 458 } 459 460 String message; 461 if (isCancel) { 462 message = "Cancel button should be on the left"; 463 } else { 464 message = "OK button should be on the right"; 465 } 466 467 if (mustCreateIcsLayout) { 468 message = String.format( 469 "Layout uses the wrong button order for API >= 14: Create a " + 470 "layout-v14/%1$s file with opposite order: %2$s", 471 context.file.getName(), message); 472 } 473 474 // Show existing button order? We can only do that for LinearLayouts 475 // since in for example a RelativeLayout the order of the elements may 476 // not be the same as the visual order 477 String layout = element.getParentNode().getNodeName(); 478 if (layout.equals(LINEAR_LAYOUT) || layout.equals(TABLE_ROW)) { 479 List<String> labelList = getLabelList(buttons); 480 String wrong = describeButtons(labelList); 481 sortButtons(labelList); 482 String right = describeButtons(labelList); 483 message += String.format(" (was \"%1$s\", should be \"%2$s\")", wrong, right); 484 } 485 486 Location location = context.getLocation(element); 487 context.report(ORDER, location, message, null); 488 } 489 490 /** 491 * Sort a list of label buttons into the expected order (Cancel on the left, 492 * OK on the right 493 */ sortButtons(List<String> labelList)494 private void sortButtons(List<String> labelList) { 495 for (int i = 0, n = labelList.size(); i < n; i++) { 496 String label = labelList.get(i); 497 if (label.equalsIgnoreCase(CANCEL_LABEL) && i > 0) { 498 swap(labelList, 0, i); 499 } else if (label.equalsIgnoreCase(OK_LABEL) && i < n - 1) { 500 swap(labelList, n - 1, i); 501 } 502 } 503 } 504 505 /** Swaps the strings at positions i and j */ swap(List<String> strings, int i, int j)506 private static void swap(List<String> strings, int i, int j) { 507 if (i != j) { 508 String temp = strings.get(i); 509 strings.set(i, strings.get(j)); 510 strings.set(j, temp); 511 } 512 } 513 514 /** Creates a display string for a list of button labels, such as "Cancel | OK" */ describeButtons(List<String> labelList)515 private String describeButtons(List<String> labelList) { 516 StringBuilder sb = new StringBuilder(); 517 for (String label : labelList) { 518 if (sb.length() > 0) { 519 sb.append(" | "); //$NON-NLS-1$ 520 } 521 sb.append(label); 522 } 523 524 return sb.toString(); 525 } 526 527 /** Returns the ordered list of button labels */ getLabelList(List<Element> views)528 private List<String> getLabelList(List<Element> views) { 529 List<String> labels = new ArrayList<String>(); 530 531 if (mIgnore == null) { 532 mIgnore = new HashSet<Element>(); 533 } 534 535 for (Element view : views) { 536 if (view.getTagName().equals(BUTTON)) { 537 String text = view.getAttributeNS(ANDROID_URI, ATTR_TEXT); 538 String label = getLabel(text); 539 labels.add(label); 540 541 // Mark all the siblings in the ignore list to ensure that we don't 542 // report *both* the Cancel and the OK button in "OK | Cancel" 543 mIgnore.add(view); 544 } 545 } 546 547 return labels; 548 } 549 getLabel(String key)550 private String getLabel(String key) { 551 String label = null; 552 if (key.startsWith(ANDROID_STRING_RESOURCE_PREFIX)) { 553 if (key.equals(ANDROID_OK_RESOURCE)) { 554 label = OK_LABEL; 555 } else if (key.equals(ANDROID_CANCEL_RESOURCE)) { 556 label = CANCEL_LABEL; 557 } 558 } else if (mKeyToLabel != null) { 559 if (key.startsWith(STRING_RESOURCE_PREFIX)) { 560 label = mKeyToLabel.get(key.substring(STRING_RESOURCE_PREFIX.length())); 561 } 562 } 563 564 if (label == null) { 565 label = key; 566 } 567 568 if (label.indexOf(' ') != -1 && label.indexOf('"') == -1) { 569 label = '"' + label + '"'; 570 } 571 572 return label; 573 } 574 575 /** Is the cancel button in the wrong position? It has to be on the left. */ isWrongCancelPosition(Element element)576 private boolean isWrongCancelPosition(Element element) { 577 return isWrongPosition(element, true /*isCancel*/); 578 } 579 580 /** Is the OK button in the wrong position? It has to be on the right. */ isWrongOkPosition(Element element)581 private boolean isWrongOkPosition(Element element) { 582 return isWrongPosition(element, false /*isCancel*/); 583 } 584 585 /** Is the given button in the wrong position? */ isWrongPosition(Element element, boolean isCancel)586 private boolean isWrongPosition(Element element, boolean isCancel) { 587 Node parentNode = element.getParentNode(); 588 if (parentNode.getNodeType() != Node.ELEMENT_NODE) { 589 return false; 590 } 591 Element parent = (Element) parentNode; 592 593 // Don't warn about single Cancel / OK buttons 594 if (LintUtils.getChildCount(parent) < 2) { 595 return false; 596 } 597 598 String layout = parent.getTagName(); 599 if (layout.equals(LINEAR_LAYOUT) || layout.equals(TABLE_ROW)) { 600 String orientation = parent.getAttributeNS(ANDROID_URI, ATTR_ORIENTATION); 601 if (VALUE_VERTICAL.equals(orientation)) { 602 return false; 603 } 604 605 if (isCancel) { 606 Node n = element.getPreviousSibling(); 607 while (n != null) { 608 if (n.getNodeType() == Node.ELEMENT_NODE) { 609 return true; 610 } 611 n = n.getPreviousSibling(); 612 } 613 } else { 614 Node n = element.getNextSibling(); 615 while (n != null) { 616 if (n.getNodeType() == Node.ELEMENT_NODE) { 617 return true; 618 } 619 n = n.getNextSibling(); 620 } 621 } 622 623 return false; 624 } else if (layout.equals(RELATIVE_LAYOUT)) { 625 // In RelativeLayouts, look for attachments which look like a clear sign 626 // that the OK or Cancel buttons are out of order: 627 // -- a left attachment on a Cancel button (where the left attachment 628 // is a button; we don't want to complain if it's pointing to a spacer 629 // or image or progress indicator etc) 630 // -- a right-side parent attachment on a Cancel button (unless it's also 631 // attached on the left, e.g. a cancel button stretching across the 632 // layout) 633 // etc. 634 if (isCancel) { 635 if (element.hasAttributeNS(ANDROID_URI, ATTR_LAYOUT_TO_RIGHT_OF) 636 && isButtonId(parent, element.getAttributeNS(ANDROID_URI, 637 ATTR_LAYOUT_TO_RIGHT_OF))) { 638 return true; 639 } 640 if (isTrue(element, ATTR_LAYOUT_ALIGN_PARENT_RIGHT) && 641 !isTrue(element, ATTR_LAYOUT_ALIGN_PARENT_LEFT)) { 642 return true; 643 } 644 } else { 645 if (element.hasAttributeNS(ANDROID_URI, ATTR_LAYOUT_TO_LEFT_OF) 646 && isButtonId(parent, element.getAttributeNS(ANDROID_URI, 647 ATTR_LAYOUT_TO_RIGHT_OF))) { 648 return true; 649 } 650 if (isTrue(element, ATTR_LAYOUT_ALIGN_PARENT_LEFT) && 651 !isTrue(element, ATTR_LAYOUT_ALIGN_PARENT_RIGHT)) { 652 return true; 653 } 654 } 655 656 return false; 657 } else { 658 // TODO: Consider other button layouts - GridLayouts, custom views extending 659 // LinearLayout etc? 660 return false; 661 } 662 } 663 664 /** 665 * Returns true if the given attribute (in the Android namespace) is set to 666 * true on the given element 667 */ isTrue(Element element, String attribute)668 private static boolean isTrue(Element element, String attribute) { 669 return VALUE_TRUE.equals(element.getAttributeNS(ANDROID_URI, attribute)); 670 } 671 672 /** Is the given target id the id of a {@code <Button>} within this RelativeLayout? */ isButtonId(Element parent, String targetId)673 private boolean isButtonId(Element parent, String targetId) { 674 for (Element child : LintUtils.getChildren(parent)) { 675 String id = child.getAttributeNS(ANDROID_URI, ATTR_ID); 676 if (LintUtils.idReferencesMatch(id, targetId)) { 677 return child.getTagName().equals(BUTTON); 678 } 679 } 680 return false; 681 } 682 }