1 /*
2  * Copyright (C) 2024 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 
17 package com.android.server.appsearch.appsindexer;
18 
19 import android.annotation.NonNull;
20 import android.app.appsearch.AppSearchBatchResult;
21 import android.app.appsearch.AppSearchEnvironmentFactory;
22 import android.app.appsearch.AppSearchManager;
23 import android.app.appsearch.AppSearchResult;
24 import android.app.appsearch.AppSearchSchema;
25 import android.app.appsearch.BatchResultCallback;
26 import android.app.appsearch.PackageIdentifier;
27 import android.app.appsearch.PutDocumentsRequest;
28 import android.app.appsearch.SearchResult;
29 import android.app.appsearch.SearchSpec;
30 import android.app.appsearch.SetSchemaRequest;
31 import android.app.appsearch.exceptions.AppSearchException;
32 import android.content.Context;
33 import android.util.AndroidRuntimeException;
34 import android.util.ArrayMap;
35 import android.util.Log;
36 
37 import com.android.internal.annotations.VisibleForTesting;
38 import com.android.server.appsearch.appsindexer.appsearchtypes.MobileApplication;
39 
40 import java.io.Closeable;
41 import java.util.Collections;
42 import java.util.List;
43 import java.util.Map;
44 import java.util.Objects;
45 import java.util.concurrent.ExecutorService;
46 
47 /**
48  * Helper class to manage the App corpus in AppSearch.
49  *
50  * <p>There are two primary methods in this class, {@link #setSchemasForPackages} and {@link
51  * #indexApps}. On a given Apps Index update, they may not necessarily both be called. For instance,
52  * if the indexer determines that the only change is that an app was deleted, there is no reason to
53  * insert any * apps, so we can save time by only calling setSchemas to erase the deleted app
54  * schema. On the other hand, if the only change is that an app was update, there is no reason to
55  * call setSchema. We can instead just update the updated app with a call to indexApps. Figuring out
56  * what needs to be done is left to {@link AppsIndexerImpl}.
57  *
58  * <p>This class is thread-safe.
59  *
60  * @hide
61  */
62 public class AppSearchHelper implements Closeable {
63     private static final String TAG = "AppSearchAppsIndexerAppSearchHelper";
64 
65     // The apps indexer uses one database, and in that database we have one schema for every app
66     // that is indexed. The reason for this is that we keep the schema types the same for every app
67     // (MobileApplication), but we need different visibility settings for each app. These different
68     // visibility settings are set with Public ACL and rely on PackageManager#canPackageQuery.
69     // Therefore each application needs its own schema. We put all these schema into a single
70     // database by dynamically renaming the schema so that they have different names.
71     public static final String APP_DATABASE = "apps-db";
72     private static final int GET_APP_IDS_PAGE_SIZE = 1000;
73     private final Context mContext;
74     private final ExecutorService mExecutor;
75     private final AppSearchManager mAppSearchManager;
76     private SyncAppSearchSession mSyncAppSearchSession;
77     private SyncGlobalSearchSession mSyncGlobalSearchSession;
78 
79     /** Creates and initializes an {@link AppSearchHelper} */
80     @NonNull
createAppSearchHelper(@onNull Context context)81     public static AppSearchHelper createAppSearchHelper(@NonNull Context context)
82             throws AppSearchException {
83         Objects.requireNonNull(context);
84 
85         AppSearchHelper appSearchHelper = new AppSearchHelper(context);
86         appSearchHelper.initializeAppSearchSessions();
87         return appSearchHelper;
88     }
89 
90     /** Creates an initialized {@link AppSearchHelper}. */
91     @VisibleForTesting
AppSearchHelper(@onNull Context context)92     private AppSearchHelper(@NonNull Context context) {
93         mContext = Objects.requireNonNull(context);
94 
95         mAppSearchManager = context.getSystemService(AppSearchManager.class);
96         if (mAppSearchManager == null) {
97             throw new AndroidRuntimeException(
98                     "Can't get AppSearchManager to initialize AppSearchHelper.");
99         }
100         mExecutor =
101                 AppSearchEnvironmentFactory.getEnvironmentInstance().createSingleThreadExecutor();
102     }
103 
104     /**
105      * Sets up the search session.
106      *
107      * @throws AppSearchException if unable to initialize the {@link SyncAppSearchSession} or the
108      *     {@link SyncGlobalSearchSession}.
109      */
initializeAppSearchSessions()110     private void initializeAppSearchSessions() throws AppSearchException {
111         AppSearchManager.SearchContext searchContext =
112                 new AppSearchManager.SearchContext.Builder(APP_DATABASE).build();
113         mSyncAppSearchSession =
114                 new SyncAppSearchSessionImpl(mAppSearchManager, searchContext, mExecutor);
115         mSyncGlobalSearchSession = new SyncGlobalSearchSessionImpl(mAppSearchManager, mExecutor);
116     }
117 
118     /** Just for testing, allows us to test various scenarios involving SyncAppSearchSession. */
119     @VisibleForTesting
setAppSearchSession(@onNull SyncAppSearchSession session)120     /* package */ void setAppSearchSession(@NonNull SyncAppSearchSession session) {
121         // Close the old one
122         mSyncAppSearchSession.close();
123         mSyncAppSearchSession = Objects.requireNonNull(session);
124     }
125 
126     /**
127      * Sets the AppsIndexer database schema to correspond to the list of passed in {@link
128      * PackageIdentifier}s. Note that this means if a schema exists in AppSearch that does not get
129      * passed in to this method, it will be erased. And if a schema does not exist in AppSearch that
130      * is passed in to this method, it will be created.
131      */
setSchemasForPackages(@onNull List<PackageIdentifier> pkgs)132     public void setSchemasForPackages(@NonNull List<PackageIdentifier> pkgs)
133             throws AppSearchException {
134         Objects.requireNonNull(pkgs);
135         SetSchemaRequest.Builder schemaBuilder =
136                 new SetSchemaRequest.Builder()
137                         // If MobileApplication schema later gets changed to a compatible schema, we
138                         // should first try setting the schema with forceOverride = false.
139                         .setForceOverride(true);
140         for (int i = 0; i < pkgs.size(); i++) {
141             PackageIdentifier pkg = pkgs.get(i);
142             // As all apps are in the same db, we have to make sure that even if it's getting
143             // updated, the schema is in the list of schemas
144             String packageName = pkg.getPackageName();
145             AppSearchSchema schemaVariant =
146                     MobileApplication.createMobileApplicationSchemaForPackage(packageName);
147             schemaBuilder.addSchemas(schemaVariant);
148             // Since the Android package of the underlying apps are different from the package name
149             // that "owns" the builtin:MobileApplication corpus in AppSearch, we needed to add the
150             // PackageIdentifier parameter to setPubliclyVisibleSchema.
151             schemaBuilder.setPubliclyVisibleSchema(schemaVariant.getSchemaType(), pkg);
152         }
153 
154         // TODO(b/275592563): Log app removal in metrics
155         mSyncAppSearchSession.setSchema(schemaBuilder.build());
156     }
157 
158     /**
159      * Indexes a collection of apps into AppSearch. This requires that the corresponding
160      * MobileApplication schemas are already set by a previous call to {@link
161      * #setSchemasForPackages}. The call doesn't necessarily have to happen in the current sync.
162      *
163      * @throws AppSearchException if indexing results in a {@link
164      *     AppSearchResult#RESULT_OUT_OF_SPACE} result code. It will also throw this if the put call
165      *     results in a system error as in {@link BatchResultCallback#onSystemError}. This may
166      *     happen if the AppSearch service unexpectedly fails to initialize and can't be recovered,
167      *     for instance.
168      */
indexApps(@onNull List<MobileApplication> apps)169     public void indexApps(@NonNull List<MobileApplication> apps) throws AppSearchException {
170         Objects.requireNonNull(apps);
171 
172         // At this point, the document schema names have already been set to the per-package name.
173         // We can just add them to the request.
174         PutDocumentsRequest request =
175                 new PutDocumentsRequest.Builder().addGenericDocuments(apps).build();
176 
177         AppSearchBatchResult<String, Void> result = mSyncAppSearchSession.put(request);
178         if (!result.isSuccess()) {
179             Map<String, AppSearchResult<Void>> failures = result.getFailures();
180             for (AppSearchResult<Void> failure : failures.values()) {
181                 // If it's out of space, stop indexing
182                 if (failure.getResultCode() == AppSearchResult.RESULT_OUT_OF_SPACE) {
183                     throw new AppSearchException(
184                             failure.getResultCode(), failure.getErrorMessage());
185                 } else {
186                     Log.e(TAG, "Ran into error while indexing apps: " + failure);
187                 }
188             }
189         }
190     }
191 
192     /**
193      * Searches AppSearch and returns a Map with the package ids and their last updated times. This
194      * helps us determine which app documents need to be re-indexed.
195      */
196     @NonNull
getAppsFromAppSearch()197     public Map<String, Long> getAppsFromAppSearch() {
198         SearchSpec allAppsSpec =
199                 new SearchSpec.Builder()
200                         .addFilterNamespaces(MobileApplication.APPS_NAMESPACE)
201                         .addProjection(
202                                 SearchSpec.SCHEMA_TYPE_WILDCARD,
203                                 Collections.singletonList(
204                                         MobileApplication.APP_PROPERTY_UPDATED_TIMESTAMP))
205                         .addFilterPackageNames(mContext.getPackageName())
206                         .setResultCountPerPage(GET_APP_IDS_PAGE_SIZE)
207                         .build();
208         SyncSearchResults results = mSyncGlobalSearchSession.search(/* query= */ "", allAppsSpec);
209         return collectUpdatedTimestampFromAllPages(results);
210     }
211 
212     /** Iterates through result pages to get the last updated times */
213     @NonNull
collectUpdatedTimestampFromAllPages( @onNull SyncSearchResults results)214     private Map<String, Long> collectUpdatedTimestampFromAllPages(
215             @NonNull SyncSearchResults results) {
216         Objects.requireNonNull(results);
217         Map<String, Long> appUpdatedMap = new ArrayMap<>();
218 
219         try {
220             List<SearchResult> resultList = results.getNextPage();
221 
222             while (!resultList.isEmpty()) {
223                 for (int i = 0; i < resultList.size(); i++) {
224                     SearchResult result = resultList.get(i);
225                     appUpdatedMap.put(
226                             result.getGenericDocument().getId(),
227                             result.getGenericDocument()
228                                     .getPropertyLong(
229                                             MobileApplication.APP_PROPERTY_UPDATED_TIMESTAMP));
230                 }
231 
232                 resultList = results.getNextPage();
233             }
234         } catch (AppSearchException e) {
235             Log.e(TAG, "Error while searching for all app documents", e);
236         }
237         // Return what we have so far. Even if this doesn't fetch all documents, that is fine as we
238         // can continue with indexing. The documents that aren't fetched will be detected as new
239         // apps and re-indexed.
240         return appUpdatedMap;
241     }
242 
243     /** Closes the AppSearch sessions. */
244     @Override
close()245     public void close() {
246         mSyncAppSearchSession.close();
247         mSyncGlobalSearchSession.close();
248     }
249 }
250