package com.airbnb.epoxy;
import com.squareup.javapoet.ClassName;
import com.squareup.javapoet.FieldSpec;
import com.squareup.javapoet.JavaFile;
import com.squareup.javapoet.MethodSpec;
import com.squareup.javapoet.MethodSpec.Builder;
import com.squareup.javapoet.ParameterSpec;
import com.squareup.javapoet.ParameterizedTypeName;
import com.squareup.javapoet.TypeName;
import com.squareup.javapoet.TypeSpec;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import javax.annotation.processing.Filer;
import javax.annotation.processing.RoundEnvironment;
import javax.lang.model.element.Element;
import javax.lang.model.element.Modifier;
import javax.lang.model.element.TypeElement;
import javax.lang.model.type.TypeKind;
import javax.lang.model.type.TypeMirror;
import javax.lang.model.util.Elements;
import javax.lang.model.util.Types;
import static com.airbnb.epoxy.Utils.EPOXY_CONTROLLER_TYPE;
import static com.airbnb.epoxy.Utils.EPOXY_MODEL_TYPE;
import static com.airbnb.epoxy.Utils.UNTYPED_EPOXY_MODEL_TYPE;
import static com.airbnb.epoxy.Utils.belongToTheSamePackage;
import static com.airbnb.epoxy.Utils.getClassName;
import static com.airbnb.epoxy.Utils.isController;
import static com.airbnb.epoxy.Utils.isEpoxyModel;
import static com.airbnb.epoxy.Utils.isSubtype;
import static com.airbnb.epoxy.Utils.validateFieldAccessibleViaGeneratedCode;
class ControllerProcessor {
private static final String CONTROLLER_HELPER_INTERFACE = "com.airbnb.epoxy.ControllerHelper";
private Filer filer;
private Elements elementUtils;
private Types typeUtils;
private ErrorLogger errorLogger;
private final ConfigManager configManager;
private final Map<TypeElement, ControllerClassInfo> controllerClassMap = new LinkedHashMap<>();
ControllerProcessor(Filer filer, Elements elementUtils, Types typeUtils,
ErrorLogger errorLogger, ConfigManager configManager) {
this.filer = filer;
this.elementUtils = elementUtils;
this.typeUtils = typeUtils;
this.errorLogger = errorLogger;
this.configManager = configManager;
}
void process(RoundEnvironment roundEnv) {
for (Element modelFieldElement : roundEnv.getElementsAnnotatedWith(AutoModel.class)) {
try {
addFieldToControllerClass(modelFieldElement, controllerClassMap);
} catch (Exception e) {
errorLogger.logError(e);
}
}
try {
updateClassesForInheritance(controllerClassMap);
} catch (Exception e) {
errorLogger.logError(e);
}
}
void resolveGeneratedModelsAndWriteJava(List<GeneratedModelInfo> generatedModels) {
resolveGeneratedModelNames(controllerClassMap, generatedModels);
generateJava(controllerClassMap);
}
/**
* Models in the same module as the controller they are used in will be processed at the same
* time, so the generated class won't yet exist. This means that we don't have any type
* information for the generated model and can't correctly import it in the generated helper
* class. We can resolve the FQN by looking at what models were already generated and finding
* matching names.
*
* @param generatedModels Information about the already generated models. Relies on the model
* processor running first and passing us this information.
*/
private void resolveGeneratedModelNames(Map<TypeElement, ControllerClassInfo> controllerClassMap,
List<GeneratedModelInfo> generatedModels) {
for (ControllerClassInfo controllerClassInfo : controllerClassMap.values()) {
for (ControllerModelField model : controllerClassInfo.models) {
if (!hasFullyQualifiedName(model)) {
model.typeName = getFullyQualifiedModelTypeName(model, generatedModels);
}
}
}
}
/**
* It will have a FQN if it is from a separate library and was already compiled, otherwise if it
* is from this module we will just have the simple name.
*/
private boolean hasFullyQualifiedName(ControllerModelField model) {
return model.typeName.toString().contains(".");
}
/**
* Returns the ClassType of the given model by finding a match in the list of generated models. If
* no match is found the original model type is returned as a fallback.
*/
private TypeName getFullyQualifiedModelTypeName(ControllerModelField model,
List<GeneratedModelInfo> generatedModels) {
String modelName = model.typeName.toString();
for (GeneratedModelInfo generatedModel : generatedModels) {
String generatedName = generatedModel.getGeneratedName().toString();
if (generatedName.endsWith("." + modelName)) {
return generatedModel.getGeneratedName();
}
}
// Fallback to using the same name
return model.typeName;
}
private void addFieldToControllerClass(Element modelField,
Map<TypeElement, ControllerClassInfo> controllerClassMap) {
TypeElement controllerClassElement = (TypeElement) modelField.getEnclosingElement();
ControllerClassInfo controllerClass =
getOrCreateTargetClass(controllerClassMap, controllerClassElement);
controllerClass.addModel(buildFieldInfo(modelField));
}
/**
* Check each controller for super classes that also have auto models. For each super class with
* auto model we add those models to the auto models of the generated class, so that a
* generated class contains all the models of its super classes combined.
* <p>
* One caveat is that if a sub class is in a different package than its super class we can't
* include auto models that are package private, otherwise the generated class won't compile.
*/
private void updateClassesForInheritance(
Map<TypeElement, ControllerClassInfo> controllerClassMap) {
for (Entry<TypeElement, ControllerClassInfo> entry : controllerClassMap.entrySet()) {
TypeElement thisClass = entry.getKey();
Map<TypeElement, ControllerClassInfo> otherClasses = new LinkedHashMap<>(controllerClassMap);
otherClasses.remove(thisClass);
for (Entry<TypeElement, ControllerClassInfo> otherEntry : otherClasses.entrySet()) {
TypeElement otherClass = otherEntry.getKey();
if (!isSubtype(thisClass, otherClass, typeUtils)) {
continue;
}
Set<ControllerModelField> otherControllerModelFields = otherEntry.getValue().models;
if (belongToTheSamePackage(thisClass, otherClass, elementUtils)) {
entry.getValue().addModels(otherControllerModelFields);
} else {
for (ControllerModelField controllerModelField : otherControllerModelFields) {
if (!controllerModelField.packagePrivate) {
entry.getValue().addModel(controllerModelField);
}
}
}
}
}
}
private ControllerClassInfo getOrCreateTargetClass(
Map<TypeElement, ControllerClassInfo> controllerClassMap,
TypeElement controllerClassElement) {
if (!isController(controllerClassElement)) {
errorLogger.logError("Class with %s annotations must extend %s (%s)",
AutoModel.class.getSimpleName(), EPOXY_CONTROLLER_TYPE,
controllerClassElement.getSimpleName());
}
ControllerClassInfo controllerClassInfo = controllerClassMap.get(controllerClassElement);
if (controllerClassInfo == null) {
controllerClassInfo = new ControllerClassInfo(elementUtils, controllerClassElement);
controllerClassMap.put(controllerClassElement, controllerClassInfo);
}
return controllerClassInfo;
}
private ControllerModelField buildFieldInfo(Element modelFieldElement) {
validateFieldAccessibleViaGeneratedCode(modelFieldElement, AutoModel.class, errorLogger);
TypeMirror fieldType = modelFieldElement.asType();
if (fieldType.getKind() != TypeKind.ERROR) {
// If the field is a generated Epoxy model then the class won't have been generated
// yet and it won't have type info. If the type can't be found that we assume it is
// a generated model and is ok.
if (!isEpoxyModel(fieldType)) {
errorLogger.logError("Fields with %s annotations must be of type %s (%s#%s)",
AutoModel.class.getSimpleName(), EPOXY_MODEL_TYPE,
modelFieldElement.getEnclosingElement().getSimpleName(),
modelFieldElement.getSimpleName());
}
}
return new ControllerModelField(modelFieldElement);
}
private void generateJava(Map<TypeElement, ControllerClassInfo> controllerClassMap) {
for (Entry<TypeElement, ControllerClassInfo> controllerInfo : controllerClassMap.entrySet()) {
try {
generateHelperClassForController(controllerInfo.getValue());
} catch (Exception e) {
errorLogger.logError(e);
}
}
}
private void generateHelperClassForController(ControllerClassInfo controllerInfo)
throws IOException {
ClassName superclass = ClassName.get(elementUtils.getTypeElement(CONTROLLER_HELPER_INTERFACE));
ParameterizedTypeName parameterizeSuperClass =
ParameterizedTypeName.get(superclass, controllerInfo.controllerClassType);
TypeSpec.Builder builder = TypeSpec.classBuilder(controllerInfo.generatedClassName)
.addJavadoc("Generated file. Do not modify!")
.addModifiers(Modifier.PUBLIC)
.superclass(parameterizeSuperClass)
.addField(controllerInfo.controllerClassType, "controller", Modifier.FINAL,
Modifier.PRIVATE)
.addMethod(buildConstructor(controllerInfo))
.addMethod(buildResetModelsMethod(controllerInfo));
if (configManager.shouldValidateModelUsage()) {
builder.addFields(buildFieldsToSaveModelsForValidation(controllerInfo))
.addMethod(buildValidateModelsHaveNotChangedMethod(controllerInfo))
.addMethod(buildValidateSameValueMethod())
.addMethod(buildSaveModelsForNextValidationMethod(controllerInfo));
}
JavaFile.builder(controllerInfo.generatedClassName.packageName(), builder.build())
.build()
.writeTo(filer);
}
private MethodSpec buildConstructor(ControllerClassInfo controllerInfo) {
ParameterSpec controllerParam = ParameterSpec
.builder(controllerInfo.controllerClassType, "controller")
.build();
return MethodSpec.constructorBuilder()
.addParameter(controllerParam)
.addModifiers(Modifier.PUBLIC)
.addStatement("this.controller = controller")
.build();
}
/**
* A field is created to save a reference to the model we create. Before the new buildModels phase
* we check that it is the same object as on the controller, validating that the user has not
* manually assigned a new model to the AutoModel field.
*/
private Iterable<FieldSpec> buildFieldsToSaveModelsForValidation(
ControllerClassInfo controllerInfo) {
List<FieldSpec> fields = new ArrayList<>();
for (ControllerModelField model : controllerInfo.models) {
fields.add(FieldSpec.builder(getClassName(UNTYPED_EPOXY_MODEL_TYPE),
model.fieldName, Modifier.PRIVATE).build());
}
return fields;
}
private MethodSpec buildValidateModelsHaveNotChangedMethod(ControllerClassInfo controllerInfo) {
Builder builder = MethodSpec.methodBuilder("validateModelsHaveNotChanged")
.addModifiers(Modifier.PRIVATE);
// Validate that annotated fields have not been reassigned or had their id changed
long id = -1;
for (ControllerModelField model : controllerInfo.models) {
builder.addStatement("validateSameModel($L, controller.$L, $S, $L)",
model.fieldName, model.fieldName, model.fieldName, id--);
}
return builder
.addStatement("validateModelHashCodesHaveNotChanged(controller)")
.build();
}
private MethodSpec buildValidateSameValueMethod() {
return MethodSpec.methodBuilder("validateSameModel")
.addModifiers(Modifier.PRIVATE)
.addParameter(getClassName(UNTYPED_EPOXY_MODEL_TYPE), "expectedObject")
.addParameter(getClassName(UNTYPED_EPOXY_MODEL_TYPE), "actualObject")
.addParameter(String.class, "fieldName")
.addParameter(TypeName.INT, "id")
.beginControlFlow("if (expectedObject != actualObject)")
.addStatement(
"throw new $T(\"Fields annotated with $L cannot be directly assigned. The controller "
+ "manages these fields for you. (\" + controller.getClass().getSimpleName() + "
+ "\"#\" + fieldName + \")\")",
IllegalStateException.class,
AutoModel.class.getSimpleName())
.endControlFlow()
.beginControlFlow("if (actualObject != null && actualObject.id() != id)")
.addStatement(
"throw new $T(\"Fields annotated with $L cannot have their id changed manually. The "
+ "controller manages the ids of these models for you. (\" + controller.getClass()"
+ ".getSimpleName() + \"#\" + fieldName + \")\")",
IllegalStateException.class,
AutoModel.class.getSimpleName())
.endControlFlow()
.build();
}
private MethodSpec buildSaveModelsForNextValidationMethod(ControllerClassInfo controllerInfo) {
Builder builder = MethodSpec.methodBuilder("saveModelsForNextValidation")
.addModifiers(Modifier.PRIVATE);
for (ControllerModelField model : controllerInfo.models) {
builder.addStatement("$L = controller.$L", model.fieldName, model.fieldName);
}
return builder.build();
}
private MethodSpec buildResetModelsMethod(ControllerClassInfo controllerInfo) {
Builder builder = MethodSpec.methodBuilder("resetAutoModels")
.addAnnotation(Override.class)
.addModifiers(Modifier.PUBLIC);
if (configManager.shouldValidateModelUsage()) {
builder.addStatement("validateModelsHaveNotChanged()");
}
boolean implicitlyAddAutoModels = configManager.implicitlyAddAutoModels(controllerInfo);
long id = -1;
for (ControllerModelField model : controllerInfo.models) {
builder.addStatement("controller.$L = new $T()", model.fieldName, model.typeName)
.addStatement("controller.$L.id($L)", model.fieldName, id--);
if (implicitlyAddAutoModels) {
builder.addStatement("setControllerToStageTo(controller.$L, controller)", model.fieldName);
}
}
if (configManager.shouldValidateModelUsage()) {
builder.addStatement("saveModelsForNextValidation()");
}
return builder.build();
}
}