blob: 7bd4799a6bd5c462cc6bd07d409c8119dd0ba613 [file] [log] [blame]
/*
* Copyright (C) 2013 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.tools.lint.checks;
import static com.android.SdkConstants.ANDROID_PREFIX;
import static com.android.SdkConstants.ANDROID_URI;
import static com.android.SdkConstants.ATTR_ID;
import static com.android.tools.lint.detector.api.Lint.stripIdPrefix;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.resources.ResourceFolderType;
import com.android.resources.ResourceType;
import com.android.tools.lint.client.api.LintDriver;
import com.android.tools.lint.detector.api.Category;
import com.android.tools.lint.detector.api.Context;
import com.android.tools.lint.detector.api.Implementation;
import com.android.tools.lint.detector.api.Issue;
import com.android.tools.lint.detector.api.JavaContext;
import com.android.tools.lint.detector.api.LayoutDetector;
import com.android.tools.lint.detector.api.Lint;
import com.android.tools.lint.detector.api.Location;
import com.android.tools.lint.detector.api.Scope;
import com.android.tools.lint.detector.api.Severity;
import com.android.tools.lint.detector.api.SourceCodeScanner;
import com.android.tools.lint.detector.api.XmlContext;
import com.android.utils.Pair;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import java.io.File;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.jetbrains.uast.UElement;
import org.w3c.dom.Attr;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
/** Checks for consistency in layouts across different resource folders */
public class LayoutConsistencyDetector extends LayoutDetector implements SourceCodeScanner {
/**
* Map from layout resource names to a list of files defining that resource, and within each
* file the value is a map from string ids to the widget type used by that id in this file
*/
private final Map<String, List<Pair<File, Map<String, String>>>> mMap =
Maps.newHashMapWithExpectedSize(64);
/**
* Ids referenced from .java files. Only ids referenced from code are considered vital to be
* consistent among the layout variations (others could just have ids assigned to them in the
* layout either automatically by the layout editor or there in order to support RelativeLayout
* constraints etc, but not be problematic in findViewById calls.)
*/
private final Set<String> mRelevantIds = Sets.newLinkedHashSetWithExpectedSize(64);
/** Map from layout to id name to a list of locations */
private Map<String, Map<String, List<Location>>> mLocations;
/** Map from layout to id name to the error message to display for each */
private Map<String, Map<String, String>> mErrorMessages;
/** Inconsistent widget types */
public static final Issue INCONSISTENT_IDS =
Issue.create(
"InconsistentLayout",
"Inconsistent Layouts",
"This check ensures that a layout resource which is defined in multiple "
+ "resource folders, specifies the same set of widgets.\n"
+ "\n"
+ "This finds cases where you have accidentally forgotten to add "
+ "a widget to all variations of the layout, which could result "
+ "in a runtime crash for some resource configurations when a "
+ "`findViewById()` fails.\n"
+ "\n"
+ "There **are** cases where this is intentional. For example, you "
+ "may have a dedicated large tablet layout which adds some extra "
+ "widgets that are not present in the phone version of the layout. "
+ "As long as the code accessing the layout resource is careful to "
+ "handle this properly, it is valid. In that case, you can suppress "
+ "this lint check for the given extra or missing views, or the whole "
+ "layout",
Category.CORRECTNESS,
6,
Severity.WARNING,
new Implementation(
LayoutConsistencyDetector.class, Scope.JAVA_AND_RESOURCE_FILES));
/** Constructs a consistency check */
public LayoutConsistencyDetector() {}
@Override
public boolean appliesTo(@NonNull ResourceFolderType folderType) {
return folderType == ResourceFolderType.LAYOUT;
}
@Override
public void visitDocument(@NonNull XmlContext context, @NonNull Document document) {
if (!context.getProject().getReportIssues()) {
return;
}
Element root = document.getDocumentElement();
if (root != null) {
if (context.getPhase() == 1) {
// Map from ids to types
Map<String, String> fileMap = Maps.newHashMapWithExpectedSize(10);
addIds(root, fileMap);
getFileMapList(context).add(Pair.of(context.file, fileMap));
} else {
String name = Lint.getLayoutName(context.file);
Map<String, List<Location>> map = mLocations.get(name);
if (map != null) {
lookupLocations(context, root, map);
}
}
}
}
@NonNull
private List<Pair<File, Map<String, String>>> getFileMapList(@NonNull XmlContext context) {
String name = Lint.getLayoutName(context.file);
List<Pair<File, Map<String, String>>> list = mMap.get(name);
if (list == null) {
list = Lists.newArrayListWithCapacity(4);
mMap.put(name, list);
}
return list;
}
@Nullable
private static String getId(@NonNull Element element) {
String id = element.getAttributeNS(ANDROID_URI, ATTR_ID);
if (id != null && !id.isEmpty() && !id.startsWith(ANDROID_PREFIX)) {
return stripIdPrefix(id);
}
return null;
}
private static void addIds(Element element, Map<String, String> map) {
String id = getId(element);
if (id != null) {
String s = stripIdPrefix(id);
map.put(s, element.getTagName());
}
NodeList childNodes = element.getChildNodes();
for (int i = 0; i < childNodes.getLength(); i++) {
Node child = childNodes.item(i);
if (child.getNodeType() == Node.ELEMENT_NODE) {
addIds((Element) child, map);
}
}
}
private static void lookupLocations(
@NonNull XmlContext context,
@NonNull Element element,
@NonNull Map<String, List<Location>> map) {
String id = getId(element);
if (id != null) {
if (map.containsKey(id)) {
if (context.getDriver().isSuppressed(context, INCONSISTENT_IDS, element)) {
map.remove(id);
return;
}
List<Location> locations = map.get(id);
if (locations == null) {
locations = Lists.newArrayList();
map.put(id, locations);
}
Attr attr = element.getAttributeNodeNS(ANDROID_URI, ATTR_ID);
assert attr != null;
Location location = context.getLocation(attr);
String folder = context.file.getParentFile().getName();
location.setMessage(String.format("Occurrence in %1$s", folder));
locations.add(location);
}
}
NodeList childNodes = element.getChildNodes();
for (int i = 0; i < childNodes.getLength(); i++) {
Node child = childNodes.item(i);
if (child.getNodeType() == Node.ELEMENT_NODE) {
lookupLocations(context, (Element) child, map);
}
}
}
@Override
public void afterCheckRootProject(@NonNull Context context) {
LintDriver driver = context.getDriver();
if (driver.getPhase() == 1) {
// First phase: gather all the ids and look for consistency issues.
// If any are found, request location computation in phase 2 by
// writing the ids needed for each layout in the {@link #mLocations} map.
for (Map.Entry<String, List<Pair<File, Map<String, String>>>> entry : mMap.entrySet()) {
String layout = entry.getKey();
List<Pair<File, Map<String, String>>> files = entry.getValue();
if (files.size() < 2) {
// No consistency problems for files that don't have resource variations
continue;
}
checkConsistentIds(layout, files);
}
if (mLocations != null) {
driver.requestRepeat(this, Scope.ALL_RESOURCES_SCOPE);
}
} else {
// Collect results and print
if (!mLocations.isEmpty()) {
reportErrors(context);
}
}
}
@NonNull
private Set<String> stripIrrelevantIds(@NonNull Set<String> ids) {
if (!mRelevantIds.isEmpty()) {
Set<String> stripped = new HashSet<>(ids);
stripped.retainAll(mRelevantIds);
return stripped;
}
return Collections.emptySet();
}
private void checkConsistentIds(
@NonNull String layout, @NonNull List<Pair<File, Map<String, String>>> files) {
int layoutCount = files.size();
assert layoutCount >= 2;
Map<File, Set<String>> idMap = getIdMap(files, layoutCount);
Set<String> inconsistent = getInconsistentIds(idMap);
if (inconsistent.isEmpty()) {
return;
}
if (mLocations == null) {
mLocations = Maps.newHashMap();
}
if (mErrorMessages == null) {
mErrorMessages = Maps.newHashMap();
}
// Map from each id, to a list of layout folders it is present in
int idCount = inconsistent.size();
Map<String, List<String>> presence = Maps.newHashMapWithExpectedSize(idCount);
Set<String> allLayouts = Sets.newHashSetWithExpectedSize(layoutCount);
for (Map.Entry<File, Set<String>> entry : idMap.entrySet()) {
File file = entry.getKey();
String folder = file.getParentFile().getName();
allLayouts.add(folder);
Set<String> ids = entry.getValue();
for (String id : ids) {
List<String> list = presence.get(id);
if (list == null) {
list = Lists.newArrayListWithExpectedSize(layoutCount);
presence.put(id, list);
}
list.add(folder);
}
}
// Compute lookup maps which will be used in phase 2 to initialize actual
// locations for the id references
Map<String, List<Location>> map = Maps.newHashMapWithExpectedSize(idCount);
mLocations.put(layout, map);
Map<String, String> messages = Maps.newHashMapWithExpectedSize(idCount);
mErrorMessages.put(layout, messages);
for (String id : inconsistent) {
map.put(id, null); // The locations will be filled in during the second phase
// Determine presence description for this id
String message;
List<String> layouts = presence.get(id);
Collections.sort(layouts);
Set<String> missingSet = new HashSet<>(allLayouts);
missingSet.removeAll(layouts);
List<String> missing = new ArrayList<>(missingSet);
Collections.sort(missing);
if (layouts.size() < layoutCount / 2) {
message =
String.format(
"The id \"%1$s\" in layout \"%2$s\" is only present in the following "
+ "layout configurations: %3$s (missing from %4$s)",
id,
layout,
Lint.formatList(layouts, Integer.MAX_VALUE),
Lint.formatList(missing, Integer.MAX_VALUE));
} else {
message =
String.format(
"The id \"%1$s\" in layout \"%2$s\" is missing from the following layout "
+ "configurations: %3$s (present in %4$s)",
id,
layout,
Lint.formatList(missing, Integer.MAX_VALUE),
Lint.formatList(layouts, Integer.MAX_VALUE));
}
messages.put(id, message);
}
}
private static Set<String> getInconsistentIds(Map<File, Set<String>> idMap) {
Set<String> union = getAllIds(idMap);
Set<String> inconsistent = new HashSet<>();
for (Map.Entry<File, Set<String>> entry : idMap.entrySet()) {
Set<String> ids = entry.getValue();
if (ids.size() < union.size()) {
Set<String> missing = new HashSet<>(union);
missing.removeAll(ids);
inconsistent.addAll(missing);
}
}
return inconsistent;
}
private static Set<String> getAllIds(Map<File, Set<String>> idMap) {
Iterator<Set<String>> iterator = idMap.values().iterator();
assert iterator.hasNext();
Set<String> union = new HashSet<>(iterator.next());
while (iterator.hasNext()) {
union.addAll(iterator.next());
}
return union;
}
private Map<File, Set<String>> getIdMap(
List<Pair<File, Map<String, String>>> files, int layoutCount) {
Map<File, Set<String>> idMap = new HashMap<>(layoutCount);
for (Pair<File, Map<String, String>> pair : files) {
File file = pair.getFirst();
Map<String, String> typeMap = pair.getSecond();
Set<String> ids = typeMap.keySet();
idMap.put(file, stripIrrelevantIds(ids));
}
return idMap;
}
private void reportErrors(Context context) {
List<String> layouts = new ArrayList<>(mLocations.keySet());
Collections.sort(layouts);
for (String layout : layouts) {
Map<String, List<Location>> locationMap = mLocations.get(layout);
Map<String, String> messageMap = mErrorMessages.get(layout);
assert locationMap != null;
assert messageMap != null;
List<String> ids = new ArrayList<>(locationMap.keySet());
Collections.sort(ids);
for (String id : ids) {
String message = messageMap.get(id);
List<Location> locations = locationMap.get(id);
if (locations != null) {
Location location = chainLocations(locations);
context.report(INCONSISTENT_IDS, location, message);
}
}
}
}
@NonNull
private static Location chainLocations(@NonNull List<Location> locations) {
assert !locations.isEmpty();
// Sort locations by the file parent folders
if (locations.size() > 1) {
locations.sort(
(location1, location2) -> {
File file1 = location1.getFile();
File file2 = location2.getFile();
String folder1 = file1.getParentFile().getName();
String folder2 = file2.getParentFile().getName();
return folder1.compareTo(folder2);
});
// Chain locations together
Iterator<Location> iterator = locations.iterator();
assert iterator.hasNext();
Location prev = iterator.next();
while (iterator.hasNext()) {
Location next = iterator.next();
prev.setSecondary(next);
prev = next;
}
}
return locations.get(0);
}
// ---- implements SourceCodeScanner ----
@Override
public boolean appliesToResourceRefs() {
return true;
}
@Override
public void visitResourceReference(
@NonNull JavaContext context,
@NonNull UElement node,
@NonNull ResourceType type,
@NonNull String name,
boolean isFramework) {
if (!isFramework && type == ResourceType.ID) {
mRelevantIds.add(name);
}
}
}