blob: 20de90d51482c1ffe207df357a5e3c22562291e3 [file] [log] [blame]
/*
* Copyright 2019 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 android.processor.view.inspector;
import android.processor.view.inspector.InspectableClassModel.Accessor;
import android.processor.view.inspector.InspectableClassModel.IntEnumEntry;
import android.processor.view.inspector.InspectableClassModel.IntFlagEntry;
import android.processor.view.inspector.InspectableClassModel.Property;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Pattern;
import javax.annotation.processing.ProcessingEnvironment;
import javax.lang.model.element.AnnotationMirror;
import javax.lang.model.element.AnnotationValue;
import javax.lang.model.element.Element;
import javax.lang.model.element.ElementKind;
import javax.lang.model.element.ExecutableElement;
import javax.lang.model.element.Modifier;
import javax.lang.model.element.TypeElement;
import javax.lang.model.type.NoType;
import javax.lang.model.type.TypeKind;
import javax.lang.model.type.TypeMirror;
/**
* Process {@code @InspectableProperty} annotations.
*
* @see android.view.inspector.InspectableProperty
*/
public final class InspectablePropertyProcessor implements ModelProcessor {
private final String mQualifiedName;
private final ProcessingEnvironment mProcessingEnv;
private final AnnotationUtils mAnnotationUtils;
/**
* Regex that matches methods names of the form {@code #getValue()}.
*/
private static final Pattern GETTER_GET_PREFIX = Pattern.compile("\\Aget[A-Z]");
/**
* Regex that matches method name of the form {@code #isPredicate()}.
*/
private static final Pattern GETTER_IS_PREFIX = Pattern.compile("\\Ais[A-Z]");
/**
* Set of android and androidx annotation qualified names for colors packed into {@code int}.
*
* @see android.annotation.ColorInt
*/
private static final String[] COLOR_INT_ANNOTATION_NAMES = {
"android.annotation.ColorInt",
"androidx.annotation.ColorInt"};
/**
* Set of android and androidx annotation qualified names for colors packed into {@code long}.
*
* @see android.annotation.ColorLong
*/
private static final String[] COLOR_LONG_ANNOTATION_NAMES = {
"android.annotation.ColorLong",
"androidx.annotation.ColorLong"};
/**
* Set of android and androidx annotation qualified names of resource ID annotations.
*/
private static final String[] RESOURCE_ID_ANNOTATION_NAMES = {
"android.annotation.AnimatorRes",
"android.annotation.AnimRes",
"android.annotation.AnyRes",
"android.annotation.ArrayRes",
"android.annotation.BoolRes",
"android.annotation.DimenRes",
"android.annotation.DrawableRes",
"android.annotation.FontRes",
"android.annotation.IdRes",
"android.annotation.IntegerRes",
"android.annotation.InterpolatorRes",
"android.annotation.LayoutRes",
"android.annotation.MenuRes",
"android.annotation.NavigationRes",
"android.annotation.PluralsRes",
"android.annotation.RawRes",
"android.annotation.StringRes",
"android.annotation.StyleableRes",
"android.annotation.StyleRes",
"android.annotation.TransitionRes",
"android.annotation.XmlRes",
"androidx.annotation.AnimatorRes",
"androidx.annotation.AnimRes",
"androidx.annotation.AnyRes",
"androidx.annotation.ArrayRes",
"androidx.annotation.BoolRes",
"androidx.annotation.DimenRes",
"androidx.annotation.DrawableRes",
"androidx.annotation.FontRes",
"androidx.annotation.IdRes",
"androidx.annotation.IntegerRes",
"androidx.annotation.InterpolatorRes",
"androidx.annotation.LayoutRes",
"androidx.annotation.MenuRes",
"androidx.annotation.NavigationRes",
"androidx.annotation.PluralsRes",
"androidx.annotation.RawRes",
"androidx.annotation.StringRes",
"androidx.annotation.StyleableRes",
"androidx.annotation.StyleRes",
"androidx.annotation.TransitionRes",
"androidx.annotation.XmlRes"
};
/**
* @param annotationQualifiedName The qualified name of the annotation to process
* @param processingEnv The processing environment from the parent processor
*/
public InspectablePropertyProcessor(
String annotationQualifiedName,
ProcessingEnvironment processingEnv) {
mQualifiedName = annotationQualifiedName;
mProcessingEnv = processingEnv;
mAnnotationUtils = new AnnotationUtils(processingEnv);
}
@Override
public void process(Element element, InspectableClassModel model) {
try {
final AnnotationMirror annotation =
mAnnotationUtils.exactlyOneMirror(mQualifiedName, element);
final Property property = buildProperty(element, annotation);
model.getProperty(property.getName()).ifPresent(p -> {
throw new ProcessingException(
String.format(
"Property \"%s\" is already defined on #%s.",
p.getName(),
p.getAccessor().invocation()),
element,
annotation);
});
model.putProperty(property);
} catch (ProcessingException processingException) {
processingException.print(mProcessingEnv.getMessager());
}
}
/**
* Build a {@link Property} from a getter and an inspectable property annotation.
*
* @param accessor An element representing the getter or public field to build from
* @param annotation A mirror of an inspectable property-shaped annotation
* @return A property for the getter and annotation
* @throws ProcessingException If the supplied data is invalid and a property cannot be modeled
*/
private Property buildProperty(Element accessor, AnnotationMirror annotation) {
final Property property;
final Optional<String> nameFromAnnotation = mAnnotationUtils
.typedValueByName("name", String.class, accessor, annotation);
validateModifiers(accessor);
switch (accessor.getKind()) {
case FIELD:
property = new Property(
nameFromAnnotation.orElseGet(() -> accessor.getSimpleName().toString()),
Accessor.ofField(accessor.getSimpleName().toString()),
determinePropertyType(accessor, annotation));
break;
case METHOD:
final ExecutableElement getter = ensureGetter(accessor);
property = new Property(
nameFromAnnotation.orElseGet(() -> inferPropertyNameFromGetter(getter)),
Accessor.ofGetter(getter.getSimpleName().toString()),
determinePropertyType(getter, annotation));
break;
default:
throw new ProcessingException(
String.format(
"Property must either be a getter method or a field, got %s.",
accessor.getKind()
),
accessor,
annotation);
}
mAnnotationUtils
.typedValueByName("hasAttributeId", Boolean.class, accessor, annotation)
.ifPresent(property::setAttributeIdInferrableFromR);
mAnnotationUtils
.typedValueByName("attributeId", Integer.class, accessor, annotation)
.ifPresent(property::setAttributeId);
switch (property.getType()) {
case INT_ENUM:
property.setIntEnumEntries(processEnumMapping(accessor, annotation));
break;
case INT_FLAG:
property.setIntFlagEntries(processFlagMapping(accessor, annotation));
break;
}
return property;
}
/**
* Validates that an element is public, concrete, and non-static.
*
* @param element The element to check
* @throws ProcessingException If the element's modifiers are invalid
*/
private void validateModifiers(Element element) {
final Set<Modifier> modifiers = element.getModifiers();
if (!modifiers.contains(Modifier.PUBLIC)) {
throw new ProcessingException(
"Property getter methods and fields must be public.",
element);
}
if (modifiers.contains(Modifier.ABSTRACT)) {
throw new ProcessingException(
"Property getter methods must not be abstract.",
element);
}
if (modifiers.contains(Modifier.STATIC)) {
throw new ProcessingException(
"Property getter methods and fields must not be static.",
element);
}
}
/**
* Check that an element is shaped like a getter.
*
* @param element An element that hopefully represents a getter
* @return An {@link ExecutableElement} that represents a getter method.
* @throws ProcessingException if the element isn't a getter
*/
private ExecutableElement ensureGetter(Element element) {
if (element.getKind() != ElementKind.METHOD) {
throw new ProcessingException(
String.format("Expected a method, got a %s", element.getKind()),
element);
}
final ExecutableElement method = (ExecutableElement) element;
if (!method.getParameters().isEmpty()) {
throw new ProcessingException(
String.format(
"Expected a getter method to take no parameters, "
+ "but got %d parameters.",
method.getParameters().size()),
element);
}
if (method.isVarArgs()) {
throw new ProcessingException(
"Expected a getter method to take no arguments, but got a var args method.",
element);
}
if (method.getReturnType() instanceof NoType) {
throw new ProcessingException(
"Expected a getter to have a return type, got void.",
element);
}
return method;
}
/**
* Determine the property type from the annotation, return type, or context clues.
*
* @param accessor An element representing the getter or field to determine the type of
* @param annotation A mirror of an inspectable property-shaped annotation
* @return The resolved property type
* @throws ProcessingException If the property type cannot be resolved or is invalid
* @see android.view.inspector.InspectableProperty#valueType()
*/
private Property.Type determinePropertyType(
Element accessor,
AnnotationMirror annotation) {
final String valueType = mAnnotationUtils
.untypedValueByName("valueType", accessor, annotation)
.map(Object::toString)
.orElse("INFERRED");
final Property.Type accessorType =
convertTypeMirrorToPropertyType(extractReturnOrFieldType(accessor), accessor);
final Optional<AnnotationValue> enumMapping =
mAnnotationUtils.valueByName("enumMapping", annotation);
final Optional<AnnotationValue> flagMapping =
mAnnotationUtils.valueByName("flagMapping", annotation);
if (accessorType != Property.Type.INT) {
enumMapping.ifPresent(value -> {
throw new ProcessingException(
String.format(
"Can only use enumMapping on int types, got %s.",
accessorType.toString().toLowerCase()),
accessor,
annotation,
value);
});
flagMapping.ifPresent(value -> {
throw new ProcessingException(
String.format(
"Can only use flagMapping on int types, got %s.",
accessorType.toString().toLowerCase()),
accessor,
annotation,
value);
});
}
switch (valueType) {
case "INFERRED":
final boolean hasColor = hasColorAnnotation(accessor);
final boolean hasResourceId = hasResourceIdAnnotation(accessor);
if (hasColor) {
enumMapping.ifPresent(value -> {
throw new ProcessingException(
"Cannot use enumMapping on a color type.",
accessor,
annotation,
value);
});
flagMapping.ifPresent(value -> {
throw new ProcessingException(
"Cannot use flagMapping on a color type.",
accessor,
annotation,
value);
});
if (hasResourceId) {
throw new ProcessingException(
"Cannot infer type, both color and resource ID annotations "
+ "are present.",
accessor,
annotation);
}
return Property.Type.COLOR;
} else if (hasResourceId) {
enumMapping.ifPresent(value -> {
throw new ProcessingException(
"Cannot use enumMapping on a resource ID type.",
accessor,
annotation,
value);
});
flagMapping.ifPresent(value -> {
throw new ProcessingException(
"Cannot use flagMapping on a resource ID type.",
accessor,
annotation,
value);
});
return Property.Type.RESOURCE_ID;
} else if (enumMapping.isPresent()) {
flagMapping.ifPresent(value -> {
throw new ProcessingException(
"Cannot use flagMapping and enumMapping simultaneously.",
accessor,
annotation,
value);
});
return Property.Type.INT_ENUM;
} else if (flagMapping.isPresent()) {
return Property.Type.INT_FLAG;
} else {
return accessorType;
}
case "NONE":
return accessorType;
case "COLOR":
switch (accessorType) {
case COLOR:
case INT:
case LONG:
return Property.Type.COLOR;
default:
throw new ProcessingException(
"Color must be a long, integer, or android.graphics.Color",
accessor,
annotation);
}
case "GRAVITY":
requirePackedIntToBeInt("Gravity", accessorType, accessor, annotation);
return Property.Type.GRAVITY;
case "INT_ENUM":
requirePackedIntToBeInt("IntEnum", accessorType, accessor, annotation);
return Property.Type.INT_ENUM;
case "INT_FLAG":
requirePackedIntToBeInt("IntFlag", accessorType, accessor, annotation);
return Property.Type.INT_FLAG;
case "RESOURCE_ID":
return Property.Type.RESOURCE_ID;
default:
throw new ProcessingException(
String.format("Unknown value type enumeration value: %s", valueType),
accessor,
annotation);
}
}
/**
* Get the type of a field or the return type of a method.
*
* @param element The element to extract a {@link TypeMirror} from
* @return The return or field type of the element
* @throws ProcessingException If the element is not a field or a method
*/
private TypeMirror extractReturnOrFieldType(Element element) {
switch (element.getKind()) {
case FIELD:
return element.asType();
case METHOD:
return ((ExecutableElement) element).getReturnType();
default:
throw new ProcessingException(
String.format(
"Unable to determine the type of a %s.",
element.getKind()),
element);
}
}
/**
* Get a property type from a type mirror
*
* @param typeMirror The type mirror to convert to a property type
* @param element The element to be used for exceptions
* @return The property type returned by the getter
* @throws ProcessingException If the return type is not a primitive or an object
*/
private Property.Type convertTypeMirrorToPropertyType(TypeMirror typeMirror, Element element) {
switch (unboxType(typeMirror)) {
case BOOLEAN:
return Property.Type.BOOLEAN;
case BYTE:
return Property.Type.BYTE;
case CHAR:
return Property.Type.CHAR;
case DOUBLE:
return Property.Type.DOUBLE;
case FLOAT:
return Property.Type.FLOAT;
case INT:
return Property.Type.INT;
case LONG:
return Property.Type.LONG;
case SHORT:
return Property.Type.SHORT;
case DECLARED:
if (isColorType(typeMirror)) {
return Property.Type.COLOR;
} else {
return Property.Type.OBJECT;
}
case ARRAY:
return Property.Type.OBJECT;
default:
throw new ProcessingException(
String.format("Unsupported property type %s.", typeMirror),
element);
}
}
/**
* Require that a value type packed into an integer be on a getter that returns an int.
*
* @param typeName The name of the type to use in the exception
* @param returnType The return type of the getter to check
* @param accessor The getter, to use in the exception
* @param annotation The annotation, to use in the exception
* @throws ProcessingException If the return type is not an int
*/
private static void requirePackedIntToBeInt(
String typeName,
Property.Type returnType,
Element accessor,
AnnotationMirror annotation) {
if (returnType != Property.Type.INT) {
throw new ProcessingException(
String.format(
"%s can only be defined on a method that returns int, got %s.",
typeName,
returnType.toString().toLowerCase()),
accessor,
annotation);
}
}
/**
* Determine if a getter is annotated with color annotation matching its return type.
*
* Note that an {@code int} return value annotated with {@link android.annotation.ColorLong} is
* not considered to be annotated, nor is a {@code long} annotated with
* {@link android.annotation.ColorInt}.
*
* @param accessor The getter or field to query
* @return True if the getter has a color annotation, false otherwise
*/
private boolean hasColorAnnotation(Element accessor) {
switch (unboxType(extractReturnOrFieldType(accessor))) {
case INT:
for (String name : COLOR_INT_ANNOTATION_NAMES) {
if (mAnnotationUtils.hasAnnotation(accessor, name)) {
return true;
}
}
return false;
case LONG:
for (String name : COLOR_LONG_ANNOTATION_NAMES) {
if (mAnnotationUtils.hasAnnotation(accessor, name)) {
return true;
}
}
return false;
default:
return false;
}
}
/**
* Determine if a getter or a field is annotated with a resource ID annotation.
*
* @param accessor The getter or field to query
* @return True if the accessor is an integer and has a resource ID annotation, false otherwise
*/
private boolean hasResourceIdAnnotation(Element accessor) {
if (unboxType(extractReturnOrFieldType(accessor)) == TypeKind.INT) {
for (String name : RESOURCE_ID_ANNOTATION_NAMES) {
if (mAnnotationUtils.hasAnnotation(accessor, name)) {
return true;
}
}
}
return false;
}
/**
* Infer a property name from a getter method.
*
* If the method is prefixed with {@code get}, the prefix will be stripped, and the
* capitalization fixed. E.g.: {@code getSomeProperty} to {@code someProperty}.
*
* Additionally, if the method's return type is a boolean, an {@code is} prefix will also be
* stripped. E.g.: {@code isPropertyEnabled} to {@code propertyEnabled}.
*
* Failing that, this method will just return the full name of the getter.
*
* @param getter An element representing a getter
* @return A string property name
*/
private String inferPropertyNameFromGetter(ExecutableElement getter) {
final String name = getter.getSimpleName().toString();
if (GETTER_GET_PREFIX.matcher(name).find()) {
return name.substring(3, 4).toLowerCase() + name.substring(4);
} else if (isBoolean(getter.getReturnType()) && GETTER_IS_PREFIX.matcher(name).find()) {
return name.substring(2, 3).toLowerCase() + name.substring(3);
} else {
return name;
}
}
/**
* Build a model of an {@code int} enumeration mapping from annotation values.
*
* This method only handles the one-to-one mapping of mirrors of
* {@link android.view.inspector.InspectableProperty.EnumMap} annotations into
* {@link IntEnumEntry} objects. Further validation should be handled elsewhere
*
* @see android.view.inspector.IntEnumMapping
* @see android.view.inspector.InspectableProperty#enumMapping()
* @param accessor The accessor of the property, used for exceptions
* @param annotation The {@link android.view.inspector.InspectableProperty} annotation to
* extract enum mapping values from.
* @return A list of int enum entries, in the order specified in source
* @throws ProcessingException if mapping doesn't exist or is invalid
*/
private List<IntEnumEntry> processEnumMapping(
Element accessor,
AnnotationMirror annotation) {
List<AnnotationMirror> enumAnnotations = mAnnotationUtils.typedArrayValuesByName(
"enumMapping", AnnotationMirror.class, accessor, annotation);
List<IntEnumEntry> enumEntries = new ArrayList<>(enumAnnotations.size());
if (enumAnnotations.isEmpty()) {
throw new ProcessingException(
"Encountered an empty array for enumMapping", accessor, annotation);
}
for (AnnotationMirror enumAnnotation : enumAnnotations) {
final String name = mAnnotationUtils.typedValueByName(
"name", String.class, accessor, enumAnnotation)
.orElseThrow(() -> {
throw new ProcessingException(
"Name is required for @EnumMap",
accessor,
enumAnnotation);
});
final int value = mAnnotationUtils.typedValueByName(
"value", Integer.class, accessor, enumAnnotation)
.orElseThrow(() -> {
throw new ProcessingException(
"Value is required for @EnumMap",
accessor,
enumAnnotation);
});
enumEntries.add(new IntEnumEntry(name, value));
}
return enumEntries;
}
/**
* Build a model of an {@code int} flag mapping from annotation values.
*
* This method only handles the one-to-one mapping of mirrors of
* {@link android.view.inspector.InspectableProperty.FlagMap} annotations into
* {@link IntFlagEntry} objects. Further validation should be handled elsewhere
*
* @see android.view.inspector.IntFlagMapping
* @see android.view.inspector.InspectableProperty#flagMapping()
* @param accessor The accessor of the property, used for exceptions
* @param annotation The {@link android.view.inspector.InspectableProperty} annotation to
* extract flag mapping values from.
* @return A list of int flags entries, in the order specified in source
* @throws ProcessingException if mapping doesn't exist or is invalid
*/
private List<IntFlagEntry> processFlagMapping(
Element accessor,
AnnotationMirror annotation) {
List<AnnotationMirror> flagAnnotations = mAnnotationUtils.typedArrayValuesByName(
"flagMapping", AnnotationMirror.class, accessor, annotation);
List<IntFlagEntry> flagEntries = new ArrayList<>(flagAnnotations.size());
if (flagAnnotations.isEmpty()) {
throw new ProcessingException(
"Encountered an empty array for flagMapping", accessor, annotation);
}
for (AnnotationMirror flagAnnotation : flagAnnotations) {
final String name = mAnnotationUtils.typedValueByName(
"name", String.class, accessor, flagAnnotation)
.orElseThrow(() -> {
throw new ProcessingException(
"Name is required for @FlagMap",
accessor,
flagAnnotation);
});
final int target = mAnnotationUtils.typedValueByName(
"target", Integer.class, accessor, flagAnnotation)
.orElseThrow(() -> {
throw new ProcessingException(
"Target is required for @FlagMap",
accessor,
flagAnnotation);
});
final Optional<Integer> mask = mAnnotationUtils.typedValueByName(
"mask", Integer.class, accessor, flagAnnotation);
if (mask.isPresent()) {
flagEntries.add(new IntFlagEntry(name, target, mask.get()));
} else {
flagEntries.add(new IntFlagEntry(name, target));
}
}
return flagEntries;
}
/**
* Determine if a {@link TypeMirror} is a boxed or unboxed boolean.
*
* @param type The type mirror to check
* @return True if the type is a boolean
*/
private boolean isBoolean(TypeMirror type) {
if (type.getKind() == TypeKind.DECLARED) {
return mProcessingEnv.getTypeUtils().unboxedType(type).getKind() == TypeKind.BOOLEAN;
} else {
return type.getKind() == TypeKind.BOOLEAN;
}
}
/**
* Unbox a type mirror if it represents a boxed type, otherwise pass it through.
*
* @param typeMirror The type mirror to unbox
* @return The same type mirror, or an unboxed primitive version
*/
private TypeKind unboxType(TypeMirror typeMirror) {
final TypeKind typeKind = typeMirror.getKind();
if (typeKind.isPrimitive()) {
return typeKind;
} else if (typeKind == TypeKind.DECLARED) {
try {
return mProcessingEnv.getTypeUtils().unboxedType(typeMirror).getKind();
} catch (IllegalArgumentException e) {
return typeKind;
}
} else {
return typeKind;
}
}
/**
* Determine if a type mirror represents a subtype of {@link android.graphics.Color}.
*
* @param typeMirror The type mirror to test
* @return True if it represents a subclass of color, false otherwise
*/
private boolean isColorType(TypeMirror typeMirror) {
final TypeElement colorType = mProcessingEnv
.getElementUtils()
.getTypeElement("android.graphics.Color");
if (colorType == null) {
return false;
} else {
return mProcessingEnv.getTypeUtils().isSubtype(typeMirror, colorType.asType());
}
}
}