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.healthconnect.exportimport; 18 19 import static android.health.connect.exportimport.ImportStatus.DATA_IMPORT_ERROR_NONE; 20 import static android.health.connect.exportimport.ImportStatus.DATA_IMPORT_ERROR_UNKNOWN; 21 import static android.health.connect.exportimport.ImportStatus.DATA_IMPORT_ERROR_VERSION_MISMATCH; 22 import static android.health.connect.exportimport.ImportStatus.DATA_IMPORT_ERROR_WRONG_FILE; 23 import static android.health.connect.exportimport.ImportStatus.DATA_IMPORT_STARTED; 24 25 import static com.android.server.healthconnect.exportimport.ExportImportNotificationSender.NOTIFICATION_TYPE_IMPORT_COMPLETE; 26 import static com.android.server.healthconnect.exportimport.ExportImportNotificationSender.NOTIFICATION_TYPE_IMPORT_IN_PROGRESS; 27 import static com.android.server.healthconnect.exportimport.ExportImportNotificationSender.NOTIFICATION_TYPE_IMPORT_UNSUCCESSFUL_GENERIC_ERROR; 28 import static com.android.server.healthconnect.exportimport.ExportImportNotificationSender.NOTIFICATION_TYPE_IMPORT_UNSUCCESSFUL_INVALID_FILE; 29 import static com.android.server.healthconnect.exportimport.ExportImportNotificationSender.NOTIFICATION_TYPE_IMPORT_UNSUCCESSFUL_VERSION_MISMATCH; 30 import static com.android.server.healthconnect.exportimport.ExportManager.LOCAL_EXPORT_DATABASE_FILE_NAME; 31 32 import android.annotation.Nullable; 33 import android.content.ContentResolver; 34 import android.content.Context; 35 import android.database.Cursor; 36 import android.database.sqlite.SQLiteDatabase; 37 import android.database.sqlite.SQLiteException; 38 import android.net.Uri; 39 import android.os.UserHandle; 40 import android.provider.OpenableColumns; 41 import android.util.Slog; 42 43 import com.android.healthfitness.flags.Flags; 44 import com.android.internal.annotations.VisibleForTesting; 45 import com.android.server.healthconnect.fitness.FitnessRecordReadHelper; 46 import com.android.server.healthconnect.fitness.FitnessRecordUpsertHelper; 47 import com.android.server.healthconnect.logging.ExportImportLogger; 48 import com.android.server.healthconnect.notifications.HealthConnectNotificationSender; 49 import com.android.server.healthconnect.storage.ExportImportSettingsStorage; 50 import com.android.server.healthconnect.storage.HealthConnectContext; 51 import com.android.server.healthconnect.storage.HealthConnectDatabase; 52 import com.android.server.healthconnect.storage.TransactionManager; 53 import com.android.server.healthconnect.storage.datatypehelpers.AppInfoHelper; 54 import com.android.server.healthconnect.storage.datatypehelpers.DeviceInfoHelper; 55 import com.android.server.healthconnect.storage.datatypehelpers.HealthDataCategoryPriorityHelper; 56 57 import java.io.File; 58 import java.io.FileNotFoundException; 59 import java.io.IOException; 60 import java.time.Clock; 61 import java.util.zip.ZipException; 62 63 /** 64 * Manages import related tasks. 65 * 66 * @hide 67 */ 68 public class ImportManager { 69 70 @VisibleForTesting static final String IMPORT_DATABASE_DIR_NAME = "export_import"; 71 72 @VisibleForTesting static final String IMPORT_DATABASE_FILE_NAME = "health_connect_import.db"; 73 74 private static final String TAG = "HealthConnectImportManager"; 75 76 private final Context mContext; 77 private final DatabaseMerger mDatabaseMerger; 78 private final TransactionManager mTransactionManager; 79 private final HealthConnectNotificationSender mNotificationSender; 80 private final ExportImportSettingsStorage mExportImportSettingsStorage; 81 private final File mEnvironmentDataDirectory; 82 private final ExportImportLogger mExportImportLogger; 83 @Nullable private final Clock mClock; 84 private final Compressor mCompressor; 85 ImportManager( AppInfoHelper appInfoHelper, Context context, ExportImportSettingsStorage exportImportSettingsStorage, TransactionManager transactionManager, FitnessRecordUpsertHelper fitnessRecordUpsertHelper, FitnessRecordReadHelper fitnessRecordReadHelper, DeviceInfoHelper deviceInfoHelper, HealthDataCategoryPriorityHelper healthDataCategoryPriorityHelper, @Nullable Clock clock, HealthConnectNotificationSender notificationSender, File environmentDataDirectory, ExportImportLogger exportImportLogger)86 public ImportManager( 87 AppInfoHelper appInfoHelper, 88 Context context, 89 ExportImportSettingsStorage exportImportSettingsStorage, 90 TransactionManager transactionManager, 91 FitnessRecordUpsertHelper fitnessRecordUpsertHelper, 92 FitnessRecordReadHelper fitnessRecordReadHelper, 93 DeviceInfoHelper deviceInfoHelper, 94 HealthDataCategoryPriorityHelper healthDataCategoryPriorityHelper, 95 @Nullable Clock clock, 96 HealthConnectNotificationSender notificationSender, 97 File environmentDataDirectory, 98 ExportImportLogger exportImportLogger) { 99 this( 100 appInfoHelper, 101 context, 102 exportImportSettingsStorage, 103 transactionManager, 104 fitnessRecordUpsertHelper, 105 fitnessRecordReadHelper, 106 deviceInfoHelper, 107 healthDataCategoryPriorityHelper, 108 clock, 109 notificationSender, 110 environmentDataDirectory, 111 exportImportLogger, 112 new Compressor()); 113 } 114 115 @VisibleForTesting ImportManager( AppInfoHelper appInfoHelper, Context context, ExportImportSettingsStorage exportImportSettingsStorage, TransactionManager transactionManager, FitnessRecordUpsertHelper fitnessRecordUpsertHelper, FitnessRecordReadHelper fitnessRecordReadHelper, DeviceInfoHelper deviceInfoHelper, HealthDataCategoryPriorityHelper healthDataCategoryPriorityHelper, @Nullable Clock clock, HealthConnectNotificationSender notificationSender, File environmentDataDirectory, ExportImportLogger exportImportLogger, Compressor compressor)116 ImportManager( 117 AppInfoHelper appInfoHelper, 118 Context context, 119 ExportImportSettingsStorage exportImportSettingsStorage, 120 TransactionManager transactionManager, 121 FitnessRecordUpsertHelper fitnessRecordUpsertHelper, 122 FitnessRecordReadHelper fitnessRecordReadHelper, 123 DeviceInfoHelper deviceInfoHelper, 124 HealthDataCategoryPriorityHelper healthDataCategoryPriorityHelper, 125 @Nullable Clock clock, 126 HealthConnectNotificationSender notificationSender, 127 File environmentDataDirectory, 128 ExportImportLogger exportImportLogger, 129 Compressor compressor) { 130 mContext = context; 131 mDatabaseMerger = 132 new DatabaseMerger( 133 appInfoHelper, 134 deviceInfoHelper, 135 healthDataCategoryPriorityHelper, 136 transactionManager, 137 fitnessRecordUpsertHelper, 138 fitnessRecordReadHelper); 139 mTransactionManager = transactionManager; 140 mExportImportSettingsStorage = exportImportSettingsStorage; 141 mClock = clock; 142 mNotificationSender = notificationSender; 143 mEnvironmentDataDirectory = environmentDataDirectory; 144 mExportImportLogger = exportImportLogger; 145 mCompressor = compressor; 146 } 147 148 /** Reads and merges the backup data from a local file. */ runImport(UserHandle userHandle, Uri uri)149 public synchronized void runImport(UserHandle userHandle, Uri uri) { 150 Slog.i(TAG, "Import started."); 151 long startTimeMillis = mClock != null ? mClock.millis() : -1; 152 mExportImportSettingsStorage.setImportState(DATA_IMPORT_STARTED); 153 mNotificationSender.sendNotificationAsUser( 154 NOTIFICATION_TYPE_IMPORT_IN_PROGRESS, userHandle); 155 156 mExportImportLogger.logImportStatus( 157 DATA_IMPORT_STARTED, 158 ExportImportLogger.NO_VALUE_RECORDED, 159 ExportImportLogger.NO_VALUE_RECORDED, 160 ExportImportLogger.NO_VALUE_RECORDED); 161 162 Context userContext = mContext.createContextAsUser(userHandle, 0); 163 HealthConnectContext dbContext = 164 HealthConnectContext.create( 165 mContext, userHandle, IMPORT_DATABASE_DIR_NAME, mEnvironmentDataDirectory); 166 File importDbFile = dbContext.getDatabasePath(IMPORT_DATABASE_FILE_NAME); 167 168 int zipFileSize = getZipFileSize(userContext, uri); 169 170 try { 171 try { 172 Slog.d(TAG, "Starting to unzip file: " + importDbFile.getAbsolutePath()); 173 mCompressor.decompress( 174 uri, LOCAL_EXPORT_DATABASE_FILE_NAME, importDbFile, userContext); 175 Slog.i(TAG, "Import file unzipped: " + importDbFile.getAbsolutePath()); 176 } catch (IllegalArgumentException e) { 177 Slog.e( 178 TAG, 179 "Failed to decompress zip file as a null-value entry was found and could " 180 + "not be processed. The file may be corrupted. Details: ", 181 e); 182 notifyAndLogInvalidFileError( 183 userHandle, startTimeMillis, intSizeInKb(importDbFile), zipFileSize); 184 return; 185 } catch (ZipException e) { 186 Slog.d( 187 TAG, 188 "Failed to decompress zip file due to a zip file format error occurring " 189 + "whilst attempting to process the input/output streams. The " 190 + "file may be corrupted. Details: ", 191 e); 192 notifyAndLogInvalidFileError( 193 userHandle, startTimeMillis, intSizeInKb(importDbFile), zipFileSize); 194 } catch (IOException e) { 195 Slog.d( 196 TAG, 197 "Failed to decompress zip file due to an unknown IO error occurring " 198 + "whilst attempting to process the input/output streams. The " 199 + "file may be corrupted. Details: ", 200 e); 201 notifyAndLogInvalidFileError( 202 userHandle, startTimeMillis, intSizeInKb(importDbFile), zipFileSize); 203 } catch (Exception e) { 204 Slog.e( 205 TAG, 206 "Failed to decompress zip file. Was unable to get a copy to the " 207 + "destination: " 208 + importDbFile.getAbsolutePath(), 209 e); 210 notifyAndLogUnknownError( 211 userHandle, startTimeMillis, intSizeInKb(importDbFile), zipFileSize); 212 } 213 214 try { 215 Slog.d(TAG, "Starting to merge database"); 216 if (canMerge(importDbFile)) { 217 HealthConnectDatabase stagedDatabase = 218 new HealthConnectDatabase(dbContext, IMPORT_DATABASE_FILE_NAME); 219 mDatabaseMerger.merge(stagedDatabase); 220 } 221 } catch (SQLiteException e) { 222 Slog.d( 223 TAG, 224 "Import failed during database merge. Selected import file is not" 225 + "a database. Details: ", 226 e); 227 notifyAndLogInvalidFileError( 228 userHandle, startTimeMillis, intSizeInKb(importDbFile), zipFileSize); 229 return; 230 } catch (IllegalStateException e) { 231 Slog.d( 232 TAG, 233 "Import failed during database merge. Existing database has a smaller" 234 + " version number than the database being imported. Details: ", 235 e); 236 sendNotificationAsUser( 237 NOTIFICATION_TYPE_IMPORT_UNSUCCESSFUL_VERSION_MISMATCH, userHandle); 238 recordError( 239 DATA_IMPORT_ERROR_VERSION_MISMATCH, 240 startTimeMillis, 241 intSizeInKb(importDbFile), 242 zipFileSize); 243 return; 244 } catch (Exception e) { 245 Slog.d( 246 TAG, 247 "Import failed during database merge due to an unknown error. " 248 + "Details: ", 249 e); 250 notifyAndLogUnknownError( 251 userHandle, startTimeMillis, intSizeInKb(importDbFile), zipFileSize); 252 return; 253 } 254 Slog.i(TAG, "Import completed"); 255 sendNotificationAsUser(NOTIFICATION_TYPE_IMPORT_COMPLETE, userHandle); 256 recordSuccess(startTimeMillis, intSizeInKb(importDbFile), zipFileSize); 257 } finally { 258 // Delete the staged db as we are done merging. 259 Slog.i(TAG, "Deleting staged db after merging"); 260 SQLiteDatabase.deleteDatabase(importDbFile); 261 } 262 } 263 getZipFileSize(Context userContext, Uri uri)264 private int getZipFileSize(Context userContext, Uri uri) { 265 if (Flags.exportImportFastFollow()) { 266 try { 267 return getFileSizeInKb(userContext.getContentResolver(), uri); 268 } catch (IllegalArgumentException e) { 269 Slog.d( 270 TAG, 271 "Unable to get the file size of the zip file due to a null-value" 272 + " cursor being found. File may be corrupted. Setting to -1 as" 273 + " currently only used for logging. Details: ", 274 e); 275 return -1; 276 } catch (Exception e) { 277 Slog.d( 278 TAG, 279 "Unable to get the file size of the zip file due to an unknown" 280 + " error. Setting to -1 as currently only used for logging." 281 + " Details: ", 282 e); 283 return -1; 284 } 285 } else { 286 return -1; 287 } 288 } 289 canMerge(File importDbFile)290 private boolean canMerge(File importDbFile) 291 throws FileNotFoundException, IllegalStateException, SQLiteException { 292 int currentDbVersion = mTransactionManager.getDatabaseVersion(); 293 if (importDbFile.exists()) { 294 try (SQLiteDatabase importDb = 295 SQLiteDatabase.openDatabase( 296 importDbFile, new SQLiteDatabase.OpenParams.Builder().build())) { 297 int stagedDbVersion = importDb.getVersion(); 298 Slog.i( 299 TAG, 300 "merging staged data, current version = " 301 + currentDbVersion 302 + ", staged version = " 303 + stagedDbVersion); 304 if (currentDbVersion < stagedDbVersion) { 305 Slog.d( 306 TAG, 307 "Import failed when attempting to start merge. The imported database" 308 + " has a greater version number than the existing database."); 309 throw new IllegalStateException( 310 "Unable to merge database - module needs" 311 + "upgrade for merging to version. Current database has smaller" 312 + "version number than database being imported."); 313 } 314 } 315 } else { 316 Slog.d( 317 TAG, 318 "Import failed when attempting to start merge, as database file was" 319 + "not found."); 320 throw new FileNotFoundException("No database file found to merge."); 321 } 322 323 Slog.i(TAG, "File can be merged."); 324 return true; 325 } 326 327 /*** 328 * Returns the size of a file in Kb for logging 329 * To keep the log size small, the data type is an int32 rather than a long (int64). 330 * Using an int allows logging sizes up to 2TB, which is sufficient for our use cases, 331 */ intSizeInKb(File file)332 private int intSizeInKb(File file) { 333 return (int) (file.length() / 1024.0); 334 } 335 336 @VisibleForTesting getFileSizeInKb(ContentResolver contentResolver, Uri zip)337 int getFileSizeInKb(ContentResolver contentResolver, Uri zip) { 338 try (Cursor cursor = contentResolver.query(zip, null, null, null, null)) { 339 if (cursor != null) { 340 int sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE); 341 cursor.moveToFirst(); 342 return (int) (cursor.getLong(sizeIndex) / 1024.0); 343 } else { 344 throw new IllegalArgumentException("Unable to find cursor, returned null."); 345 } 346 } 347 } 348 recordError( int importStatus, long startTimeMillis, int originalDataSizeKb, int compressedDataSizeKb)349 private void recordError( 350 int importStatus, 351 long startTimeMillis, 352 int originalDataSizeKb, 353 int compressedDataSizeKb) { 354 mExportImportSettingsStorage.setImportState(importStatus); 355 if (!Flags.exportImportFastFollow()) return; 356 // Convert to int to save on logs storage, int can hold about 68 years 357 int timeToErrorMillis = mClock != null ? (int) (mClock.millis() - startTimeMillis) : -1; 358 mExportImportLogger.logImportStatus( 359 importStatus, timeToErrorMillis, originalDataSizeKb, compressedDataSizeKb); 360 } 361 recordSuccess( long startTimeMillis, int originalDataSizeKb, int compressedDataSizeKb)362 private void recordSuccess( 363 long startTimeMillis, int originalDataSizeKb, int compressedDataSizeKb) { 364 mExportImportSettingsStorage.setImportState(DATA_IMPORT_ERROR_NONE); 365 if (!Flags.exportImportFastFollow()) return; 366 // Convert to int to save on logs storage, int can hold about 68 years 367 int timeToErrorMillis = mClock != null ? (int) (mClock.millis() - startTimeMillis) : -1; 368 mExportImportLogger.logImportStatus( 369 DATA_IMPORT_ERROR_NONE, 370 timeToErrorMillis, 371 originalDataSizeKb, 372 compressedDataSizeKb); 373 } 374 sendNotificationAsUser(int notificationType, UserHandle userHandle)375 private void sendNotificationAsUser(int notificationType, UserHandle userHandle) { 376 mNotificationSender.clearNotificationsAsUser(userHandle); 377 mNotificationSender.sendNotificationAsUser(notificationType, userHandle); 378 } 379 notifyAndLogUnknownError( UserHandle userHandle, long startTimeMillis, int originalFileSize, int compressedFileSize)380 private void notifyAndLogUnknownError( 381 UserHandle userHandle, 382 long startTimeMillis, 383 int originalFileSize, 384 int compressedFileSize) { 385 sendNotificationAsUser(NOTIFICATION_TYPE_IMPORT_UNSUCCESSFUL_GENERIC_ERROR, userHandle); 386 recordError( 387 DATA_IMPORT_ERROR_UNKNOWN, startTimeMillis, originalFileSize, compressedFileSize); 388 } 389 notifyAndLogInvalidFileError( UserHandle userHandle, long startTimeMillis, int originalFileSize, int compressedFileSize)390 private void notifyAndLogInvalidFileError( 391 UserHandle userHandle, 392 long startTimeMillis, 393 int originalFileSize, 394 int compressedFileSize) { 395 sendNotificationAsUser(NOTIFICATION_TYPE_IMPORT_UNSUCCESSFUL_INVALID_FILE, userHandle); 396 recordError( 397 DATA_IMPORT_ERROR_WRONG_FILE, 398 startTimeMillis, 399 originalFileSize, 400 compressedFileSize); 401 } 402 } 403