1 /* 2 * Copyright 2018 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 package androidx.appsearch.compiler; 17 18 import static androidx.appsearch.compiler.IntrospectionHelper.APPSEARCH_ANNOTATION_PKG; 19 import static androidx.appsearch.compiler.IntrospectionHelper.DOCUMENT_ANNOTATION_SIMPLE_CLASS_NAME; 20 21 import static javax.lang.model.util.ElementFilter.typesIn; 22 23 import androidx.annotation.VisibleForTesting; 24 25 import com.google.auto.common.BasicAnnotationProcessor; 26 import com.google.auto.common.MoreElements; 27 import com.google.auto.value.AutoValue; 28 import com.google.common.collect.ImmutableList; 29 import com.google.common.collect.ImmutableSet; 30 import com.google.common.collect.ImmutableSetMultimap; 31 import com.squareup.javapoet.JavaFile; 32 33 import org.jspecify.annotations.NonNull; 34 35 import java.io.File; 36 import java.io.IOException; 37 import java.nio.charset.StandardCharsets; 38 import java.security.MessageDigest; 39 import java.security.NoSuchAlgorithmException; 40 import java.util.ArrayList; 41 import java.util.Collections; 42 import java.util.HashMap; 43 import java.util.HashSet; 44 import java.util.List; 45 import java.util.Map; 46 import java.util.Set; 47 48 import javax.annotation.Nullable; 49 import javax.annotation.processing.Messager; 50 import javax.annotation.processing.ProcessingEnvironment; 51 import javax.annotation.processing.SupportedAnnotationTypes; 52 import javax.annotation.processing.SupportedOptions; 53 import javax.annotation.processing.SupportedSourceVersion; 54 import javax.lang.model.SourceVersion; 55 import javax.lang.model.element.Element; 56 import javax.lang.model.element.ElementKind; 57 import javax.lang.model.element.TypeElement; 58 import javax.tools.Diagnostic.Kind; 59 60 /** 61 * Processes {@code androidx.appsearch.annotation.Document} annotations. 62 * 63 * <p>Only plain Java objects and AutoValue Document classes without builders are supported. 64 */ 65 @SupportedAnnotationTypes({APPSEARCH_ANNOTATION_PKG + "." + DOCUMENT_ANNOTATION_SIMPLE_CLASS_NAME}) 66 @SupportedSourceVersion(SourceVersion.RELEASE_8) 67 @SupportedOptions({ 68 AppSearchCompiler.OUTPUT_DIR_OPTION, 69 AppSearchCompiler.RESTRICT_GENERATED_CODE_TO_LIB_OPTION 70 }) 71 public class AppSearchCompiler extends BasicAnnotationProcessor { 72 /** 73 * This property causes us to write output to a different folder instead of the usual filer 74 * location. It should only be used for testing. 75 */ 76 @VisibleForTesting 77 static final String OUTPUT_DIR_OPTION = "AppSearchCompiler.OutputDir"; 78 79 /** 80 * This property causes us to annotate the generated classes with 81 * {@link androidx.annotation.RestrictTo} with the scope 82 * {@link androidx.annotation.RestrictTo.Scope#LIBRARY}. 83 * 84 * <p>In practice, this annotation only affects AndroidX tooling and so this option may only 85 * be useful to other AndroidX libs. 86 */ 87 @VisibleForTesting 88 static final String RESTRICT_GENERATED_CODE_TO_LIB_OPTION = 89 "AppSearchCompiler.RestrictGeneratedCodeToLib"; 90 91 @Override getSupportedSourceVersion()92 public @NonNull SourceVersion getSupportedSourceVersion() { 93 return SourceVersion.latestSupported(); 94 } 95 96 @Override steps()97 protected Iterable<? extends Step> steps() { 98 return ImmutableList.of(new AppSearchCompileStep(processingEnv)); 99 } 100 101 private static final class AppSearchCompileStep implements Step { 102 private final ProcessingEnvironment mProcessingEnv; 103 private final Messager mMessager; 104 private final Map<String, List<String>> mDocumentClassMap; 105 // Annotation processing can be run in multiple rounds. This tracks the index of current 106 // round starting from 0. 107 private int mRoundIndex; 108 AppSearchCompileStep(ProcessingEnvironment processingEnv)109 AppSearchCompileStep(ProcessingEnvironment processingEnv) { 110 mProcessingEnv = processingEnv; 111 mMessager = processingEnv.getMessager(); 112 mDocumentClassMap = new HashMap<>(); 113 mRoundIndex = -1; 114 } 115 116 @Override annotations()117 public ImmutableSet<String> annotations() { 118 return ImmutableSet.of(IntrospectionHelper.DOCUMENT_ANNOTATION_CLASS.canonicalName()); 119 } 120 121 @Override process( ImmutableSetMultimap<String, Element> elementsByAnnotation)122 public ImmutableSet<Element> process( 123 ImmutableSetMultimap<String, Element> elementsByAnnotation) { 124 mDocumentClassMap.clear(); 125 mRoundIndex += 1; 126 127 Set<TypeElement> documentElements = 128 typesIn(elementsByAnnotation.get( 129 IntrospectionHelper.DOCUMENT_ANNOTATION_CLASS.canonicalName())); 130 131 ImmutableSet.Builder<Element> nextRound = new ImmutableSet.Builder<>(); 132 String documentMapClassPackage = null; 133 Set<String> classNames = new HashSet<>(); 134 for (TypeElement document : documentElements) { 135 try { 136 processDocument(document); 137 } catch (MissingTypeException e) { 138 // Save it for next round to wait for the AutoValue annotation processor to 139 // be run first. 140 nextRound.add(document); 141 } catch (ProcessingException e) { 142 // Prints error message. 143 e.printDiagnostic(mMessager); 144 } 145 classNames.add(document.getQualifiedName().toString()); 146 String packageName = 147 mProcessingEnv.getElementUtils().getPackageOf(document).toString(); 148 // We must choose a deterministic package to place the generated document map 149 // class. Given multiple packages, we have no real preference between them. So 150 // for the sake of making a deterministic selection, we always choose to generate 151 // the map in the lexicographically smallest package. 152 if (documentMapClassPackage == null || packageName.compareTo( 153 documentMapClassPackage) < 0) { 154 documentMapClassPackage = packageName; 155 } 156 } 157 158 try { 159 if (!classNames.isEmpty()) { 160 // Append the hash code of classNames and the index of the current round as a 161 // suffix to the name of the generated document map class. This will prevent 162 // the generation of two classes with the same name, which could otherwise 163 // happen when there are two Java modules that contain classes in the same 164 // package name, or there are multiple rounds of annotation processing for some 165 // module. 166 String classSuffix = generateStringSetHash( 167 classNames, /* delimiter= */ ",") + "_" + mRoundIndex; 168 writeJavaFile(DocumentMapGenerator.generate( 169 mProcessingEnv, 170 documentMapClassPackage, 171 classSuffix, 172 mDocumentClassMap, 173 getRestrictGeneratedCodeToLibOption())); 174 } 175 } catch (NoSuchAlgorithmException | IOException e) { 176 mProcessingEnv.getMessager().printMessage(Kind.ERROR, 177 "Failed to create the AppSearch document map class: " + e); 178 } 179 180 // Pass elements to next round of processing. 181 return nextRound.build(); 182 } 183 writeJavaFile(JavaFile javaFile)184 private void writeJavaFile(JavaFile javaFile) throws IOException { 185 String outputDir = getOutputDirOption(); 186 if (outputDir == null || outputDir.isEmpty()) { 187 javaFile.writeTo(mProcessingEnv.getFiler()); 188 } else { 189 mMessager.printMessage( 190 Kind.NOTE, 191 "Writing output to \"" + outputDir 192 + "\" due to the presence of -A" + OUTPUT_DIR_OPTION); 193 javaFile.writeTo(new File(outputDir)); 194 } 195 } 196 197 /** 198 * Process the document class by generating a factory class for it and properly update 199 * {@link #mDocumentClassMap}. 200 */ processDocument(@onNull TypeElement element)201 private void processDocument(@NonNull TypeElement element) 202 throws ProcessingException, MissingTypeException { 203 if (element.getKind() != ElementKind.CLASS 204 && element.getKind() != ElementKind.INTERFACE) { 205 throw new ProcessingException( 206 "@Document annotation on something other than a class or an interface", 207 element); 208 } 209 210 DocumentModel model; 211 if (element.getAnnotation(AutoValue.class) != null) { 212 // Document class is annotated as AutoValue class. For processing the AutoValue 213 // class, we also need the generated class from AutoValue annotation processor. 214 TypeElement generatedElement = 215 mProcessingEnv.getElementUtils().getTypeElement( 216 getAutoValueGeneratedClassName(element)); 217 if (generatedElement == null) { 218 // Generated class is not found. 219 throw new MissingTypeException(element); 220 } else { 221 model = DocumentModel.createAutoValueModel(mProcessingEnv, element, 222 generatedElement); 223 } 224 } else { 225 // Non-AutoValue AppSearch Document class. 226 model = DocumentModel.createPojoModel(mProcessingEnv, element); 227 } 228 229 CodeGenerator generator = new CodeGenerator( 230 mProcessingEnv, model, getRestrictGeneratedCodeToLibOption()); 231 try { 232 writeJavaFile(generator.createJavaFile()); 233 } catch (IOException e) { 234 ProcessingException pe = 235 new ProcessingException("Failed to write output", model.getClassElement()); 236 pe.initCause(e); 237 throw pe; 238 } 239 240 List<String> documentClassList = mDocumentClassMap.computeIfAbsent( 241 model.getSchemaName(), k -> new ArrayList<>()); 242 documentClassList.add( 243 mProcessingEnv.getElementUtils().getBinaryName(element).toString()); 244 } 245 getRestrictGeneratedCodeToLibOption()246 private boolean getRestrictGeneratedCodeToLibOption() { 247 return Boolean.parseBoolean( 248 mProcessingEnv.getOptions().get(RESTRICT_GENERATED_CODE_TO_LIB_OPTION)); 249 } 250 251 @Nullable getOutputDirOption()252 private String getOutputDirOption() { 253 return mProcessingEnv.getOptions().get(OUTPUT_DIR_OPTION); 254 } 255 256 /** 257 * Gets the generated class name of an AutoValue annotated class. 258 * 259 * <p>This is the same naming strategy used by AutoValue's processor. 260 */ getAutoValueGeneratedClassName(TypeElement element)261 private String getAutoValueGeneratedClassName(TypeElement element) { 262 TypeElement type = element; 263 String name = type.getSimpleName().toString(); 264 while (type.getEnclosingElement() instanceof TypeElement) { 265 type = (TypeElement) type.getEnclosingElement(); 266 name = type.getSimpleName().toString() + "_" + name; 267 } 268 String pkg = MoreElements.getPackage(type).getQualifiedName().toString(); 269 String dot = pkg.isEmpty() ? "" : "."; 270 return pkg + dot + "AutoValue_" + name; 271 } 272 273 /** 274 * Generate a SHA-256 hash for a given string set. 275 * 276 * @param set The set of the strings. 277 * @param delimiter The delimiter used to separate the strings, which should not have 278 * appeared in any of the strings in the set. 279 */ generateStringSetHash(@onNull Set<String> set, @NonNull String delimiter)280 private static @NonNull String generateStringSetHash(@NonNull Set<String> set, 281 @NonNull String delimiter) throws NoSuchAlgorithmException { 282 List<String> sortedList = new ArrayList<>(set); 283 Collections.sort(sortedList); 284 285 MessageDigest md = MessageDigest.getInstance("SHA-256"); 286 for (String s : sortedList) { 287 md.update(s.getBytes(StandardCharsets.UTF_8)); 288 md.update(delimiter.getBytes(StandardCharsets.UTF_8)); 289 } 290 StringBuilder result = new StringBuilder(); 291 for (byte b : md.digest()) { 292 String hex = Integer.toHexString(0xFF & b); 293 if (hex.length() == 1) { 294 result.append('0'); 295 } 296 result.append(hex); 297 } 298 return result.toString(); 299 } 300 } 301 } 302