/* * Copyright (C) 2012 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.ATTR_ON_CLICK; import static com.android.SdkConstants.PREFIX_RESOURCE_REF; import com.android.annotations.NonNull; import com.android.tools.lint.client.api.LintDriver; import com.android.tools.lint.detector.api.Category; import com.android.tools.lint.detector.api.ClassContext; import com.android.tools.lint.detector.api.Context; import com.android.tools.lint.detector.api.Detector.ClassScanner; import com.android.tools.lint.detector.api.Implementation; import com.android.tools.lint.detector.api.Issue; import com.android.tools.lint.detector.api.LayoutDetector; import com.android.tools.lint.detector.api.LintUtils; import com.android.tools.lint.detector.api.Location; import com.android.tools.lint.detector.api.Location.Handle; import com.android.tools.lint.detector.api.Scope; import com.android.tools.lint.detector.api.Severity; import com.android.tools.lint.detector.api.Speed; import com.android.tools.lint.detector.api.XmlContext; import com.google.common.base.Joiner; import org.objectweb.asm.Opcodes; import org.objectweb.asm.tree.ClassNode; import org.objectweb.asm.tree.MethodNode; import org.w3c.dom.Attr; import org.w3c.dom.Node; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * Checks for missing onClick handlers */ public class OnClickDetector extends LayoutDetector implements ClassScanner { /** Missing onClick handlers */ public static final Issue ISSUE = Issue.create( "OnClick", //$NON-NLS-1$ "`onClick` method does not exist", "The `onClick` attribute value should be the name of a method in this View's context " + "to invoke when the view is clicked. This name must correspond to a public method " + "that takes exactly one parameter of type `View`.\n" + "\n" + "Must be a string value, using '\\;' to escape characters such as '\\n' or " + "'\\uxxxx' for a unicode character.", Category.CORRECTNESS, 10, Severity.ERROR, new Implementation( OnClickDetector.class, Scope.CLASS_AND_ALL_RESOURCE_FILES)); private Map<String, Location.Handle> mNames; private Map<String, List<String>> mSimilar; private boolean mHaveBytecode; /** Constructs a new {@link OnClickDetector} */ public OnClickDetector() { } @NonNull @Override public Speed getSpeed() { return Speed.FAST; } @Override public void afterCheckProject(@NonNull Context context) { if (mNames != null && !mNames.isEmpty() && mHaveBytecode) { List<String> names = new ArrayList<String>(mNames.keySet()); Collections.sort(names); LintDriver driver = context.getDriver(); for (String name : names) { Handle handle = mNames.get(name); Object clientData = handle.getClientData(); if (clientData instanceof Node) { if (driver.isSuppressed(null, ISSUE, (Node) clientData)) { continue; } } Location location = handle.resolve(); String message = String.format( "Corresponding method handler '`public void %1$s(android.view.View)`' not found", name); List<String> similar = mSimilar != null ? mSimilar.get(name) : null; if (similar != null) { Collections.sort(similar); message += String.format(" (did you mean `%1$s` ?)", Joiner.on(", ").join(similar)); } context.report(ISSUE, location, message); } } } // ---- Implements XmlScanner ---- @Override public Collection<String> getApplicableAttributes() { return Collections.singletonList(ATTR_ON_CLICK); } @Override public void visitAttribute(@NonNull XmlContext context, @NonNull Attr attribute) { String value = attribute.getValue(); if (value.isEmpty() || value.trim().isEmpty()) { context.report(ISSUE, attribute, context.getLocation(attribute), "`onClick` attribute value cannot be empty"); } else if (!value.equals(value.trim())) { context.report(ISSUE, attribute, context.getLocation(attribute), "There should be no whitespace around attribute values"); } else if (!value.startsWith(PREFIX_RESOURCE_REF)) { // Not resolved if (!context.getProject().getReportIssues()) { // If this is a library project not being analyzed, ignore it return; } if (mNames == null) { mNames = new HashMap<String, Location.Handle>(); } Handle handle = context.createLocationHandle(attribute); handle.setClientData(attribute); // Replace unicode characters with the actual value since that's how they // appear in the ASM signatures if (value.contains("\\u")) { //$NON-NLS-1$ Pattern pattern = Pattern.compile("\\\\u(\\d\\d\\d\\d)"); //$NON-NLS-1$ Matcher matcher = pattern.matcher(value); StringBuilder sb = new StringBuilder(value.length()); int remainder = 0; while (matcher.find()) { sb.append(value.substring(0, matcher.start())); String unicode = matcher.group(1); int hex = Integer.parseInt(unicode, 16); sb.append((char) hex); remainder = matcher.end(); } sb.append(value.substring(remainder)); value = sb.toString(); } mNames.put(value, handle); } } // ---- Implements ClassScanner ---- @SuppressWarnings("rawtypes") @Override public void checkClass(@NonNull ClassContext context, @NonNull ClassNode classNode) { if (mNames == null) { // No onClick attributes in the XML files return; } mHaveBytecode = true; List methodList = classNode.methods; for (Object m : methodList) { MethodNode method = (MethodNode) m; boolean rightArguments = method.desc.equals("(Landroid/view/View;)V"); //$NON-NLS-1$ if (!mNames.containsKey(method.name)) { if (rightArguments) { // See if there's a possible typo instead for (String n : mNames.keySet()) { if (LintUtils.editDistance(n, method.name) <= 2) { recordSimilar(n, classNode, method); break; } } } continue; } // TODO: Validate class hierarchy: should extend a context method // Longer term, also validate that it's in a layout that corresponds to // the given activity if (rightArguments){ // Found: remove from list to be checked mNames.remove(method.name); // Make sure the method is public if ((method.access & Opcodes.ACC_PUBLIC) == 0) { Location location = context.getLocation(method, classNode); String message = String.format( "On click handler `%1$s(View)` must be public", method.name); context.report(ISSUE, location, message); } else if ((method.access & Opcodes.ACC_STATIC) != 0) { Location location = context.getLocation(method, classNode); String message = String.format( "On click handler `%1$s(View)` should not be static", method.name); context.report(ISSUE, location, message); } if (mNames.isEmpty()) { mNames = null; return; } } } } private void recordSimilar(String name, ClassNode classNode, MethodNode method) { if (mSimilar == null) { mSimilar = new HashMap<String, List<String>>(); } List<String> list = mSimilar.get(name); if (list == null) { list = new ArrayList<String>(); mSimilar.put(name, list); } String signature = ClassContext.createSignature(classNode.name, method.name, method.desc); list.add(signature); } }