1 /*
2  * Copyright 2023 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 // @exportToFramework:skipFile()
17 package androidx.appsearch.app;
18 
19 import android.util.Log;
20 
21 import androidx.annotation.AnyThread;
22 import androidx.annotation.GuardedBy;
23 import androidx.annotation.RestrictTo;
24 import androidx.annotation.WorkerThread;
25 import androidx.appsearch.annotation.Document;
26 import androidx.collection.ArrayMap;
27 
28 import org.jspecify.annotations.NonNull;
29 import org.jspecify.annotations.Nullable;
30 
31 import java.util.ArrayList;
32 import java.util.Collections;
33 import java.util.List;
34 import java.util.Map;
35 import java.util.Objects;
36 import java.util.ServiceLoader;
37 
38 /**
39  * A class that maintains the map from schema type names to the fully qualified names of the
40  * corresponding document classes.
41  */
42 @AnyThread
43 public abstract class AppSearchDocumentClassMap {
44 
45     private static final String TAG = "AppSearchDocumentClassM";
46     private static final Object sLock = new Object();
47 
48     /**
49      * The cached value of {@link #getGlobalMap()}.
50      */
51     private static volatile Map<String, List<String>> sGlobalMap = null;
52 
53     /**
54      * The cached value of {@code Class.forName(className)} for AppSearch document classes.
55      */
56     private static volatile Map<String, Class<?>> sCachedAppSearchClasses = new ArrayMap<>();
57 
58     /**
59      * Returns the global map that includes all AppSearch document classes annotated with
60      * {@link Document} that are available in the current runtime. It maps from AppSearch's type
61      * name specified by {@link Document#name()} to the list of the fully qualified names of the
62      * corresponding document classes. The values are lists because it is possible that two
63      * document classes are associated with the same AppSearch type name.
64      *
65      * <p>Note that although this method, under normal circumstances, executes quickly, it
66      * performs a synchronous disk read operation in order to build the map, which means it can
67      * potentially introduce I/O blocking if executed on the main thread.
68      *
69      * <p>Since every call to this method should return the same map, the value of this map will
70      * be internally cached, so that only the first call will perform disk I/O.
71      */
72     @WorkerThread
getGlobalMap()73     public static @NonNull Map<String, List<String>> getGlobalMap() {
74         if (sGlobalMap == null) {
75             synchronized (sLock) {
76                 if (sGlobalMap == null) {
77                     sGlobalMap = buildGlobalMapLocked();
78                 }
79             }
80         }
81         return sGlobalMap;
82     }
83 
84     /**
85      * Looks up the provided map to find a class for {@code schemaName} that is assignable to
86      * {@code documentClass}. Returns null if such class is not found.
87      */
88     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
getAssignableClassBySchemaName( @onNull Map<String, List<String>> map, @NonNull String schemaName, @NonNull Class<T> documentClass)89     public static <T> @Nullable Class<? extends T> getAssignableClassBySchemaName(
90             @NonNull Map<String, List<String>> map, @NonNull String schemaName,
91             @NonNull Class<T> documentClass) {
92         List<String> classNames = map.get(schemaName);
93         if (classNames == null) {
94             return null;
95         }
96         // If there are multiple classes that correspond to the schema name, then we will:
97         // 1. skip any classes that are not assignable to documentClass.
98         // 2. if there are still multiple candidates, return the first one in the global map.
99         for (int i = 0; i < classNames.size(); ++i) {
100             String className = classNames.get(i);
101             try {
102                 Class<?> clazz = getAppSearchDocumentClass(className);
103                 if (documentClass.isAssignableFrom(clazz)) {
104                     return clazz.asSubclass(documentClass);
105                 }
106             } catch (ClassNotFoundException e) {
107                 Log.w(TAG, "Failed to load document class \"" + className + "\". Perhaps the "
108                         + "class was proguarded out?");
109             }
110         }
111         return null;
112     }
113 
114     /**
115      * Returns the map from schema type names to the list of the fully qualified names of the
116      * corresponding document classes.
117      */
getMap()118     protected abstract @NonNull Map<String, List<String>> getMap();
119 
getAppSearchDocumentClass(@onNull String className)120     private static @NonNull Class<?> getAppSearchDocumentClass(@NonNull String className)
121             throws ClassNotFoundException {
122         Class<?> result;
123         synchronized (sLock) {
124             result = sCachedAppSearchClasses.get(className);
125         }
126         if (result == null) {
127             result = Class.forName(className);
128             synchronized (sLock) {
129                 sCachedAppSearchClasses.put(className, result);
130             }
131         }
132         return result;
133     }
134 
135     /**
136      * Collects all of the instances of the generated {@link AppSearchDocumentClassMap} classes
137      * available in the current JVM environment, and calls the {@link #getMap()} method from them to
138      * build and return the merged map. The keys are schema type names, and the values are the
139      * lists of the corresponding document classes.
140      */
141     @GuardedBy("AppSearchDocumentClassMap.sLock")
buildGlobalMapLocked()142     private static @NonNull Map<String, List<String>> buildGlobalMapLocked() {
143         ServiceLoader<AppSearchDocumentClassMap> loader = ServiceLoader.load(
144                 AppSearchDocumentClassMap.class, AppSearchDocumentClassMap.class.getClassLoader());
145         Map<String, List<String>> result = new ArrayMap<>();
146         for (AppSearchDocumentClassMap appSearchDocumentClassMap : loader) {
147             Map<String, List<String>> documentClassMap = appSearchDocumentClassMap.getMap();
148             for (Map.Entry<String, List<String>> entry : documentClassMap.entrySet()) {
149                 String schemaName = entry.getKey();
150                 // A single schema name can be mapped to more than one document classes because
151                 // document classes can choose to have arbitrary schema names. The most common
152                 // case is when there are multiple AppSearch packages that define the same schema
153                 // name. It is necessary to keep track all of the mapped document classes to prevent
154                 // from losing any information.
155                 List<String> documentClassNames = result.get(schemaName);
156                 if (documentClassNames == null) {
157                     documentClassNames = new ArrayList<>();
158                     result.put(schemaName, documentClassNames);
159                 }
160                 documentClassNames.addAll(entry.getValue());
161             }
162         }
163 
164         for (String schemaName : result.keySet()) {
165             result.put(schemaName,
166                     Collections.unmodifiableList(Objects.requireNonNull(result.get(schemaName))));
167         }
168         return Collections.unmodifiableMap(result);
169     }
170 }
171