LayoutInspectionStep.kt

/*
 * Copyright 2021 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 androidx.resourceinspection.processor

import com.google.auto.common.AnnotationMirrors.getAnnotationValue
import com.google.auto.common.BasicAnnotationProcessor
import com.google.auto.common.GeneratedAnnotationSpecs.generatedAnnotationSpec
import com.google.auto.common.MoreElements.asExecutable
import com.google.auto.common.MoreElements.asType
import com.google.auto.common.MoreElements.getPackage
import com.google.auto.common.Visibility
import com.google.auto.common.Visibility.effectiveVisibilityOfElement
import com.google.common.collect.ImmutableSetMultimap
import com.squareup.javapoet.AnnotationSpec
import javax.annotation.processing.ProcessingEnvironment
import javax.annotation.processing.Processor
import javax.lang.model.element.AnnotationMirror
import javax.lang.model.element.AnnotationValue
import javax.lang.model.element.Element
import javax.lang.model.element.ExecutableElement
import javax.lang.model.element.TypeElement
import javax.lang.model.type.TypeKind
import javax.lang.model.type.TypeMirror
import javax.tools.Diagnostic

/** Processing step for generating layout inspection companions from `Attribute` annotations. */
internal class LayoutInspectionStep(
    private val processingEnv: ProcessingEnvironment,
    processorClass: Class<out Processor>
) : BasicAnnotationProcessor.Step {
    private val generatedAnnotation: AnnotationSpec? = generatedAnnotationSpec(
        processingEnv.elementUtils,
        processingEnv.sourceVersion,
        processorClass
    ).orElse(null)

    override fun annotations(): Set<String> {
        return setOf(ATTRIBUTE, APP_COMPAT_SHADOWED_ATTRIBUTES)
    }

    override fun process(
        elementsByAnnotation: ImmutableSetMultimap<String, Element>
    ): Set<Element> {
        if (!isViewInspectorApiPresent()) {
            printError(
                "View inspector (android.view.inspector) API is not present. " +
                    "Please ensure compile SDK is 29 or greater."
            )
            return emptySet()
        }

        val views = mergeViews(
            elementsByAnnotation[ATTRIBUTE]
                .groupBy({ asType(it.enclosingElement) }, { asExecutable(it) }),
            elementsByAnnotation[APP_COMPAT_SHADOWED_ATTRIBUTES]
                .mapTo(mutableSetOf()) { asType(it) }
        )
        val filer = processingEnv.filer

        views.forEach { generateInspectionCompanion(it, generatedAnnotation).writeTo(filer) }

        // We don't defer elements for later rounds in this processor
        return emptySet()
    }

    /** Checks if the view inspector API is present in the compile class path */
    private fun isViewInspectorApiPresent(): Boolean {
        return VIEW_INSPECTOR_CLASSES.all { className ->
            processingEnv.elementUtils.getTypeElement(className) != null
        }
    }

    /** Merge shadowed and regular attributes into [View] models. */
    private fun mergeViews(
        viewsWithGetters: Map<TypeElement, List<ExecutableElement>>,
        viewsWithShadowedAttributes: Set<TypeElement>
    ): List<View> {
        return (viewsWithGetters.keys + viewsWithShadowedAttributes).mapNotNull { viewType ->
            val getterAttributes = viewsWithGetters[viewType].orEmpty().map(::parseGetter)

            if (viewType in viewsWithShadowedAttributes) {
                inferShadowedAttributes(viewType)?.let { shadowedAttributes ->
                    createView(viewType, getterAttributes + shadowedAttributes)
                }
            } else {
                createView(viewType, getterAttributes)
            }
        }
    }

    /** Parse the annotated getters of a view class into a [View]. */
    private fun createView(type: TypeElement, attributes: Collection<Attribute?>): View? {
        val duplicateAttributes = attributes
            .filterNotNull()
            .groupBy { it.qualifiedName }
            .values
            .filter { it.size > 1 }

        if (duplicateAttributes.any()) {
            duplicateAttributes.forEach { duplicates ->
                duplicates.forEach { attribute ->
                    val qualifiedName = attribute.qualifiedName
                    val otherGetters = duplicates
                        .filter { it.invocation != attribute.invocation }
                        .joinToString { it.invocation }

                    printError(
                        "Duplicate attribute $qualifiedName is also present on $otherGetters",
                        (attribute as? GetterAttribute)?.getter,
                        (attribute as? GetterAttribute)?.annotation
                    )
                }
            }
            return null
        }

        if (attributes.isEmpty() || attributes.any { it == null }) {
            return null
        }

        return View(type, attributes = attributes.filterNotNull().sortedBy { it.qualifiedName })
    }

    /** Get an [Attribute] from a method known to have an `Attribute` annotation. */
    private fun parseGetter(getter: ExecutableElement): Attribute? {
        val annotation = getter.getAnnotationMirror(ATTRIBUTE)!!
        val annotationValue = getAnnotationValue(annotation, "value")
        val value = annotationValue.value as String
        var hasError = false

        if (getter.parameters.isNotEmpty() || getter.returnType.kind == TypeKind.VOID) {
            printError("@Attribute must annotate a getter", getter, annotation)
            hasError = true
        }

        if (effectiveVisibilityOfElement(getter) != Visibility.PUBLIC) {
            printError("@Attribute getter must be public", getter, annotation)
            hasError = true
        }

        if (!getter.enclosingElement.asType().isAssignableTo(VIEW)) {
            printError("@Attribute must be on a subclass of android.view.View", getter, annotation)
            hasError = true
        }

        val intMapping = parseIntMapping(annotation)
        if (!validateIntMapping(getter, intMapping)) {
            hasError = true
        }

        val match = ATTRIBUTE_VALUE.matchEntire(value)

        if (match == null) {
            if (!value.contains(':')) {
                printError("@Attribute must include namespace", getter, annotation, annotationValue)
            } else {
                printError("Invalid attribute name", getter, annotation, annotationValue)
            }
            return null // Returning here since there's no more checks we can do
        }

        if (hasError) {
            return null
        }

        val (namespace, name) = match.destructured
        val type = inferAttributeType(getter, intMapping)

        if (!isAttributeInRFile(namespace, name)) {
            printError("Attribute $namespace:$name not found", getter, annotation)
            return null
        }

        return GetterAttribute(getter, annotation, namespace, name, type, intMapping)
    }

    /** Parse `Attribute.intMapping`. */
    private fun parseIntMapping(annotation: AnnotationMirror): List<IntMap> {
        return (getAnnotationValue(annotation, "intMapping").value as List<*>).map { entry ->
            val intMapAnnotation = (entry as AnnotationValue).value as AnnotationMirror

            IntMap(
                name = getAnnotationValue(intMapAnnotation, "name").value as String,
                value = getAnnotationValue(intMapAnnotation, "value").value as Int,
                mask = getAnnotationValue(intMapAnnotation, "mask").value as Int,
                annotation = intMapAnnotation
            )
        }.sortedBy { it.value }
    }

    /** Check that int mapping is valid and consistent */
    private fun validateIntMapping(element: Element, intMapping: List<IntMap>): Boolean {
        if (intMapping.isEmpty()) {
            return true // Return early for the common case of no int mapping
        }

        var result = true
        val isEnum = intMapping.all { it.mask == 0 }

        // Check for duplicate names for both flags and enums
        val duplicateNames = intMapping.groupBy { it.name }.values.filter { it.size > 1 }
        duplicateNames.flatten().forEach { intMap ->
            printError(
                "Duplicate int ${if (isEnum) "enum" else "flag"} entry name: \"${intMap.name}\"",
                element,
                intMap.annotation,
                intMap.annotation?.let { getAnnotationValue(it, "name") }
            )
        }
        if (duplicateNames.isNotEmpty()) {
            result = false
        }

        if (isEnum) {
            // Check for duplicate enum values
            val duplicateValues = intMapping.groupBy { it.value }.values.filter { it.size > 1 }
            duplicateValues.forEach { group ->
                group.forEach { intMap ->
                    val others = (group - intMap).joinToString { "\"${it.name}\"" }
                    printError(
                        "Int enum value ${intMap.value} is duplicated on entries $others",
                        element,
                        intMap.annotation,
                        intMap.annotation?.let { getAnnotationValue(it, "value") }
                    )
                }
            }
            if (duplicateValues.isNotEmpty()) {
                result = false
            }
        } else {
            // Check for invalid flags, with masks that obscure part of the value. Note that a mask
            // of 0 is a special case which implies that the mask is equal to the value as in enums.
            intMapping.forEach { intMap ->
                if (intMap.mask and intMap.value != intMap.value && intMap.mask != 0) {
                    printError(
                        "Int flag mask 0x${intMap.mask.toString(16)} does not reveal value " +
                            "0x${intMap.value.toString(16)}",
                        element,
                        intMap.annotation
                    )
                    result = false
                }
            }

            // Check for duplicate flags
            val duplicatePairs = intMapping
                .groupBy { Pair(if (it.mask != 0) it.mask else it.value, it.value) }
                .values
                .filter { it.size > 1 }

            duplicatePairs.forEach { group ->
                group.forEach { intMap ->
                    val others = (group - intMap).joinToString { "\"${it.name}\"" }
                    val mask = if (intMap.mask != 0) intMap.mask else intMap.value
                    printError(
                        "Int flag mask 0x${mask.toString(16)} and value " +
                            "0x${intMap.value.toString(16)} is duplicated on entries $others",
                        element,
                        intMap.annotation
                    )
                }
            }

            if (duplicatePairs.isNotEmpty()) {
                result = false
            }
        }

        return result
    }

    /** Map the getter's annotations and return type to the internal attribute type. */
    private fun inferAttributeType(
        getter: ExecutableElement,
        intMapping: List<IntMap>
    ): AttributeType {
        return when (getter.returnType.kind) {
            TypeKind.BOOLEAN -> AttributeType.BOOLEAN
            TypeKind.BYTE -> AttributeType.BYTE
            TypeKind.CHAR -> AttributeType.CHAR
            TypeKind.DOUBLE -> AttributeType.DOUBLE
            TypeKind.FLOAT -> AttributeType.FLOAT
            TypeKind.SHORT -> AttributeType.SHORT
            TypeKind.INT -> when {
                getter.isAnnotationPresent(COLOR_INT) -> AttributeType.COLOR
                getter.isAnnotationPresent(GRAVITY_INT) -> AttributeType.GRAVITY
                getter.hasResourceIdAnnotation() -> AttributeType.RESOURCE_ID
                intMapping.any { it.mask != 0 } -> AttributeType.INT_FLAG
                intMapping.isNotEmpty() -> AttributeType.INT_ENUM
                else -> AttributeType.INT
            }
            TypeKind.LONG ->
                if (getter.isAnnotationPresent(COLOR_LONG)) {
                    AttributeType.COLOR
                } else {
                    AttributeType.LONG
                }
            TypeKind.DECLARED, TypeKind.ARRAY ->
                if (getter.returnType.isAssignableTo(COLOR)) {
                    AttributeType.COLOR
                } else {
                    AttributeType.OBJECT
                }
            else -> throw IllegalArgumentException("Unexpected attribute type")
        }
    }

    /** Determines shadowed attributes based on interfaces present on the view. */
    private fun inferShadowedAttributes(viewType: TypeElement): List<ShadowedAttribute>? {
        if (!viewType.asType().isAssignableTo(VIEW)) {
            printError(
                "@AppCompatShadowedAttributes must be on a subclass of android.view.View",
                viewType,
                viewType.getAnnotationMirror(APP_COMPAT_SHADOWED_ATTRIBUTES)
            )
            return null
        }

        if (!getPackage(viewType).qualifiedName.startsWith("androidx.appcompat.")) {
            printError(
                "@AppCompatShadowedAttributes is only supported in the androidx.appcompat package",
                viewType,
                viewType.getAnnotationMirror(APP_COMPAT_SHADOWED_ATTRIBUTES)
            )
            return null
        }

        val attributes = viewType.interfaces.flatMap {
            APP_COMPAT_INTERFACE_MAP[it.toString()].orEmpty()
        }

        if (attributes.isEmpty()) {
            printError(
                "@AppCompatShadowedAttributes is present on this view, but it does not implement " +
                    "any interfaces that indicate it has shadowed attributes.",
                viewType,
                viewType.getAnnotationMirror(APP_COMPAT_SHADOWED_ATTRIBUTES)
            )
            return null
        }

        return attributes
    }

    /** Check if an R.java file exists for [namespace] and that it contains attribute [name] */
    private fun isAttributeInRFile(namespace: String, name: String): Boolean {
        return processingEnv.elementUtils.getTypeElement("$namespace.R")
            ?.enclosedElements?.find { it.simpleName.contentEquals("attr") }
            ?.enclosedElements?.find { it.simpleName.contentEquals(name) } != null
    }

    private fun Element.hasResourceIdAnnotation(): Boolean {
        return this.annotationMirrors.any {
            asType(it.annotationType.asElement()).qualifiedName matches RESOURCE_ID_ANNOTATION
        }
    }

    private fun TypeMirror.isAssignableTo(typeName: String): Boolean {
        val assignableType = requireNotNull(processingEnv.elementUtils.getTypeElement(typeName)) {
            "Expected $typeName to exist"
        }
        return processingEnv.typeUtils.isAssignable(this, assignableType.asType())
    }

    /** Convenience wrapper for [javax.annotation.processing.Messager.printMessage]. */
    private fun printError(
        message: String,
        element: Element? = null,
        annotation: AnnotationMirror? = null,
        value: AnnotationValue? = null
    ) {
        processingEnv.messager.printMessage(
            Diagnostic.Kind.ERROR,
            message,
            element,
            annotation,
            value
        )
    }

    /** Find an annotation mirror by its qualified name */
    private fun Element.getAnnotationMirror(qualifiedName: String): AnnotationMirror? {
        return this.annotationMirrors.firstOrNull { annotation ->
            asType(annotation.annotationType.asElement())
                .qualifiedName.contentEquals(qualifiedName)
        }
    }

    /** True if the supplied annotation name is present on the element */
    private fun Element.isAnnotationPresent(qualifiedName: String): Boolean {
        return getAnnotationMirror(qualifiedName) != null
    }

    private companion object {
        /** Regex for validating and parsing attribute name and namespace. */
        val ATTRIBUTE_VALUE = """(\w+(?:\.\w+)*):(\w+)""".toRegex()

        /** Regex for matching resource ID annotations. */
        val RESOURCE_ID_ANNOTATION = """androidx?\.annotation\.[A-Z]\w+Res""".toRegex()

        /** Fully qualified name of the `Attribute` annotation */
        const val ATTRIBUTE = "androidx.resourceinspection.annotation.Attribute"

        /** Fully qualified name of the `AppCompatShadowedAttributes` annotation */
        const val APP_COMPAT_SHADOWED_ATTRIBUTES =
            "androidx.resourceinspection.annotation.AppCompatShadowedAttributes"

        /** Fully qualified name of the platform's `Color` class */
        const val COLOR = "android.graphics.Color"

        /** Fully qualified name of `ColorInt` */
        const val COLOR_INT = "androidx.annotation.ColorInt"

        /** Fully qualified name of `ColorLong` */
        const val COLOR_LONG = "androidx.annotation.ColorLong"

        /** Fully qualified name of `GravityInt` */
        const val GRAVITY_INT = "androidx.annotation.GravityInt"

        /** Fully qualified name of the platform's View class */
        const val VIEW = "android.view.View"

        /** Fully qualified names of the view inspector classes introduced in API 29 */
        val VIEW_INSPECTOR_CLASSES = listOf(
            "android.view.inspector.InspectionCompanion",
            "android.view.inspector.PropertyReader",
            "android.view.inspector.PropertyMapper"
        )

        /**
         * Map of compat interface names in `androidx.core` to the AppCompat attributes they
         * shadow. These virtual attributes are added to the inspection companion for views within
         * AppCompat with the `@AppCompatShadowedAttributes` annotation.
         *
         * As you can tell, this is brittle. The good news is these are established platform APIs
         * from API <= 29 (the minimum for app inspection) and are unlikely to change in the
         * future. If you update this list, please update the documentation comment in
         * [androidx.resourceinspection.annotation.AppCompatShadowedAttributes] as well.
         */
        val APP_COMPAT_INTERFACE_MAP: Map<String, List<ShadowedAttribute>> = mapOf(
            "androidx.core.view.TintableBackgroundView" to listOf(
                ShadowedAttribute("backgroundTint", "getBackgroundTintList()"),
                ShadowedAttribute("backgroundTintMode", "getBackgroundTintMode()")
            ),
            "androidx.core.widget.AutoSizeableTextView" to listOf(
                ShadowedAttribute(
                    "autoSizeTextType",
                    "getAutoSizeTextType()",
                    AttributeType.INT_ENUM,
                    listOf(
                        IntMap("none", 0 /* TextView.AUTO_SIZE_TEXT_TYPE_NONE */),
                        IntMap("uniform", 1 /* TextView.AUTO_SIZE_TEXT_TYPE_UNIFORM */),
                    )
                ),
                ShadowedAttribute(
                    "autoSizeStepGranularity", "getAutoSizeStepGranularity()", AttributeType.INT
                ),
                ShadowedAttribute(
                    "autoSizeMinTextSize", "getAutoSizeMinTextSize()", AttributeType.INT
                ),
                ShadowedAttribute(
                    "autoSizeMaxTextSize", "getAutoSizeMaxTextSize()", AttributeType.INT
                )
            ),
            "androidx.core.widget.TintableCheckedTextView" to listOf(
                ShadowedAttribute("checkMarkTint", "getCheckMarkTintList()"),
                ShadowedAttribute("checkMarkTintMode", "getCheckMarkTintMode()")
            ),
            "androidx.core.widget.TintableCompoundButton" to listOf(
                ShadowedAttribute("buttonTint", "getButtonTintList()"),
                ShadowedAttribute("buttonTintMode", "getButtonTintMode()")
            ),
            "androidx.core.widget.TintableCompoundDrawablesView" to listOf(
                ShadowedAttribute("drawableTint", "getCompoundDrawableTintList()"),
                ShadowedAttribute("drawableTintMode", "getCompoundDrawableTintMode()")
            ),
            "androidx.core.widget.TintableImageSourceView" to listOf(
                ShadowedAttribute("tint", "getImageTintList()"),
                ShadowedAttribute("tintMode", "getImageTintMode()"),
            )
        )
    }
}