| /* |
| * Copyright (C) 2014 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_VIEW_VIEW; |
| |
| import com.android.annotations.NonNull; |
| import com.android.annotations.Nullable; |
| import com.android.tools.lint.detector.api.Category; |
| import com.android.tools.lint.detector.api.ClassContext; |
| import com.android.tools.lint.detector.api.Detector; |
| import com.android.tools.lint.detector.api.Implementation; |
| import com.android.tools.lint.detector.api.Issue; |
| 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 org.objectweb.asm.Opcodes; |
| import org.objectweb.asm.tree.AbstractInsnNode; |
| import org.objectweb.asm.tree.ClassNode; |
| import org.objectweb.asm.tree.InsnList; |
| import org.objectweb.asm.tree.MethodInsnNode; |
| import org.objectweb.asm.tree.MethodNode; |
| |
| import java.util.List; |
| import java.util.ListIterator; |
| |
| /** |
| * Checks that views that override View#onTouchEvent also implement View#performClick |
| * and call performClick when click detection occurs. |
| */ |
| public class ClickableViewAccessibilityDetector extends Detector implements Detector.ClassScanner { |
| |
| public static final Issue ISSUE = Issue.create( |
| "ClickableViewAccessibility", //$NON-NLS-1$ |
| "Accessibility in Custom Views", |
| "If a `View` that overrides `onTouchEvent` or uses an `OnTouchListener` does not also " |
| + "implement `performClick` and call it when clicks are detected, the `View` " |
| + "may not handle accessibility actions properly. Logic handling the click " |
| + "actions should ideally be placed in `View#performClick` as some " |
| + "accessibility services invoke `performClick` when a click action " |
| + "should occur.", |
| Category.A11Y, |
| 6, |
| Severity.WARNING, |
| new Implementation( |
| ClickableViewAccessibilityDetector.class, |
| Scope.CLASS_FILE_SCOPE)); |
| |
| private static final String ON_TOUCH_EVENT = "onTouchEvent"; //$NON-NLS-1$ |
| private static final String ON_TOUCH_EVENT_SIG = "(Landroid/view/MotionEvent;)Z"; //$NON-NLS-1$ |
| private static final String PERFORM_CLICK = "performClick"; //$NON-NLS-1$ |
| private static final String PERFORM_CLICK_SIG = "()Z"; //$NON-NLS-1$ |
| private static final String SET_ON_TOUCH_LISTENER = "setOnTouchListener"; //$NON-NLS-1$ |
| private static final String SET_ON_TOUCH_LISTENER_SIG = "(Landroid/view/View$OnTouchListener;)V"; //$NON-NLS-1$ |
| private static final String ON_TOUCH = "onTouch"; //$NON-NLS-1$ |
| private static final String ON_TOUCH_SIG = "(Landroid/view/View;Landroid/view/MotionEvent;)Z"; //$NON-NLS-1$ |
| private static final String ON_TOUCH_LISTENER = "android/view/View$OnTouchListener"; //$NON-NLS-1$ |
| |
| |
| /** Constructs a new {@link ClickableViewAccessibilityDetector} */ |
| public ClickableViewAccessibilityDetector() { |
| } |
| |
| @NonNull |
| @Override |
| public Speed getSpeed() { |
| return Speed.FAST; |
| } |
| |
| // ---- Implements ClassScanner ---- |
| @Override |
| public void checkClass(@NonNull ClassContext context, @NonNull ClassNode classNode) { |
| scanForAndCheckSetOnTouchListenerCalls(context, classNode); |
| |
| // Ignore abstract classes. |
| if ((classNode.access & Opcodes.ACC_ABSTRACT) != 0) { |
| return; |
| } |
| |
| if (context.getDriver().isSubclassOf(classNode, ANDROID_VIEW_VIEW)) { |
| checkView(context, classNode); |
| } |
| |
| if (implementsOnTouchListener(classNode)) { |
| checkOnTouchListener(context, classNode); |
| } |
| } |
| |
| @SuppressWarnings("unchecked") // ASM API |
| public static void scanForAndCheckSetOnTouchListenerCalls( |
| ClassContext context, |
| ClassNode classNode) { |
| List<MethodNode> methods = classNode.methods; |
| for (MethodNode methodNode : methods) { |
| ListIterator<AbstractInsnNode> iterator = methodNode.instructions.iterator(); |
| while (iterator.hasNext()) { |
| AbstractInsnNode abstractInsnNode = iterator.next(); |
| if (abstractInsnNode.getType() == AbstractInsnNode.METHOD_INSN) { |
| MethodInsnNode methodInsnNode = (MethodInsnNode) abstractInsnNode; |
| if (methodInsnNode.name.equals(SET_ON_TOUCH_LISTENER) |
| && methodInsnNode.desc.equals(SET_ON_TOUCH_LISTENER_SIG)) { |
| checkSetOnTouchListenerCall(context, methodNode, methodInsnNode); |
| } |
| } |
| } |
| } |
| } |
| |
| @SuppressWarnings("unchecked") // ASM API |
| public static void checkSetOnTouchListenerCall( |
| @NonNull ClassContext context, |
| @NonNull MethodNode method, |
| @NonNull MethodInsnNode call) { |
| String owner = call.owner; |
| |
| // Ignore the call if it was called on a non-view. |
| ClassNode ownerClass = context.getDriver().findClass(context, owner, 0); |
| if(ownerClass == null |
| || !context.getDriver().isSubclassOf(ownerClass, ANDROID_VIEW_VIEW)) { |
| return; |
| } |
| |
| MethodNode performClick = findMethod(ownerClass.methods, PERFORM_CLICK, PERFORM_CLICK_SIG); |
| //noinspection VariableNotUsedInsideIf |
| if (performClick == null) { |
| String message = String.format( |
| "Custom view `%1$s` has `setOnTouchListener` called on it but does not " |
| + "override `performClick`", ownerClass.name); |
| context.report(ISSUE, method, call, context.getLocation(call), message); |
| } |
| } |
| |
| @SuppressWarnings("unchecked") // ASM API |
| private static void checkOnTouchListener(ClassContext context, ClassNode classNode) { |
| MethodNode onTouchNode = |
| findMethod( |
| classNode.methods, |
| ON_TOUCH, |
| ON_TOUCH_SIG); |
| if (onTouchNode != null) { |
| AbstractInsnNode performClickInsnNode = findMethodCallInstruction( |
| onTouchNode.instructions, |
| ANDROID_VIEW_VIEW, |
| PERFORM_CLICK, |
| PERFORM_CLICK_SIG); |
| if (performClickInsnNode == null) { |
| String message = String.format( |
| "`%1$s#onTouch` should call `View#performClick` when a click is detected", |
| classNode.name); |
| context.report( |
| ISSUE, |
| onTouchNode, |
| null, |
| context.getLocation(onTouchNode, classNode), |
| message); |
| } |
| } |
| } |
| |
| @SuppressWarnings("unchecked") // ASM API |
| private static void checkView(ClassContext context, ClassNode classNode) { |
| MethodNode onTouchEvent = findMethod(classNode.methods, ON_TOUCH_EVENT, ON_TOUCH_EVENT_SIG); |
| MethodNode performClick = findMethod(classNode.methods, PERFORM_CLICK, PERFORM_CLICK_SIG); |
| |
| // Check if we override onTouchEvent. |
| if (onTouchEvent != null) { |
| // Ensure that we also override performClick. |
| //noinspection VariableNotUsedInsideIf |
| if (performClick == null) { |
| String message = String.format( |
| "Custom view `%1$s` overrides `onTouchEvent` but not `performClick`", |
| classNode.name); |
| context.report(ISSUE, onTouchEvent, null, |
| context.getLocation(onTouchEvent, classNode), message); |
| } else { |
| // If we override performClick, ensure that it is called inside onTouchEvent. |
| AbstractInsnNode performClickInOnTouchEventInsnNode = findMethodCallInstruction( |
| onTouchEvent.instructions, |
| classNode.name, |
| PERFORM_CLICK, |
| PERFORM_CLICK_SIG); |
| if (performClickInOnTouchEventInsnNode == null) { |
| String message = String.format( |
| "`%1$s#onTouchEvent` should call `%1$s#performClick` when a click is detected", |
| classNode.name); |
| context.report(ISSUE, onTouchEvent, null, |
| context.getLocation(onTouchEvent, classNode), message); |
| } |
| } |
| } |
| |
| // Ensure that, if performClick is implemented, performClick calls super.performClick. |
| if (performClick != null) { |
| AbstractInsnNode superPerformClickInPerformClickInsnNode = findMethodCallInstruction( |
| performClick.instructions, |
| classNode.superName, |
| PERFORM_CLICK, |
| PERFORM_CLICK_SIG); |
| if (superPerformClickInPerformClickInsnNode == null) { |
| String message = String.format( |
| "`%1$s#performClick` should call `super#performClick`", |
| classNode.name); |
| context.report(ISSUE, performClick, null, |
| context.getLocation(performClick, classNode), message); |
| } |
| } |
| } |
| |
| @Nullable |
| private static MethodNode findMethod( |
| @NonNull List<MethodNode> methods, |
| @NonNull String name, |
| @NonNull String desc) { |
| for (MethodNode method : methods) { |
| if (name.equals(method.name) |
| && desc.equals(method.desc)) { |
| return method; |
| } |
| } |
| return null; |
| } |
| |
| @SuppressWarnings("unchecked") // ASM API |
| @Nullable |
| private static AbstractInsnNode findMethodCallInstruction( |
| @NonNull InsnList instructions, |
| @NonNull String owner, |
| @NonNull String name, |
| @NonNull String desc) { |
| ListIterator<AbstractInsnNode> iterator = instructions.iterator(); |
| |
| while (iterator.hasNext()) { |
| AbstractInsnNode insnNode = iterator.next(); |
| if (insnNode.getType() == AbstractInsnNode.METHOD_INSN) { |
| MethodInsnNode methodInsnNode = (MethodInsnNode) insnNode; |
| if ((methodInsnNode.owner.equals(owner)) |
| && (methodInsnNode.name.equals(name)) |
| && (methodInsnNode.desc.equals(desc))) { |
| return methodInsnNode; |
| } |
| } |
| } |
| |
| return null; |
| } |
| |
| private static boolean implementsOnTouchListener(ClassNode classNode) { |
| return (classNode.interfaces != null) && (classNode.interfaces.contains(ON_TOUCH_LISTENER)); |
| } |
| } |