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