/* * Copyright (C) 2021 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package android.app.appsearch; import static android.app.appsearch.AppSearchResult.RESULT_INVALID_SCHEMA; import static android.os.ParcelFileDescriptor.MODE_READ_ONLY; import static android.os.ParcelFileDescriptor.MODE_WRITE_ONLY; import android.annotation.NonNull; import android.annotation.WorkerThread; import android.app.appsearch.aidl.AppSearchResultParcel; import android.app.appsearch.aidl.IAppSearchManager; import android.app.appsearch.aidl.IAppSearchResultCallback; import android.app.appsearch.exceptions.AppSearchException; import android.content.AttributionSource; import android.os.Bundle; import android.os.Parcel; import android.os.ParcelFileDescriptor; import android.os.RemoteException; import android.os.UserHandle; import android.util.ArraySet; import java.io.Closeable; import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.EOFException; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.util.List; import java.util.Objects; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; /** * The helper class for {@link AppSearchSchema} migration. * *

It will query and migrate {@link GenericDocument} in given type to a new version. * @hide */ public class AppSearchMigrationHelper implements Closeable { private final IAppSearchManager mService; private final AttributionSource mCallerAttributionSource; private final String mDatabaseName; private final UserHandle mUserHandle; private final File mMigratedFile; private final Set mDestinationTypes; private boolean mAreDocumentsMigrated = false; AppSearchMigrationHelper(@NonNull IAppSearchManager service, @NonNull UserHandle userHandle, @NonNull AttributionSource callerAttributionSource, @NonNull String databaseName, @NonNull Set newSchemas) throws IOException { mService = Objects.requireNonNull(service); mUserHandle = Objects.requireNonNull(userHandle); mCallerAttributionSource = Objects.requireNonNull(callerAttributionSource); mDatabaseName = Objects.requireNonNull(databaseName); mMigratedFile = File.createTempFile(/*prefix=*/"appsearch", /*suffix=*/null); mDestinationTypes = new ArraySet<>(newSchemas.size()); for (AppSearchSchema newSchema : newSchemas) { mDestinationTypes.add(newSchema.getSchemaType()); } } /** * Queries all documents that need to be migrated to a different version and transform * documents to that version by passing them to the provided {@link Migrator}. * *

The method will be executed on the executor provided to * {@link AppSearchSession#setSchema}. * * @param schemaType The schema type that needs to be updated and whose {@link GenericDocument} * need to be migrated. * @param migrator The {@link Migrator} that will upgrade or downgrade a {@link * GenericDocument} to new version. */ @WorkerThread public void queryAndTransform(@NonNull String schemaType, @NonNull Migrator migrator, int currentVersion, int finalVersion) throws IOException, AppSearchException, InterruptedException, ExecutionException { File queryFile = File.createTempFile(/*prefix=*/"appsearch", /*suffix=*/null); try (ParcelFileDescriptor fileDescriptor = ParcelFileDescriptor.open(queryFile, MODE_WRITE_ONLY)) { CompletableFuture> future = new CompletableFuture<>(); mService.writeQueryResultsToFile(mCallerAttributionSource, mDatabaseName, fileDescriptor, /*queryExpression=*/ "", new SearchSpec.Builder() .addFilterSchemas(schemaType) .setTermMatch(SearchSpec.TERM_MATCH_EXACT_ONLY) .build().getBundle(), mUserHandle, new IAppSearchResultCallback.Stub() { @Override public void onResult(AppSearchResultParcel resultParcel) { future.complete(resultParcel.getResult()); } }); AppSearchResult result = future.get(); if (!result.isSuccess()) { throw new AppSearchException(result.getResultCode(), result.getErrorMessage()); } readAndTransform(queryFile, migrator, currentVersion, finalVersion); } catch (RemoteException e) { throw e.rethrowFromSystemServer(); } finally { queryFile.delete(); } } /** * Puts all {@link GenericDocument} migrated from the previous call to * {@link #queryAndTransform} into AppSearch. * *

This method should be only called once. * * @param responseBuilder a SetSchemaResponse builder whose result will be returned by this * function with any * {@link android.app.appsearch.SetSchemaResponse.MigrationFailure} * added in. * @return the {@link SetSchemaResponse} for {@link AppSearchSession#setSchema} call. */ @NonNull AppSearchResult putMigratedDocuments( @NonNull SetSchemaResponse.Builder responseBuilder) { if (!mAreDocumentsMigrated) { return AppSearchResult.newSuccessfulResult(responseBuilder.build()); } try (ParcelFileDescriptor fileDescriptor = ParcelFileDescriptor.open(mMigratedFile, MODE_READ_ONLY)) { CompletableFuture>> future = new CompletableFuture<>(); mService.putDocumentsFromFile(mCallerAttributionSource, mDatabaseName, fileDescriptor, mUserHandle, new IAppSearchResultCallback.Stub() { @Override public void onResult(AppSearchResultParcel resultParcel) { future.complete(resultParcel.getResult()); } }); AppSearchResult> result = future.get(); if (!result.isSuccess()) { return AppSearchResult.newFailedResult(result); } List migratedFailureBundles = result.getResultValue(); for (int i = 0; i < migratedFailureBundles.size(); i++) { responseBuilder.addMigrationFailure( new SetSchemaResponse.MigrationFailure(migratedFailureBundles.get(i))); } } catch (RemoteException e) { throw e.rethrowFromSystemServer(); } catch (Throwable t) { return AppSearchResult.throwableToFailedResult(t); } finally { mMigratedFile.delete(); } return AppSearchResult.newSuccessfulResult(responseBuilder.build()); } /** * Reads all saved {@link GenericDocument}s from the given {@link File}. * *

Transforms those {@link GenericDocument}s to the final version. * *

Save migrated {@link GenericDocument}s to the {@link #mMigratedFile}. */ private void readAndTransform(@NonNull File file, @NonNull Migrator migrator, int currentVersion, int finalVersion) throws IOException, AppSearchException { try (DataInputStream inputStream = new DataInputStream(new FileInputStream(file)); DataOutputStream outputStream = new DataOutputStream(new FileOutputStream( mMigratedFile, /*append=*/ true))) { GenericDocument document; while (true) { try { document = readDocumentFromInputStream(inputStream); } catch (EOFException e) { break; // Nothing wrong. We just finished reading. } GenericDocument newDocument; if (currentVersion < finalVersion) { newDocument = migrator.onUpgrade(currentVersion, finalVersion, document); } else { // currentVersion == finalVersion case won't trigger migration and get here. newDocument = migrator.onDowngrade(currentVersion, finalVersion, document); } if (!mDestinationTypes.contains(newDocument.getSchemaType())) { // we exit before the new schema has been set to AppSearch. So no // observable changes will be applied to stored schemas and documents. // And the temp file will be deleted at close(), which will be triggered at // the end of try-with-resources block of SearchSessionImpl. throw new AppSearchException( RESULT_INVALID_SCHEMA, "Receive a migrated document with schema type: " + newDocument.getSchemaType() + ". But the schema types doesn't exist in the request"); } writeBundleToOutputStream(outputStream, newDocument.getBundle()); } mAreDocumentsMigrated = true; } } /** * Reads the {@link Bundle} of a {@link GenericDocument} from given {@link DataInputStream}. * * @param inputStream The inputStream to read from * * @throws IOException on read failure. * @throws EOFException if {@link java.io.InputStream} reaches the end. */ @NonNull public static GenericDocument readDocumentFromInputStream( @NonNull DataInputStream inputStream) throws IOException { int length = inputStream.readInt(); if (length == 0) { throw new EOFException(); } byte[] serializedMessage = new byte[length]; inputStream.read(serializedMessage); Parcel parcel = Parcel.obtain(); try { parcel.unmarshall(serializedMessage, 0, serializedMessage.length); parcel.setDataPosition(0); Bundle bundle = parcel.readBundle(); return new GenericDocument(bundle); } finally { parcel.recycle(); } } /** * Serializes a {@link Bundle} and writes into the given {@link DataOutputStream}. */ public static void writeBundleToOutputStream( @NonNull DataOutputStream outputStream, @NonNull Bundle bundle) throws IOException { Parcel parcel = Parcel.obtain(); try { parcel.writeBundle(bundle); byte[] serializedMessage = parcel.marshall(); outputStream.writeInt(serializedMessage.length); outputStream.write(serializedMessage); } finally { parcel.recycle(); } } @Override public void close() throws IOException { mMigratedFile.delete(); } }