| /* |
| * Copyright (C) 2011 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 com.android.SdkConstants.ANDROID_URI |
| import com.android.SdkConstants.ANDROID_VIEW_PKG |
| import com.android.SdkConstants.ANDROID_WEBKIT_PKG |
| import com.android.SdkConstants.ANDROID_WIDGET_PREFIX |
| import com.android.SdkConstants.ATTR_CLASS |
| import com.android.SdkConstants.ATTR_ID |
| import com.android.SdkConstants.ATTR_TAG |
| import com.android.SdkConstants.CLASS_VIEW |
| import com.android.SdkConstants.DOT_XML |
| import com.android.SdkConstants.ID_PREFIX |
| import com.android.SdkConstants.NEW_ID_PREFIX |
| import com.android.SdkConstants.VIEW_FRAGMENT |
| import com.android.SdkConstants.VIEW_INCLUDE |
| import com.android.SdkConstants.VIEW_MERGE |
| import com.android.SdkConstants.VIEW_TAG |
| import com.android.ide.common.rendering.api.ResourceNamespace |
| import com.android.ide.common.resources.ResourceItem |
| import com.android.ide.common.util.PathString |
| import com.android.resources.ResourceFolderType |
| import com.android.resources.ResourceType |
| import com.android.tools.lint.detector.api.Category |
| import com.android.tools.lint.detector.api.ConstantEvaluator |
| 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.LintFix |
| import com.android.tools.lint.detector.api.Location |
| import com.android.tools.lint.detector.api.ResourceEvaluator |
| import com.android.tools.lint.detector.api.ResourceXmlDetector |
| 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.tools.lint.detector.api.getLanguageLevel |
| import com.android.tools.lint.detector.api.skipParentheses |
| import com.android.tools.lint.detector.api.stripIdPrefix |
| import com.google.common.base.Joiner |
| import com.google.common.collect.ArrayListMultimap |
| import com.google.common.collect.Multimap |
| import com.intellij.openapi.util.text.StringUtil |
| import com.intellij.pom.java.LanguageLevel.JDK_1_7 |
| import com.intellij.pom.java.LanguageLevel.JDK_1_8 |
| import com.intellij.psi.PsiClass |
| import com.intellij.psi.PsiClassType |
| import com.intellij.psi.PsiMethod |
| import com.intellij.psi.PsiTypeParameter |
| import com.intellij.psi.util.TypeConversionUtil |
| import org.jetbrains.uast.UBinaryExpressionWithType |
| import org.jetbrains.uast.UCallExpression |
| import org.jetbrains.uast.UElement |
| import org.jetbrains.uast.UExpression |
| import org.jetbrains.uast.UQualifiedReferenceExpression |
| import org.jetbrains.uast.UVariable |
| import org.w3c.dom.Attr |
| import org.xmlpull.v1.XmlPullParser |
| import org.xmlpull.v1.XmlPullParserException |
| import java.io.IOException |
| import java.util.ArrayList |
| import java.util.Arrays |
| import java.util.EnumSet |
| import java.util.HashMap |
| |
| /** |
| * Detector for finding inconsistent usage of views and casts |
| */ |
| open class ViewTypeDetector : ResourceXmlDetector(), SourceCodeScanner { |
| |
| private val idToViewTag = HashMap<String, Any>(50) |
| |
| private var fileIdMap: MutableMap<PathString, Multimap<String, String>>? = null |
| |
| override fun appliesTo(folderType: ResourceFolderType): Boolean { |
| return folderType == ResourceFolderType.LAYOUT |
| } |
| |
| override fun getApplicableAttributes(): Collection<String>? { |
| return Arrays.asList(ATTR_ID, ATTR_TAG) |
| } |
| |
| override fun visitAttribute(context: XmlContext, attribute: Attr) { |
| val value = attribute.value |
| val id = when { |
| value.startsWith(ID_PREFIX) -> value.substring(ID_PREFIX.length) |
| value.startsWith(NEW_ID_PREFIX) -> value.substring(NEW_ID_PREFIX.length) |
| // keep tags in the same map for simplicity but add prefix such that we don't |
| // accidentally mix tags and id names together |
| ATTR_TAG == attribute.localName -> TAG_NAME_PREFIX + value |
| else -> return // usually some @android:id where we don't enforce casts |
| } |
| |
| val view = run { |
| var cls = attribute.ownerElement.tagName |
| if (cls == VIEW_TAG) { |
| cls = attribute.ownerElement.getAttribute(ATTR_CLASS) |
| } else if (cls == VIEW_FRAGMENT) { |
| if (ATTR_TAG != attribute.localName) { |
| // For <fragment> tags we only want to store tag associations; |
| // it's quite common to programmatically add/remove fragments |
| // using id's, as well as also have that id on a container layout |
| // view as a default, and in that case the get call might look |
| // like an invalid cast. |
| return |
| } |
| |
| cls = attribute.ownerElement.getAttribute(ATTR_CLASS) |
| } |
| cls = cls.replace('$', '.') |
| if (cls.isEmpty()) { |
| return |
| } |
| cls |
| } |
| |
| val existing = idToViewTag[id] |
| if (existing == null) { |
| idToViewTag[id] = view |
| } else if (existing is String) { |
| val existingString = existing as String? |
| if (existingString != view) { |
| // Convert to list |
| val list = ArrayList<String>(2) |
| list.add(existing) |
| list.add(view) |
| idToViewTag[id] = list |
| } |
| } else if (existing is MutableList<*>) { |
| @Suppress("UNCHECKED_CAST") |
| val list = existing as MutableList<String> |
| if (!list.contains(view)) { |
| list.add(view) |
| } |
| } |
| } |
| |
| // ---- Implements SourceCodeScanner ---- |
| |
| override fun getApplicableMethodNames(): List<String>? { |
| return Arrays.asList( |
| FIND_VIEW_BY_ID, |
| REQUIRE_VIEW_BY_ID, |
| FIND_FRAGMENT_BY_TAG |
| // "findFragmentById": Disabled for now. This leads to a lot |
| // of false positives. See the support library demos for example. |
| // What happens is that one layout tag, such as a <FrameLayout> |
| // may specify an id, such as R.id.details. |
| // Then, elsewhere, there is fragment code to lazily add and replace |
| // fragments, and these use the id to look it up (and fragments |
| // can use the id of a layout container as well as the id of a |
| // previously registered fragment, which is why we get these mismatches.) |
| ) |
| } |
| |
| override fun visitMethodCall( |
| context: JavaContext, |
| node: UCallExpression, |
| method: PsiMethod |
| ) { |
| val client = context.client |
| val current = skipParentheses(node) ?: return |
| var parent = current.uastParent |
| |
| val errorNode: UElement |
| val castType: PsiClassType |
| |
| when (parent) { |
| is UBinaryExpressionWithType -> { |
| val cast = parent |
| val type = cast.type as? PsiClassType ?: return |
| castType = type |
| errorNode = cast |
| } |
| is UExpression -> { |
| if (parent is UCallExpression) { |
| val c = parent |
| checkMissingCast(context, node, c) |
| return |
| } |
| |
| if (parent is UQualifiedReferenceExpression) { |
| val ref = parent |
| if (ref.selector !== current) { |
| return |
| } |
| |
| parent = parent.uastParent |
| if (parent !is UBinaryExpressionWithType) { |
| return |
| } |
| } |
| |
| // Implicit cast? |
| val variable = parent as? UExpression ?: return |
| val type = variable.getExpressionType() as? PsiClassType ?: return |
| castType = type |
| errorNode = parent |
| } |
| is UVariable -> { |
| // Implicit cast? |
| val variable = parent |
| val type = variable.type as? PsiClassType ?: return |
| castType = type |
| errorNode = parent |
| } |
| else -> return |
| } |
| |
| val castTypeClass = castType.canonicalText |
| if (castTypeClass == CLASS_VIEW || |
| castTypeClass == "kotlin.Unit" || |
| castTypeClass == "android.app.Fragment" || |
| castTypeClass == "android.support.v4.app.Fragment" || |
| castTypeClass == "androidx.fragment.app.Fragment" |
| ) { |
| return |
| } |
| |
| val methodName = node.methodName |
| val findView = FIND_VIEW_BY_ID == methodName || REQUIRE_VIEW_BY_ID == methodName |
| val findTag = !findView && FIND_FRAGMENT_BY_TAG == methodName |
| |
| val args = node.valueArguments |
| if (args.size == 1) { |
| val first = args[0] |
| var tag: String? = null |
| var id: String? = null |
| if (findTag) { |
| tag = ConstantEvaluator.evaluateString(context, first, false) ?: return |
| tag = TAG_NAME_PREFIX + tag |
| } else { |
| val resourceUrl = ResourceEvaluator.getResource(context.evaluator, first) |
| if (resourceUrl != null && |
| resourceUrl.type == ResourceType.ID && |
| !resourceUrl.isFramework |
| ) { |
| id = resourceUrl.name |
| } |
| } |
| if (id != null || tag != null) { |
| // We can't search for tags in the resource repository incrementally |
| if (id != null && client.supportsProjectResources()) { |
| val resources = |
| client.getResourceRepository(context.mainProject, true, false) ?: return |
| |
| val items = |
| resources.getResources(ResourceNamespace.TODO(), ResourceType.ID, id) |
| if (!items.isEmpty()) { |
| val compatible = HashSet<String>() |
| for (item in items) { |
| val tags = getViewTags(context, item) |
| if (tags != null) { |
| compatible.addAll(tags) |
| } |
| } |
| if (!compatible.isEmpty()) { |
| val layoutTypes = ArrayList(compatible) |
| checkCompatible( |
| context, |
| castType, |
| castTypeClass, |
| null, |
| layoutTypes, |
| errorNode, |
| first, |
| items, |
| findView |
| ) |
| } |
| } |
| } else { |
| val types = idToViewTag[if (id != null) id else tag] |
| if (types is String) { |
| checkCompatible( |
| context, |
| castType, |
| castTypeClass, |
| types, |
| null, |
| errorNode, |
| first, |
| null, |
| findView |
| ) |
| } else if (types is List<*>) { |
| @Suppress("UNCHECKED_CAST") |
| val layoutTypes = types as List<String> |
| checkCompatible( |
| context, |
| castType, |
| castTypeClass, |
| null, |
| layoutTypes, |
| errorNode, |
| first, |
| null, |
| findView |
| ) |
| } |
| } |
| } |
| } |
| } |
| |
| private fun checkMissingCast( |
| context: JavaContext, |
| findViewByIdCall: UCallExpression, |
| surroundingCall: UCallExpression |
| ) { |
| // This issue only applies in Java, not Kotlin etc - and for language level 1.8 |
| val languageLevel = getLanguageLevel(surroundingCall, JDK_1_7) |
| if (languageLevel.isLessThan(JDK_1_8)) { |
| return |
| } |
| |
| surroundingCall.uastParent as? UQualifiedReferenceExpression ?: return |
| |
| val valueArguments = surroundingCall.valueArguments |
| var parameterIndex = -1 |
| var i = 0 |
| val n = valueArguments.size |
| while (i < n) { |
| if (findViewByIdCall == valueArguments[i]) { |
| parameterIndex = i |
| } |
| i++ |
| } |
| if (parameterIndex == -1) { |
| return |
| } |
| |
| val resolvedMethod = surroundingCall.resolve() ?: return |
| val parameters = resolvedMethod.parameterList.parameters |
| if (parameterIndex >= parameters.size) { |
| return |
| } |
| |
| val parameterType = parameters[parameterIndex].type as? PsiClassType ?: return |
| parameterType.resolve() as? PsiTypeParameter ?: return |
| val erasure = TypeConversionUtil.erasure(parameterType) |
| if (erasure == null || erasure.canonicalText == CLASS_VIEW) { |
| return |
| } |
| |
| val returnType = resolvedMethod.returnType as? PsiClassType ?: return |
| if (returnType.resolve() !is PsiTypeParameter) { |
| return |
| } |
| |
| val callName = findViewByIdCall.methodName ?: return |
| val fix = LintFix.create() |
| .replace() |
| .name("Add cast") |
| .text(callName) |
| .shortenNames() |
| .reformat(true) |
| .with("(android.view.View)$callName") |
| .build() |
| |
| context.report( |
| ADD_CAST, |
| context.getLocation(findViewByIdCall), |
| "Add explicit cast here; won't compile with Java language level 1.8 without it", |
| fix |
| ) |
| } |
| |
| protected open fun getViewTags(context: Context, item: ResourceItem): Collection<String>? { |
| // Check view tag in this file. |
| val source = item.source ?: return null |
| val map = getIdToTagsIn(context, source) ?: return null // This is cached |
| return map.get(item.name) |
| } |
| |
| private fun getIdToTagsIn(context: Context, file: PathString): Multimap<String, String>? { |
| if (!file.fileName.endsWith(DOT_XML)) { |
| return null |
| } |
| val fileIdMap = fileIdMap ?: run { |
| val list = HashMap<PathString, Multimap<String, String>>() |
| fileIdMap = list |
| list |
| } |
| var map: Multimap<String, String>? = fileIdMap[file] |
| if (map == null) { |
| map = ArrayListMultimap.create() |
| fileIdMap[file] = map |
| try { |
| val parser = context.client.createXmlPullParser(file) |
| if (parser != null) { |
| addTags(parser, map) |
| } |
| } catch (ignore: XmlPullParserException) { |
| // Users might be editing these files in the IDE; don't flag |
| } catch (ignore: IOException) { |
| // Users might be editing these files in the IDE; don't flag |
| } |
| } |
| return map |
| } |
| |
| private fun addTags(parser: XmlPullParser, map: Multimap<String, String>) { |
| while (true) { |
| val event = parser.next() |
| if (event == XmlPullParser.START_TAG) { |
| var id: String? = parser.getAttributeValue(ANDROID_URI, ATTR_ID) |
| if (id != null && !id.isEmpty()) { |
| id = stripIdPrefix(id) |
| var tag = parser.name ?: continue |
| if (tag == VIEW_TAG || tag == VIEW_FRAGMENT) { |
| tag = parser.getAttributeValue(null, ATTR_CLASS) ?: continue |
| if (tag.isEmpty()) { |
| continue |
| } |
| } else if (tag == VIEW_MERGE || tag == VIEW_INCLUDE) { |
| continue |
| } |
| tag = tag.replace('$', '.') |
| if (!map.containsEntry(id, tag)) { |
| map.put(id, tag) |
| } |
| } |
| } else if (event == XmlPullParser.END_DOCUMENT) { |
| return |
| } |
| } |
| } |
| |
| /** Check if the view and cast type are compatible */ |
| private fun checkCompatible( |
| context: JavaContext, |
| castType: PsiClassType, |
| castTypeClass: String, |
| tag: String?, |
| tags: List<String>?, |
| node: UElement, |
| resourceReference: UExpression, |
| items: List<ResourceItem>?, |
| findView: Boolean |
| ) { |
| assert(tag == null || tags == null) { tag!! + tags!! } // Should only specify one or the other |
| |
| // Common case: they match: quickly check for this and fail if not |
| if (castTypeClass == tag || tags != null && tags.contains(castTypeClass)) { |
| return |
| } |
| |
| val castClass = castType.resolve() |
| |
| var compatible = true |
| if (findView) { |
| if (tag != null) { |
| if (tag != castTypeClass && !context.sdkInfo.isSubViewOf(castTypeClass, tag)) { |
| compatible = false |
| } |
| } else if (tags != null) { // always true |
| compatible = false |
| for (type in tags) { |
| if (type == castTypeClass || context.sdkInfo.isSubViewOf(castTypeClass, type)) { |
| compatible = true |
| break |
| } |
| } |
| } |
| } else { |
| compatible = castClass == null // Otherwise we can't use class to check either |
| } |
| |
| // Use real classes to handle checks |
| if (castClass != null && !compatible) { |
| if (tag != null) { |
| if (isCompatible(context, castClass, tag)) { |
| return |
| } |
| } else if (tags != null) { // always true |
| for (t in tags) { |
| if (isCompatible(context, castClass, t)) { |
| return |
| } |
| } |
| } |
| } |
| |
| if (compatible) { |
| return |
| } |
| |
| // Not compatible: report error |
| |
| val displayTag = tag ?: Joiner.on("|").join(tags!!) |
| |
| var sampleLayout: String? = null |
| if (items != null && (tags == null || tags.size == 1)) { |
| for (item in items) { |
| val t = getViewTags(context, item) |
| if (t != null && t.contains(displayTag)) { |
| val source = item.source |
| if (source != null) { |
| val parentName = source.parentFileName |
| sampleLayout = if (item.configuration.isDefault || parentName == null) { |
| source.fileName |
| } else { |
| parentName + "/" + source.fileName |
| } |
| break |
| } |
| } |
| } |
| } |
| |
| val incompatibleTag = castTypeClass.substring(castTypeClass.lastIndexOf('.') + 1) |
| |
| val message: String |
| val location: Location |
| val type = if (findView) "layout" else "fragment" |
| val verb = if (findView) "was" else "referenced" |
| if (node !is UBinaryExpressionWithType) { |
| if (node is UVariable && node.typeReference != null) { |
| location = context.getLocation(node.typeReference!!) |
| location.secondary = |
| createSecondary(context, displayTag, resourceReference, sampleLayout) |
| } else { |
| location = context.getLocation(node) |
| } |
| message = |
| "Unexpected implicit cast to `$incompatibleTag`: $type tag $verb `$displayTag`" |
| } else { |
| location = context.getLocation(node) |
| if (sampleLayout != null) { |
| location.secondary = |
| createSecondary(context, displayTag, resourceReference, sampleLayout) |
| } |
| message = "Unexpected cast to `$incompatibleTag`: $type tag $verb `$displayTag`" |
| } |
| context.report(WRONG_VIEW_CAST, node, location, message) |
| } |
| |
| private fun createSecondary( |
| context: JavaContext, |
| tag: String, |
| resourceReference: UExpression, |
| sampleLayout: String? |
| ): Location { |
| val secondary = context.getLocation(resourceReference) |
| if (sampleLayout != null) { |
| val article = if (tag.indexOf('.') == -1 && |
| tag.indexOf('|') == -1 && |
| // We don't have widgets right now which start with a silent consonant |
| StringUtil.isVowel(Character.toLowerCase(tag[0])) |
| ) |
| "an" |
| else "a" |
| secondary.message = "Id bound to $article `$tag` in `$sampleLayout`" |
| } |
| return secondary |
| } |
| |
| private fun isCompatible( |
| context: JavaContext, |
| castClass: PsiClass, |
| tag: String |
| ): Boolean { |
| var cls: PsiClass? = null |
| if (tag.indexOf('.') == -1) { |
| for (prefix in arrayOf( |
| // See framework's PhoneLayoutInflater: these are the prefixes |
| // that don't need fully qualified names in layouts |
| ANDROID_WIDGET_PREFIX, ANDROID_VIEW_PKG, ANDROID_WEBKIT_PKG |
| )) { |
| cls = context.evaluator.findClass(prefix + tag) |
| |
| if (cls != null) { |
| break |
| } |
| } |
| } else { |
| cls = context.evaluator.findClass(tag) |
| } |
| |
| return if (cls != null) { |
| cls.isInheritor(castClass, true) |
| } else true |
| |
| // Didn't find class - just assume it's compatible since we don't want false positives |
| } |
| |
| companion object { |
| /** Mismatched view types */ |
| @JvmField |
| val WRONG_VIEW_CAST = Issue.create( |
| id = "WrongViewCast", |
| briefDescription = "Mismatched view type", |
| explanation = """ |
| Keeps track of the view types associated with ids and if it finds a usage \ |
| of the id in the Java code it ensures that it is treated as the same type.""", |
| category = Category.CORRECTNESS, |
| priority = 9, |
| severity = Severity.ERROR, |
| androidSpecific = true, |
| implementation = Implementation( |
| ViewTypeDetector::class.java, |
| EnumSet.of(Scope.ALL_RESOURCE_FILES, Scope.ALL_JAVA_FILES), |
| Scope.JAVA_FILE_SCOPE |
| ) |
| ) |
| |
| /** Mismatched view types */ |
| @JvmField |
| val ADD_CAST = Issue.create( |
| id = "FindViewByIdCast", |
| briefDescription = "Add Explicit Cast", |
| explanation = """ |
| In Android O, the `findViewById` signature switched to using generics, which \ |
| means that most of the time you can leave out explicit casts and just assign \ |
| the result of the `findViewById` call to variables of specific view classes. |
| |
| However, due to language changes between Java 7 and 8, this change may cause \ |
| code to not compile without explicit casts. This lint check looks for these \ |
| scenarios and suggests casts to be added now such that the code will \ |
| continue to compile if the language level is updated to 1.8.""", |
| category = Category.CORRECTNESS, |
| priority = 9, |
| severity = Severity.WARNING, |
| androidSpecific = true, |
| implementation = Implementation(ViewTypeDetector::class.java, Scope.JAVA_FILE_SCOPE) |
| ) |
| |
| const val FIND_VIEW_BY_ID = "findViewById" |
| const val REQUIRE_VIEW_BY_ID = "requireViewById" |
| const val FIND_FRAGMENT_BY_TAG = "findFragmentByTag" |
| private const val TAG_NAME_PREFIX = ":tag:" |
| } |
| } |