1 /*
2  * Copyright (C) 2021 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 android.app.appsearch;
18 
19 import static android.app.appsearch.AppSearchResult.RESULT_INVALID_SCHEMA;
20 import static android.os.ParcelFileDescriptor.MODE_READ_ONLY;
21 import static android.os.ParcelFileDescriptor.MODE_WRITE_ONLY;
22 
23 import android.annotation.NonNull;
24 import android.annotation.Nullable;
25 import android.annotation.WorkerThread;
26 import android.app.appsearch.SetSchemaResponse.MigrationFailure;
27 import android.app.appsearch.aidl.AppSearchAttributionSource;
28 import android.app.appsearch.aidl.AppSearchResultParcel;
29 import android.app.appsearch.aidl.IAppSearchManager;
30 import android.app.appsearch.aidl.IAppSearchResultCallback;
31 import android.app.appsearch.aidl.PutDocumentsFromFileAidlRequest;
32 import android.app.appsearch.aidl.WriteSearchResultsToFileAidlRequest;
33 import android.app.appsearch.exceptions.AppSearchException;
34 import android.app.appsearch.safeparcel.GenericDocumentParcel;
35 import android.app.appsearch.stats.SchemaMigrationStats;
36 import android.app.appsearch.util.ExceptionUtil;
37 import android.os.Parcel;
38 import android.os.ParcelFileDescriptor;
39 import android.os.RemoteException;
40 import android.os.SystemClock;
41 import android.os.UserHandle;
42 import android.util.ArraySet;
43 
44 import java.io.Closeable;
45 import java.io.DataInputStream;
46 import java.io.DataOutputStream;
47 import java.io.EOFException;
48 import java.io.File;
49 import java.io.FileInputStream;
50 import java.io.FileOutputStream;
51 import java.io.IOException;
52 import java.util.List;
53 import java.util.Objects;
54 import java.util.Set;
55 import java.util.concurrent.CountDownLatch;
56 import java.util.concurrent.ExecutionException;
57 import java.util.concurrent.atomic.AtomicReference;
58 
59 /**
60  * The helper class for {@link AppSearchSchema} migration.
61  *
62  * <p>It will query and migrate {@link GenericDocument} in given type to a new version.
63  * Application-specific cache directory is used to store the temporary files created during
64  * migration.
65  *
66  * @hide
67  */
68 public class AppSearchMigrationHelper implements Closeable {
69     private final IAppSearchManager mService;
70     private final AppSearchAttributionSource mCallerAttributionSource;
71     private final String mDatabaseName;
72     private final UserHandle mUserHandle;
73     @Nullable private final File mTempDirectoryForSchemaMigration;
74     private final File mMigratedFile;
75     private final Set<String> mDestinationTypes;
76     private int mTotalNeedMigratedDocumentCount = 0;
77 
78     /**
79      * Initializes an AppSearchMigrationHelper instance.
80      *
81      * @param service The {@link IAppSearchManager} service from which to make api calls.
82      * @param userHandle The user for which the session should be created.
83      * @param callerAttributionSource The attribution source containing the caller's package name
84      *     and uid
85      * @param databaseName The name of the database where this schema lives.
86      * @param newSchemas The set of new schemas to update existing schemas.
87      * @param tempDirectoryForSchemaMigration The directory to create temporary files needed for
88      *     migration. If this is null, the default temporary-file directory (/data/local/tmp) will
89      *     be used.
90      * @throws IOException on failure to create a temporary file.
91      */
AppSearchMigrationHelper( @onNull IAppSearchManager service, @NonNull UserHandle userHandle, @NonNull AppSearchAttributionSource callerAttributionSource, @NonNull String databaseName, @NonNull Set<AppSearchSchema> newSchemas, @Nullable File tempDirectoryForSchemaMigration)92     AppSearchMigrationHelper(
93             @NonNull IAppSearchManager service,
94             @NonNull UserHandle userHandle,
95             @NonNull AppSearchAttributionSource callerAttributionSource,
96             @NonNull String databaseName,
97             @NonNull Set<AppSearchSchema> newSchemas,
98             @Nullable File tempDirectoryForSchemaMigration)
99             throws IOException {
100         mService = Objects.requireNonNull(service);
101         mUserHandle = Objects.requireNonNull(userHandle);
102         mCallerAttributionSource = Objects.requireNonNull(callerAttributionSource);
103         mDatabaseName = Objects.requireNonNull(databaseName);
104         mTempDirectoryForSchemaMigration = tempDirectoryForSchemaMigration;
105         mMigratedFile =
106                 File.createTempFile(
107                         /* prefix= */ "appsearch",
108                         /* suffix= */ null,
109                         mTempDirectoryForSchemaMigration);
110         mDestinationTypes = new ArraySet<>(newSchemas.size());
111         for (AppSearchSchema newSchema : newSchemas) {
112             mDestinationTypes.add(newSchema.getSchemaType());
113         }
114     }
115 
116     /**
117      * Queries all documents that need to be migrated to a different version and transform documents
118      * to that version by passing them to the provided {@link Migrator}.
119      *
120      * <p>The method will be executed on the executor provided to {@link
121      * AppSearchSession#setSchema}.
122      *
123      * @param schemaType The schema type that needs to be updated and whose {@link GenericDocument}
124      *     need to be migrated.
125      * @param migrator The {@link Migrator} that will upgrade or downgrade a {@link GenericDocument}
126      *     to new version.
127      * @param schemaMigrationStatsBuilder The {@link SchemaMigrationStats.Builder} contains schema
128      *     migration stats information
129      */
130     @WorkerThread
queryAndTransform( @onNull String schemaType, @NonNull Migrator migrator, int currentVersion, int finalVersion, @Nullable SchemaMigrationStats.Builder schemaMigrationStatsBuilder)131     void queryAndTransform(
132             @NonNull String schemaType,
133             @NonNull Migrator migrator,
134             int currentVersion,
135             int finalVersion,
136             @Nullable SchemaMigrationStats.Builder schemaMigrationStatsBuilder)
137             throws IOException, AppSearchException, InterruptedException, ExecutionException {
138         File queryFile =
139                 File.createTempFile(
140                         /* prefix= */ "appsearch",
141                         /* suffix= */ null,
142                         mTempDirectoryForSchemaMigration);
143         try (ParcelFileDescriptor fileDescriptor =
144                 ParcelFileDescriptor.open(queryFile, MODE_WRITE_ONLY)) {
145             CountDownLatch latch = new CountDownLatch(1);
146             AtomicReference<AppSearchResult<Void>> resultReference = new AtomicReference<>();
147             mService.writeSearchResultsToFile(
148                     new WriteSearchResultsToFileAidlRequest(
149                             mCallerAttributionSource,
150                             mDatabaseName,
151                             fileDescriptor,
152                             /* searchExpression= */ "",
153                             new SearchSpec.Builder()
154                                     .addFilterSchemas(schemaType)
155                                     .setTermMatch(SearchSpec.TERM_MATCH_EXACT_ONLY)
156                                     .build(),
157                             mUserHandle,
158                             /* binderCallStartTimeMillis= */ SystemClock.elapsedRealtime()),
159                     new IAppSearchResultCallback.Stub() {
160                         @Override
161                         public void onResult(AppSearchResultParcel resultParcel) {
162                             resultReference.set(resultParcel.getResult());
163                             latch.countDown();
164                         }
165                     });
166             latch.await();
167             AppSearchResult<Void> result = resultReference.get();
168             if (!result.isSuccess()) {
169                 throw new AppSearchException(result.getResultCode(), result.getErrorMessage());
170             }
171             readAndTransform(
172                     queryFile, migrator, currentVersion, finalVersion, schemaMigrationStatsBuilder);
173         } catch (RemoteException e) {
174             ExceptionUtil.handleRemoteException(e);
175         } finally {
176             queryFile.delete();
177         }
178     }
179 
180     /**
181      * Puts all {@link GenericDocument} migrated from the previous call to {@link
182      * #queryAndTransform} into AppSearch.
183      *
184      * <p>This method should be only called once.
185      *
186      * @param responseBuilder a SetSchemaResponse builder whose result will be returned by this
187      *     function with any {@link android.app.appsearch.SetSchemaResponse.MigrationFailure} added
188      *     in.
189      * @param schemaMigrationStatsBuilder The {@link SchemaMigrationStats.Builder} contains schema
190      *     migration stats information
191      * @param totalLatencyStartTimeMillis start timestamp to calculate total migration latency in
192      *     Millis
193      * @return the {@link SetSchemaResponse} for {@link AppSearchSession#setSchema} call.
194      */
195     @NonNull
putMigratedDocuments( @onNull SetSchemaResponse.Builder responseBuilder, @NonNull SchemaMigrationStats.Builder schemaMigrationStatsBuilder, long totalLatencyStartTimeMillis)196     AppSearchResult<SetSchemaResponse> putMigratedDocuments(
197             @NonNull SetSchemaResponse.Builder responseBuilder,
198             @NonNull SchemaMigrationStats.Builder schemaMigrationStatsBuilder,
199             long totalLatencyStartTimeMillis) {
200         if (mTotalNeedMigratedDocumentCount == 0) {
201             return AppSearchResult.newSuccessfulResult(responseBuilder.build());
202         }
203         try (ParcelFileDescriptor fileDescriptor =
204                 ParcelFileDescriptor.open(mMigratedFile, MODE_READ_ONLY)) {
205             CountDownLatch latch = new CountDownLatch(1);
206             AtomicReference<AppSearchResult<List<MigrationFailure>>> resultReference =
207                     new AtomicReference<>();
208             mService.putDocumentsFromFile(
209                     new PutDocumentsFromFileAidlRequest(
210                             mCallerAttributionSource,
211                             mDatabaseName,
212                             fileDescriptor,
213                             mUserHandle,
214                             schemaMigrationStatsBuilder.build(),
215                             totalLatencyStartTimeMillis,
216                             /* binderCallStartTimeMillis= */ SystemClock.elapsedRealtime()),
217                     new IAppSearchResultCallback.Stub() {
218                         @Override
219                         public void onResult(AppSearchResultParcel resultParcel) {
220                             resultReference.set(resultParcel.getResult());
221                             latch.countDown();
222                         }
223                     });
224             latch.await();
225             AppSearchResult<List<MigrationFailure>> result = resultReference.get();
226             if (!result.isSuccess()) {
227                 return AppSearchResult.newFailedResult(result);
228             }
229             List<MigrationFailure> migrationFailures =
230                     Objects.requireNonNull(result.getResultValue());
231             responseBuilder.addMigrationFailures(migrationFailures);
232         } catch (RemoteException e) {
233             ExceptionUtil.handleRemoteException(e);
234         } catch (InterruptedException | IOException | RuntimeException e) {
235             return AppSearchResult.throwableToFailedResult(e);
236         } finally {
237             mMigratedFile.delete();
238         }
239         return AppSearchResult.newSuccessfulResult(responseBuilder.build());
240     }
241 
242     /**
243      * Reads all saved {@link GenericDocument}s from the given {@link File}.
244      *
245      * <p>Transforms those {@link GenericDocument}s to the final version.
246      *
247      * <p>Save migrated {@link GenericDocument}s to the {@link #mMigratedFile}.
248      */
readAndTransform( @onNull File file, @NonNull Migrator migrator, int currentVersion, int finalVersion, @Nullable SchemaMigrationStats.Builder schemaMigrationStatsBuilder)249     private void readAndTransform(
250             @NonNull File file,
251             @NonNull Migrator migrator,
252             int currentVersion,
253             int finalVersion,
254             @Nullable SchemaMigrationStats.Builder schemaMigrationStatsBuilder)
255             throws IOException, AppSearchException {
256         try (DataInputStream inputStream = new DataInputStream(new FileInputStream(file));
257                 DataOutputStream outputStream =
258                         new DataOutputStream(
259                                 new FileOutputStream(mMigratedFile, /* append= */ true))) {
260             GenericDocument document;
261             while (true) {
262                 try {
263                     document = readDocumentFromInputStream(inputStream);
264                 } catch (EOFException e) {
265                     break;
266                     // Nothing wrong. We just finished reading.
267                 }
268 
269                 GenericDocument newDocument;
270                 if (currentVersion < finalVersion) {
271                     newDocument = migrator.onUpgrade(currentVersion, finalVersion, document);
272                 } else {
273                     // currentVersion == finalVersion case won't trigger migration and get here.
274                     newDocument = migrator.onDowngrade(currentVersion, finalVersion, document);
275                 }
276                 ++mTotalNeedMigratedDocumentCount;
277 
278                 if (!mDestinationTypes.contains(newDocument.getSchemaType())) {
279                     // we exit before the new schema has been set to AppSearch. So no
280                     // observable changes will be applied to stored schemas and documents.
281                     // And the temp file will be deleted at close(), which will be triggered at
282                     // the end of try-with-resources block of SearchSessionImpl.
283                     throw new AppSearchException(
284                             RESULT_INVALID_SCHEMA,
285                             "Receive a migrated document with schema type: "
286                                     + newDocument.getSchemaType()
287                                     + ". But the schema types doesn't exist in the request");
288                 }
289                 writeDocumentToOutputStream(outputStream, newDocument);
290             }
291         }
292         if (schemaMigrationStatsBuilder != null) {
293             schemaMigrationStatsBuilder.setTotalNeedMigratedDocumentCount(
294                     mTotalNeedMigratedDocumentCount);
295         }
296     }
297 
298     /**
299      * Reads a {@link GenericDocument} from given {@link DataInputStream}.
300      *
301      * @param inputStream The inputStream to read from
302      * @throws IOException on read failure.
303      * @throws EOFException if {@link java.io.InputStream} reaches the end.
304      */
305     @NonNull
readDocumentFromInputStream(@onNull DataInputStream inputStream)306     public static GenericDocument readDocumentFromInputStream(@NonNull DataInputStream inputStream)
307             throws IOException {
308         int length = inputStream.readInt();
309         if (length == 0) {
310             throw new EOFException();
311         }
312         byte[] serializedMessage = new byte[length];
313         inputStream.read(serializedMessage);
314 
315         Parcel parcel = Parcel.obtain();
316         try {
317             parcel.unmarshall(serializedMessage, 0, serializedMessage.length);
318             parcel.setDataPosition(0);
319             GenericDocumentParcel genericDocumentParcel =
320                     GenericDocumentParcel.CREATOR.createFromParcel(parcel);
321             return new GenericDocument(genericDocumentParcel);
322         } finally {
323             parcel.recycle();
324         }
325     }
326 
327     /** Serializes a {@link GenericDocument} and writes into the given {@link DataOutputStream}. */
writeDocumentToOutputStream( @onNull DataOutputStream outputStream, @NonNull GenericDocument document)328     public static void writeDocumentToOutputStream(
329             @NonNull DataOutputStream outputStream, @NonNull GenericDocument document)
330             throws IOException {
331         GenericDocumentParcel documentParcel = document.getDocumentParcel();
332         Parcel parcel = Parcel.obtain();
333         try {
334             documentParcel.writeToParcel(parcel, /* flags= */ 0);
335             byte[] serializedMessage = parcel.marshall();
336             outputStream.writeInt(serializedMessage.length);
337             outputStream.write(serializedMessage);
338         } finally {
339             parcel.recycle();
340         }
341     }
342 
343     @Override
close()344     public void close() throws IOException {
345         mMigratedFile.delete();
346     }
347 }
348