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