/* * Copyright 2017 Google Inc. All Rights Reserved. * * 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 pub.devrel.bundler; import com.squareup.javapoet.ClassName; import com.squareup.javapoet.JavaFile; import com.squareup.javapoet.MethodSpec; import com.squareup.javapoet.TypeSpec; import java.io.Serializable; import java.util.ArrayList; import java.util.List; import javax.annotation.processing.ProcessingEnvironment; 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.element.VariableElement; import javax.lang.model.type.ArrayType; import javax.lang.model.type.DeclaredType; import javax.lang.model.type.PrimitiveType; import javax.lang.model.type.TypeKind; import javax.lang.model.type.TypeMirror; import javax.tools.Diagnostic; /** * Generates code to convert a POJO class to/from an Android Bundle. */ public class Bundler { /** Policy for matching two types. **/ private enum MatchPolicy { // Types must be the same EXACT, // Type a must be assignable to type b ASSIGNABLE } // Access the Bundle class like this since we don't have the ability to get Android classes // in this Java module private static final ClassName BUNDLE_CLASS = ClassName.get("android.os", "Bundle"); // Other Android classes private static final String BUNDLE_CLASS_NAME = "android.os.Bundle"; private static final String I_BINDER_CLASS_NAME = "android.os.IBinder"; private static final String PARCELABLE_CLASS_NAME = "android.os.Parcelable"; private static final String SIZE_CLASS_NAME = "android.util.Size"; private static final String SIZE_F_CLASS_NAME = "android.util.SizeF"; private ProcessingEnvironment environment; private BundlerClassInfo info; public Bundler(ProcessingEnvironment environment, BundlerClassInfo info) { this.environment = environment; this.info = info; } /** * Returns the fully qualified name of the generated class. Ex: com.foo.far.BazBundler. */ public String getQualifiedBundlerClassName() { return info.className.packageName() + "." + getBundlerClassName(); } /** * Returns the simple class name of the generate class. Ex: BazBundler. */ public String getBundlerClassName() { return info.className.simpleName() + "Bundler"; } /** * Process the BundlerClass and return the source of a generated Bundler class, as a String. * The output of this method is intended for writing to a ".java" file. */ public String getBundlerClassSource() { // Create class named {FooObject}Bundler TypeSpec bundlerType = TypeSpec.classBuilder(getBundlerClassName()) .addModifiers(Modifier.PUBLIC, Modifier.FINAL) .addMethod(createToBundleMethod()) .addMethod(createFromBundleMethod()) .build(); JavaFile javaFile = JavaFile.builder(info.className.packageName(), bundlerType) .build(); return javaFile.toString(); } /** * Create the "fromBundle" method that accepts a Bundle and returns a member * of the wrapped class. */ private MethodSpec createFromBundleMethod() { MethodSpec.Builder builder = MethodSpec.methodBuilder("fromBundle") .addModifiers(Modifier.PUBLIC, Modifier.STATIC) .addParameter(BUNDLE_CLASS, "bundle") .returns(info.className); // Ensure the class has an empty constructor boolean hasEmptyConstructor = false; for (Element e : info.typeElement.getEnclosedElements()) { if (e.getKind() == ElementKind.CONSTRUCTOR) { boolean isEmptyConstructor = ((ExecutableElement) e).getParameters().isEmpty(); hasEmptyConstructor = hasEmptyConstructor || isEmptyConstructor; } } if (!hasEmptyConstructor) { String message = "[EasyBundler] Type " + info.className + " does not have default constructor!"; environment.getMessager().printMessage(Diagnostic.Kind.ERROR, message); } // Create a new instance of the object builder.addStatement("$T object = new $T()", info.className, info.className); // Get each field from the bundle and set it on the object for (VariableElement field : getApplicableFields()) { // Decide on the key for the field and how to get it from the bundle String fieldKey = getFieldKey(field); String getMethod = bundleGetMethod(field); if (isPublic(field)) { // Public fields can be set directly // Ex: object.someField = (Type) bundle.getString("KEY") if (requiresCast(getMethod)) { // Set with cast builder.addStatement("object.$L = ($T) bundle.$L($S)", field.getSimpleName(), field.asType(), getMethod, fieldKey); } else { // Set without cast builder.addStatement("object.$L = bundle.$L($S)", field.getSimpleName(), getMethod, fieldKey); } } else { // Non-public fields are set with the setter // Ex: object.setSomeField((Type) bundle.getString("KEY")) if (requiresCast(getMethod)) { // Set with cast builder.addStatement("object.$L(($T) bundle.$L($S))", setterName(field), field.asType(), getMethod, fieldKey); } else { // Set without cast builder.addStatement("object.$L(bundle.$L($S))", setterName(field), getMethod, fieldKey); } } } // Return the object instance builder.addStatement("return object"); return builder.build(); } /** * Create the "toBundle" method that serializes the wrapped class as a Bundle. */ private MethodSpec createToBundleMethod() { MethodSpec.Builder builder = MethodSpec.methodBuilder("toBundle") .addModifiers(Modifier.PUBLIC, Modifier.STATIC) .addParameter(info.className, "object") .returns(BUNDLE_CLASS); // Create new bundle builder.addStatement("$T bundle = new $T()", BUNDLE_CLASS, BUNDLE_CLASS); // Get each field from the object and set it on the bundle for (VariableElement field : getApplicableFields()) { // Decide on the key for the field and how to add it to the bundle String fieldKey = getFieldKey(field); String putMethod = bundlePutMethod(field); if (isPublic(field)) { // Public fields can be accessed directly // Ex: bundle.putString("KEY", object.someField) builder.addStatement("bundle.$L($S, object.$L)", putMethod, fieldKey, field.getSimpleName()); } else { // Non-public fields are accessed via getter // Ex: bundle.putString("KEY, object.getSomeField()) builder.addStatement("bundle.$L($S, object.$L())", putMethod, fieldKey, getterName(field)); } } // Return statement builder.addStatement("return bundle"); return builder.build(); } /** * Returns a (probably) unique Bundle key for a field. */ private String getFieldKey(VariableElement field) { return "KEY_" + info.className.reflectionName() + "_" + field.getSimpleName(); } /** * Returns the name of "put" method from the Bundle class for a given field. Ex: putString * or putCharSequenceArray. */ private String bundlePutMethod(VariableElement field) { return "put" + bundleMethodSuffix(field); } /** * Returns the name of "get" method from the Bundle class for a given field. Ex: getString * or getCharSequenceArray. */ private String bundleGetMethod(VariableElement field) { return "get" + bundleMethodSuffix(field); } /** * Returns {@code true} if a cast is needed when using a certain bundle method. */ private boolean requiresCast(String bundleMethodName) { // TL;DR - ParcelableArrayList is a pain in the ass! return !(bundleMethodName.contains("ParcelableArrayList")); } /** * Returns the suffix for a bundle method based on type. For a String field this would be * "String", for an Integer field this would be "Int". Used by * {@link #bundlePutMethod(VariableElement)} and {@link #bundleGetMethod(VariableElement)}. */ private String bundleMethodSuffix(VariableElement field) { // Method lists consulted: // * https://developer.android.com/reference/android/os/BaseBundle.html // * https://developer.android.com/reference/android/os/Bundle.html // Not supported: // * SparseParcelableArray<? extends Parcelable> // Primitives and boxed primitives if (matchesClass(field, Boolean.class, MatchPolicy.ASSIGNABLE)) { return "Boolean"; } else if (matchesClass(field, Byte.class, MatchPolicy.ASSIGNABLE)) { return "Byte"; } else if (matchesClass(field, Character.class, MatchPolicy.ASSIGNABLE)) { return "Char"; } else if (matchesClass(field, Double.class, MatchPolicy.ASSIGNABLE)) { return "Double"; } else if (matchesClass(field, Float.class, MatchPolicy.ASSIGNABLE)) { return "Float"; } else if (matchesClass(field, Integer.class, MatchPolicy.ASSIGNABLE)) { return "Int"; } else if (matchesClass(field, Long.class, MatchPolicy.ASSIGNABLE)) { return "Long"; } else if (matchesClass(field, Short.class, MatchPolicy.ASSIGNABLE)) { return "Short"; } // Non-primitive classes if (matchesClass(field, String.class, MatchPolicy.EXACT)) { return "String"; } if (matchesClass(field, CharSequence.class, MatchPolicy.EXACT)) { return "CharSequence"; } else if (matchesClass(field, BUNDLE_CLASS_NAME, MatchPolicy.EXACT)) { return "Bundle"; } else if (matchesClass(field, I_BINDER_CLASS_NAME, MatchPolicy.EXACT)) { return "Binder"; } else if (matchesClass(field, SIZE_CLASS_NAME, MatchPolicy.EXACT)) { return "Size"; } else if (matchesClass(field, SIZE_F_CLASS_NAME, MatchPolicy.EXACT)) { return "SizeF"; } // Primitive array classes if (matchesPrimitiveArrayClass(field, TypeKind.BYTE)) { return "ByteArray"; } else if (matchesPrimitiveArrayClass(field, TypeKind.BOOLEAN)) { return "BooleanArray"; } else if (matchesPrimitiveArrayClass(field, TypeKind.CHAR)) { return "CharArray"; } else if (matchesPrimitiveArrayClass(field, TypeKind.DOUBLE)) { return "DoubleArray"; } else if (matchesPrimitiveArrayClass(field, TypeKind.FLOAT)) { return "FloatArray"; } else if (matchesPrimitiveArrayClass(field, TypeKind.INT)) { return "IntArray"; } else if (matchesPrimitiveArrayClass(field, TypeKind.LONG)) { return "LongArray"; } else if (matchesPrimitiveArrayClass(field, TypeKind.SHORT)) { return "ShortArray"; } // Non-primitive array classes if (matchesArrayClass(field, String.class, MatchPolicy.EXACT)) { return "StringArray"; } else if (matchesArrayClass(field, CharSequence.class, MatchPolicy.EXACT)) { return "CharSequenceArray"; } // ArrayList classes if (matchesArrayListClass(field, CharSequence.class, MatchPolicy.EXACT)) { return "CharSequenceArrayList"; } else if (matchesArrayListClass(field, Integer.class, MatchPolicy.EXACT)) { return "IntegerArrayList"; } else if (matchesArrayListClass(field, String.class, MatchPolicy.EXACT)) { return "StringArrayList"; } // Parcelable[] if (matchesArrayClass(field, PARCELABLE_CLASS_NAME, MatchPolicy.ASSIGNABLE)) { return "ParcelableArray"; } // ArrayList<Parcelable> if (matchesArrayListClass(field, PARCELABLE_CLASS_NAME, MatchPolicy.ASSIGNABLE)) { return "ParcelableArrayList"; } // Serializable and Parcelable last to avoid masking something more specific if (matchesClass(field, Serializable.class, MatchPolicy.ASSIGNABLE)) { return "Serializable"; } else if (matchesClass(field, PARCELABLE_CLASS_NAME, MatchPolicy.ASSIGNABLE)) { return "Parcelable"; } // Could not find type, throw Exception String message = "[EasyBundler] Field " + field.getSimpleName() + " in class " + info.className + " cannot be included in bundle: unknown type " + field.asType(); environment.getMessager().printMessage(Diagnostic.Kind.ERROR, message); return null; } /** * Returns {@code true} if the type of an {@link VariableElement} is the same as a * particular {@link Class}. This cannot be used with array classes or generic types. */ private boolean matchesClass(VariableElement field, Class<?> clazz, MatchPolicy policy) { return matchesClass(field, clazz.getCanonicalName(), policy); } /** * See {@link #matchesClass(VariableElement, Class, MatchPolicy)}. */ private boolean matchesClass(VariableElement field, String className, MatchPolicy policy) { TypeElement target = getTypeElementForClass(className); return target != null && typesMatch(field.asType(), target.asType(), policy); } /** * Returns {@code true} if the type of an {@link VariableElement} representing an array * is an array where the members are a particular {@link Class}. */ private boolean matchesArrayClass(VariableElement field, Class<?> clazz, MatchPolicy policy) { return matchesArrayClass(field, clazz.getCanonicalName(), policy); } /** * See {@link #matchesArrayClass(VariableElement, Class, MatchPolicy)}. */ private boolean matchesArrayClass(VariableElement field, String className, MatchPolicy policy) { // Check if the field is an array if (field.asType().getKind() != TypeKind.ARRAY) { return false; } // Get the type of array it is TypeMirror componentType = ((ArrayType) field.asType()).getComponentType(); // Perform check TypeElement target = getTypeElementForClass(className); return target != null && typesMatch(componentType, target.asType(), policy); } /** * Returns {@code true} if the type of an {@link VariableElement} representing an array * is a primitive array where the members are a particular {@link TypeKind}. */ private boolean matchesPrimitiveArrayClass(VariableElement field, TypeKind kind) { PrimitiveType primitiveType = environment.getTypeUtils().getPrimitiveType(kind); ArrayType arrayType = environment.getTypeUtils().getArrayType(primitiveType); return typesMatch(field.asType(), arrayType, MatchPolicy.EXACT); } /** * Returns {@code true} if the type of an {@link VariableElement} representing an ArrayList * is an ArrayList where the members are a particular {@link Class}. */ private boolean matchesArrayListClass(VariableElement field, Class<?> clazz, MatchPolicy policy) { return matchesArrayListClass(field, clazz.getCanonicalName(), policy); } /** * See {@link #matchesArrayListClass(VariableElement, Class, MatchPolicy)}. */ private boolean matchesArrayListClass(VariableElement field, String className, MatchPolicy policy) { if (!(field.asType() instanceof DeclaredType)) { return false; } // Get generic information DeclaredType declaredType = (DeclaredType) field.asType(); // Check general form if (declaredType.getTypeArguments().size() != 1) { return false; } // Ensure that outer type is ArrayList TypeElement arrayList = getTypeElementForClass(ArrayList.class); TypeMirror erased = environment.getTypeUtils().erasure(declaredType); boolean isArrayList = typesMatch(erased, arrayList.asType(), MatchPolicy.ASSIGNABLE); // Make sure inner type matches TypeMirror innerType = declaredType.getTypeArguments().get(0); TypeElement target = getTypeElementForClass(className); boolean innerTypeMatches = target != null && typesMatch(innerType, target.asType(), policy); return isArrayList && innerTypeMatches; } /** * Returns {@code true} if two type mirrors match. This can be either exact match or * assignability depending on the {@link MatchPolicy}. */ private boolean typesMatch(TypeMirror a, TypeMirror b, MatchPolicy policy) { switch (policy) { case EXACT: return environment.getTypeUtils().isSameType(a, b); case ASSIGNABLE: return environment.getTypeUtils().isAssignable(a, b); default: return false; } } /** * Returns an {@link TypeElement} representing a {@link Class} for comparison. */ private TypeElement getTypeElementForClass(Class clazz) { return getTypeElementForClass(clazz.getCanonicalName()); } /** * Returns an {@link TypeElement} representing a {@link Class} for comparison, by name. */ private TypeElement getTypeElementForClass(String className) { return environment.getElementUtils().getTypeElement(className); } /** * Returns the list of {@link VariableElement} fields that can be properly bundled. This is * a list of public fields or private/protected fields that have predictably-named getters * and setters. */ private List<VariableElement> getApplicableFields() { List<VariableElement> result = new ArrayList<>(); for (VariableElement field : info.fields) { // Skip static fields if (isStatic(field)) { continue; } if (isPublic(field)) { // Public fields can always be considered result.add(field); } else { // Non-public fields can be considered if there is a getter and setter String getterName = getterName(field); String setterName = setterName(field); // Search for appropriate getters and setters boolean hasGetter = false; boolean hasSetter = false; for (ExecutableElement ee : info.methods) { // Find methods that are named the getter if (getterName.equals(ee.getSimpleName().toString())) { // Ensure that they take no params and return the correct type boolean noParams = ee.getParameters().isEmpty(); boolean correctReturn = ee.getReturnType().equals(field.asType()); hasGetter = hasGetter || (noParams && correctReturn); } // Find methods that are named like the setter if (setterName.equals(ee.getSimpleName().toString())) { // Ensure that they take exactly one param of the right type List<? extends VariableElement> params = ee.getParameters(); boolean correctParams = params.size() == 1 && params.get(0).asType().equals(field.asType()); hasSetter = hasSetter || correctParams; } } // All conditions met, add the field if (hasGetter && hasSetter) { result.add(field); } } } return result; } /** * Returns {@link true} if a {@link VariableElement} is a {@code static} field */ private static boolean isStatic(VariableElement element) { return element.getModifiers().contains(Modifier.STATIC); } /** * Returns {@link true} if a {@link VariableElement} is a {@code public} field */ private static boolean isPublic(VariableElement element) { return element.getModifiers().contains(Modifier.PUBLIC); } /** * Returns the standard getter method name for a {@link VariableElement}. Ex: foo --> getFoo. */ private static String getterName(VariableElement element) { return "get" + capitalizedName(element); } /** * Returns the standard setter method name for a {@link VariableElement}. Ex: foo --> setFoo. */ private static String setterName(VariableElement element) { return "set" + capitalizedName(element); } /** * Capitalizes the first letter of the name of a {@link VariableElement}. Used by * {@link #getterName(VariableElement)} and {@link #setterName(VariableElement)}. */ private static String capitalizedName(VariableElement element) { // TODO(samstern): this will almost certainly choke on unicode String name = element.getSimpleName().toString(); return name.substring(0, 1).toUpperCase() + name.substring(1); } }