blob: 2b76fd9d9beb0086457e3d47abc5fd8f834706cc [file] [log] [blame]
/*
* Copyright (C) 2018 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.metalava
import com.android.SdkConstants
import com.android.tools.metalava.model.AnnotationAttributeValue
import com.android.tools.metalava.model.ClassItem
import com.android.tools.metalava.model.Codebase
import com.android.tools.metalava.model.FieldItem
import com.android.tools.metalava.model.Item
import com.android.tools.metalava.model.MethodItem
import com.android.tools.metalava.model.ParameterItem
import com.android.tools.metalava.model.TypeItem
import com.android.tools.metalava.model.visitors.ApiVisitor
import java.util.regex.Pattern
/** Misc API suggestions */
class AndroidApiChecks {
fun check(codebase: Codebase) {
codebase.accept(object : ApiVisitor(
// Sort by source order such that warnings follow source line number order
methodComparator = MethodItem.sourceOrderComparator,
fieldComparator = FieldItem.comparator
) {
override fun skip(item: Item): Boolean {
// Limit the checks to the android.* namespace (except for ICU)
if (item is ClassItem) {
val name = item.qualifiedName()
return !(name.startsWith("android.") && !name.startsWith("android.icu."))
}
return super.skip(item)
}
override fun visitItem(item: Item) {
checkTodos(item)
}
override fun visitMethod(method: MethodItem) {
checkRequiresPermission(method)
if (!method.isConstructor()) {
checkVariable(method, "@return", "Return value of '" + method.name() + "'", method.returnType())
}
}
override fun visitField(field: FieldItem) {
if (field.name().contains("ACTION")) {
checkIntentAction(field)
}
checkVariable(field, null, "Field '" + field.name() + "'", field.type())
}
override fun visitParameter(parameter: ParameterItem) {
checkVariable(
parameter,
parameter.name(),
"Parameter '" + parameter.name() + "' of '" + parameter.containingMethod().name() + "'",
parameter.type()
)
}
})
}
private var cachedDocumentation: String = ""
private var cachedDocumentationItem: Item? = null
private var cachedDocumentationTag: String? = null
// Cache around findDocumentation
private fun getDocumentation(item: Item, tag: String?): String {
return if (item === cachedDocumentationItem && cachedDocumentationTag == tag) {
cachedDocumentation
} else {
cachedDocumentationItem = item
cachedDocumentationTag = tag
cachedDocumentation = findDocumentation(item, tag)
cachedDocumentation
}
}
private fun findDocumentation(item: Item, tag: String?): String {
if (item is ParameterItem) {
return findDocumentation(item.containingMethod(), item.name())
}
val doc = item.documentation
if (doc.isBlank()) {
return ""
}
if (tag == null) {
return doc
}
var begin: Int
if (tag == "@return") {
// return tag
begin = doc.indexOf("@return")
} else {
begin = 0
while (true) {
begin = doc.indexOf(tag, begin)
if (begin == -1) {
return ""
} else {
// See if it's prefixed by @param
// Scan backwards and allow whitespace and *
var ok = false
for (i in begin - 1 downTo 0) {
val c = doc[i]
if (c != '*' && !Character.isWhitespace(c)) {
if (c == 'm' && doc.startsWith("@param", i - 5, true)) {
begin = i - 5
ok = true
}
break
}
}
if (ok) {
// found beginning
break
}
}
begin += tag.length
}
}
if (begin == -1) {
return ""
}
// Find end
// This is the first block tag on a new line
var isLinePrefix = false
var end = doc.length
for (i in begin + 1 until doc.length) {
val c = doc[i]
if (c == '@' && (
isLinePrefix ||
doc.startsWith("@param", i, true) ||
doc.startsWith("@return", i, true)
)
) {
// Found it
end = i
break
} else if (c == '\n') {
isLinePrefix = true
} else if (c != '*' && !Character.isWhitespace(c)) {
isLinePrefix = false
}
}
return doc.substring(begin, end)
}
private fun checkTodos(item: Item) {
if (item.documentation.contains("TODO:") || item.documentation.contains("TODO(")) {
reporter.report(Issues.TODO, item, "Documentation mentions 'TODO'")
}
}
private fun checkRequiresPermission(method: MethodItem) {
val text = method.documentation
val annotation = method.modifiers.findAnnotation("android.support.annotation.RequiresPermission")
if (annotation != null) {
for (attribute in annotation.attributes) {
var values: List<AnnotationAttributeValue>? = null
when (attribute.name) {
"value", "allOf", "anyOf" -> {
values = attribute.leafValues()
}
}
if (values == null || values.isEmpty()) {
continue
}
for (value in values) {
// var perm = String.valueOf(value.value())
var perm = value.toSource()
if (perm.indexOf('.') >= 0) perm = perm.substring(perm.lastIndexOf('.') + 1)
if (text.contains(perm)) {
reporter.report(
// Why is that a problem? Sometimes you want to describe
// particular use cases.
Issues.REQUIRES_PERMISSION, method,
"Method '" + method.name() +
"' documentation mentions permissions already declared by @RequiresPermission"
)
}
}
}
} else if (text.contains("android.Manifest.permission") || text.contains("android.permission.")) {
reporter.report(
Issues.REQUIRES_PERMISSION, method,
"Method '" + method.name() +
"' documentation mentions permissions without declaring @RequiresPermission"
)
}
}
private fun checkIntentAction(field: FieldItem) {
// Intent rules don't apply to support library
if (field.containingClass().qualifiedName().startsWith("android.support.")) {
return
}
val hasBehavior = field.modifiers.findAnnotation("android.annotation.BroadcastBehavior") != null
val hasSdkConstant = field.modifiers.findAnnotation("android.annotation.SdkConstant") != null
val text = field.documentation
if (text.contains("Broadcast Action:") ||
text.contains("protected intent") && text.contains("system")
) {
if (!hasBehavior) {
reporter.report(
Issues.BROADCAST_BEHAVIOR, field,
"Field '" + field.name() + "' is missing @BroadcastBehavior"
)
}
if (!hasSdkConstant) {
reporter.report(
Issues.SDK_CONSTANT, field,
"Field '" + field.name() +
"' is missing @SdkConstant(SdkConstantType.BROADCAST_INTENT_ACTION)"
)
}
}
if (text.contains("Activity Action:")) {
if (!hasSdkConstant) {
reporter.report(
Issues.SDK_CONSTANT, field,
"Field '" + field.name() +
"' is missing @SdkConstant(SdkConstantType.ACTIVITY_INTENT_ACTION)"
)
}
}
}
private fun checkVariable(
item: Item,
tag: String?,
ident: String,
type: TypeItem?
) {
type ?: return
if (type.toString() == "int" && constantPattern.matcher(getDocumentation(item, tag)).find()) {
var foundTypeDef = false
for (annotation in item.modifiers.annotations()) {
val cls = annotation.resolve() ?: continue
val modifiers = cls.modifiers
if (modifiers.findAnnotation(SdkConstants.INT_DEF_ANNOTATION.oldName()) != null ||
modifiers.findAnnotation(SdkConstants.INT_DEF_ANNOTATION.newName()) != null
) {
// TODO: Check that all the constants listed in the documentation are included in the
// annotation?
foundTypeDef = true
break
}
}
if (!foundTypeDef) {
reporter.report(
Issues.INT_DEF, item,
// TODO: Include source code you can paste right into the code?
"$ident documentation mentions constants without declaring an @IntDef"
)
}
}
if (nullPattern.matcher(getDocumentation(item, tag)).find() &&
!item.hasNullnessInfo()
) {
reporter.report(
Issues.NULLABLE, item,
"$ident documentation mentions 'null' without declaring @NonNull or @Nullable"
)
}
}
companion object {
val constantPattern: Pattern = Pattern.compile("[A-Z]{3,}_([A-Z]{3,}|\\*)")
@Suppress("SpellCheckingInspection")
val nullPattern: Pattern = Pattern.compile("\\bnull\\b")
}
}