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.ScheduledExportStatus.DATA_EXPORT_ERROR_CLEARING_LOG_TABLES; 20 import static android.health.connect.exportimport.ScheduledExportStatus.DATA_EXPORT_ERROR_CLEARING_PHR_TABLES; 21 import static android.health.connect.exportimport.ScheduledExportStatus.DATA_EXPORT_ERROR_NONE; 22 import static android.health.connect.exportimport.ScheduledExportStatus.DATA_EXPORT_ERROR_UNKNOWN; 23 import static android.health.connect.exportimport.ScheduledExportStatus.DATA_EXPORT_LOST_FILE_ACCESS; 24 import static android.health.connect.exportimport.ScheduledExportStatus.DATA_EXPORT_STARTED; 25 26 import static com.android.healthfitness.flags.Flags.exportImportFastFollow; 27 import static com.android.healthfitness.flags.Flags.extendExportImportTelemetry; 28 import static com.android.server.healthconnect.exportimport.ExportImportNotificationSender.NOTIFICATION_TYPE_EXPORT_UNSUCCESSFUL_GENERIC_ERROR; 29 import static com.android.server.healthconnect.logging.ExportImportLogger.NO_VALUE_RECORDED; 30 31 import android.content.Context; 32 import android.database.sqlite.SQLiteDatabase; 33 import android.health.connect.exportimport.ScheduledExportStatus.DataExportError; 34 import android.net.Uri; 35 import android.os.UserHandle; 36 import android.util.Slog; 37 38 import com.android.healthfitness.flags.Flags; 39 import com.android.internal.annotations.VisibleForTesting; 40 import com.android.server.healthconnect.logging.ExportImportLogger; 41 import com.android.server.healthconnect.notifications.HealthConnectNotificationSender; 42 import com.android.server.healthconnect.storage.ExportImportSettingsStorage; 43 import com.android.server.healthconnect.storage.HealthConnectContext; 44 import com.android.server.healthconnect.storage.HealthConnectDatabase; 45 import com.android.server.healthconnect.storage.TransactionManager; 46 import com.android.server.healthconnect.storage.datatypehelpers.AccessLogsHelper; 47 import com.android.server.healthconnect.storage.datatypehelpers.ChangeLogsHelper; 48 import com.android.server.healthconnect.storage.datatypehelpers.MedicalDataSourceHelper; 49 import com.android.server.healthconnect.storage.datatypehelpers.MedicalResourceHelper; 50 import com.android.server.healthconnect.storage.datatypehelpers.MedicalResourceIndicesHelper; 51 52 import java.io.File; 53 import java.io.FileNotFoundException; 54 import java.io.IOException; 55 import java.io.OutputStream; 56 import java.nio.file.Files; 57 import java.nio.file.StandardCopyOption; 58 import java.time.Clock; 59 import java.util.List; 60 61 /** 62 * Class that manages export related tasks. In this context, export means to make an encrypted copy 63 * of Health Connect data that the user can store in some online storage solution. 64 * 65 * @hide 66 */ 67 public class ExportManager { 68 69 @VisibleForTesting static final String LOCAL_EXPORT_DIR_NAME = "export_import"; 70 71 static final String LOCAL_EXPORT_DATABASE_FILE_NAME = "health_connect_export.db"; 72 73 @VisibleForTesting static final String LOCAL_EXPORT_ZIP_FILE_NAME = "health_connect_export.zip"; 74 75 private static final String TAG = "HealthConnectExportImport"; 76 77 private final Context mContext; 78 private final Clock mClock; 79 private final TransactionManager mTransactionManager; 80 private final ExportImportSettingsStorage mExportImportSettingsStorage; 81 82 private final HealthConnectNotificationSender mNotificationSender; 83 private final File mEnvironmentDataDirectory; 84 private final ExportImportLogger mExportImportLogger; 85 private final ErrorReporter mErrorReporter; 86 private final Compressor mCompressor; 87 88 // Tables to drop instead of tables to keep to avoid risk of bugs if new data types are added. 89 /** 90 * Logs size is non-trivial, exporting them would make the process slower and the upload file 91 * would need more storage. Furthermore, logs from a previous device don't provide the user with 92 * useful information. 93 */ 94 @VisibleForTesting 95 public static final List<String> TABLES_TO_CLEAR = 96 List.of(AccessLogsHelper.TABLE_NAME, ChangeLogsHelper.TABLE_NAME); 97 98 private static final List<String> PHR_TABLES_TO_CLEAR = 99 List.of( 100 MedicalDataSourceHelper.getMainTableName(), 101 MedicalResourceHelper.getMainTableName(), 102 MedicalResourceIndicesHelper.getTableName()); 103 ExportManager( Context context, Clock clock, ExportImportSettingsStorage exportImportSettingsStorage, TransactionManager transactionManager, HealthConnectNotificationSender notificationSender, File environmentDataDirectory, ExportImportLogger exportImportLogger)104 public ExportManager( 105 Context context, 106 Clock clock, 107 ExportImportSettingsStorage exportImportSettingsStorage, 108 TransactionManager transactionManager, 109 HealthConnectNotificationSender notificationSender, 110 File environmentDataDirectory, 111 ExportImportLogger exportImportLogger) { 112 this( 113 context, 114 clock, 115 exportImportSettingsStorage, 116 transactionManager, 117 notificationSender, 118 environmentDataDirectory, 119 exportImportLogger, 120 new ErrorReporter(), 121 new Compressor()); 122 } 123 124 @VisibleForTesting ExportManager( Context context, Clock clock, ExportImportSettingsStorage exportImportSettingsStorage, TransactionManager transactionManager, HealthConnectNotificationSender notificationSender, File environmentDataDirectory, ExportImportLogger exportImportLogger, ErrorReporter errorReporter, Compressor compressor)125 ExportManager( 126 Context context, 127 Clock clock, 128 ExportImportSettingsStorage exportImportSettingsStorage, 129 TransactionManager transactionManager, 130 HealthConnectNotificationSender notificationSender, 131 File environmentDataDirectory, 132 ExportImportLogger exportImportLogger, 133 ErrorReporter errorReporter, 134 Compressor compressor) { 135 mContext = context; 136 mClock = clock; 137 mExportImportSettingsStorage = exportImportSettingsStorage; 138 mTransactionManager = transactionManager; 139 mNotificationSender = notificationSender; 140 mEnvironmentDataDirectory = environmentDataDirectory; 141 mExportImportLogger = exportImportLogger; 142 mErrorReporter = errorReporter; 143 mCompressor = compressor; 144 } 145 146 /** 147 * Makes a local copy of the HC database, deletes the unnecessary data for export and sends the 148 * data to a cloud provider. 149 * 150 * @return true if the export was successful, false otherwise 151 */ runExport(UserHandle userHandle)152 public synchronized boolean runExport(UserHandle userHandle) { 153 Slog.i(TAG, "Export started."); 154 long startTimeMillis = mClock.millis(); 155 mExportImportLogger.logExportStatus( 156 DATA_EXPORT_STARTED, NO_VALUE_RECORDED, NO_VALUE_RECORDED, NO_VALUE_RECORDED); 157 158 HealthConnectContext dbContext = 159 HealthConnectContext.create( 160 mContext, userHandle, LOCAL_EXPORT_DIR_NAME, mEnvironmentDataDirectory); 161 File localExportDbFile = getLocalExportDbFile(dbContext); 162 File localExportZipFile = getLocalExportZipFile(dbContext); 163 164 try { 165 try { 166 exportLocally(localExportDbFile); 167 } catch (Exception e) { 168 mErrorReporter.failed(ErrorReporter.LOCAL_FILE, e, intSizeInKb(localExportDbFile)); 169 recordError( 170 DATA_EXPORT_ERROR_UNKNOWN, 171 startTimeMillis, 172 intSizeInKb(localExportDbFile), 173 /* Compressed size will be 0, not yet compressed */ 174 intSizeInKb(localExportZipFile)); 175 sendNotificationIfEnabled( 176 userHandle, NOTIFICATION_TYPE_EXPORT_UNSUCCESSFUL_GENERIC_ERROR); 177 return false; 178 } 179 180 try { 181 deleteLogTablesContent(dbContext); 182 } catch (Exception e) { 183 mErrorReporter.failed( 184 ErrorReporter.CLEAR_LOG_TABLES, e, intSizeInKb(localExportDbFile)); 185 recordError( 186 DATA_EXPORT_ERROR_CLEARING_LOG_TABLES, 187 startTimeMillis, 188 intSizeInKb(localExportDbFile), 189 /* Compressed size will be 0, not yet compressed */ 190 intSizeInKb(localExportZipFile)); 191 sendNotificationIfEnabled( 192 userHandle, NOTIFICATION_TYPE_EXPORT_UNSUCCESSFUL_GENERIC_ERROR); 193 return false; 194 } 195 196 if (!Flags.personalHealthRecordEnableExportImport() 197 && Flags.personalHealthRecordDisableExportImport()) { 198 try { 199 deletePhrTablesContent(dbContext); 200 } catch (Exception e) { 201 mErrorReporter.failed( 202 ErrorReporter.CLEAR_PHR_TABLES, e, intSizeInKb(localExportDbFile)); 203 recordError( 204 DATA_EXPORT_ERROR_CLEARING_PHR_TABLES, 205 startTimeMillis, 206 intSizeInKb(localExportDbFile), 207 /* Compressed size will be 0, not yet compressed */ 208 intSizeInKb(localExportZipFile)); 209 sendNotificationIfEnabled( 210 userHandle, NOTIFICATION_TYPE_EXPORT_UNSUCCESSFUL_GENERIC_ERROR); 211 return false; 212 } 213 } 214 215 try { 216 mCompressor.compress( 217 localExportDbFile, LOCAL_EXPORT_DATABASE_FILE_NAME, localExportZipFile); 218 } catch (Exception e) { 219 mErrorReporter.failed(ErrorReporter.COMPRESSION, e, intSizeInKb(localExportDbFile)); 220 recordError( 221 DATA_EXPORT_ERROR_UNKNOWN, 222 startTimeMillis, 223 intSizeInKb(localExportDbFile), 224 /* Compressed size will be 0, not yet compressed */ 225 intSizeInKb(localExportZipFile)); 226 sendNotificationIfEnabled( 227 userHandle, NOTIFICATION_TYPE_EXPORT_UNSUCCESSFUL_GENERIC_ERROR); 228 return false; 229 } 230 231 Uri destinationUri = mExportImportSettingsStorage.getUri(); 232 try { 233 exportToUri(dbContext, localExportZipFile, destinationUri); 234 } catch (FileNotFoundException e) { 235 mErrorReporter.failed( 236 ErrorReporter.LOST_EXPORT_LOCATION_ACCESS, 237 e, 238 intSizeInKb(localExportDbFile)); 239 recordError( 240 DATA_EXPORT_LOST_FILE_ACCESS, 241 startTimeMillis, 242 intSizeInKb(localExportDbFile), 243 intSizeInKb(localExportZipFile)); 244 sendNotificationIfEnabled( 245 userHandle, NOTIFICATION_TYPE_EXPORT_UNSUCCESSFUL_GENERIC_ERROR); 246 return false; 247 } catch (Exception e) { 248 mErrorReporter.failed(ErrorReporter.URI_EXPORT, e, intSizeInKb(localExportDbFile)); 249 recordError( 250 DATA_EXPORT_ERROR_UNKNOWN, 251 startTimeMillis, 252 intSizeInKb(localExportDbFile), 253 intSizeInKb(localExportZipFile)); 254 sendNotificationIfEnabled( 255 userHandle, NOTIFICATION_TYPE_EXPORT_UNSUCCESSFUL_GENERIC_ERROR); 256 return false; 257 } 258 Slog.i(TAG, "Export completed."); 259 Slog.d(TAG, "Original file size: " + intSizeInKb(localExportDbFile)); 260 recordSuccess( 261 startTimeMillis, 262 intSizeInKb(localExportDbFile), 263 intSizeInKb(localExportZipFile), 264 destinationUri); 265 return true; 266 } finally { 267 deleteLocalExportFiles(userHandle); 268 } 269 } 270 recordSuccess( long startTimeMillis, int originalDataSizeKb, int compressedDataSizeKb, Uri destinationUri)271 protected void recordSuccess( 272 long startTimeMillis, 273 int originalDataSizeKb, 274 int compressedDataSizeKb, 275 Uri destinationUri) { 276 mExportImportSettingsStorage.setLastSuccessfulExport(mClock.instant(), destinationUri); 277 278 if (extendExportImportTelemetry()) { 279 mExportImportSettingsStorage.resetExportRepeatErrorOnRetryCount(); 280 } 281 282 // The logging proto holds an int32 not an in64 to save on logs storage. The cast makes this 283 // explicit. The int can hold 24.855 days worth of milli seconds, which 284 // is sufficient because the system would kill the process earlier. 285 int timeToSuccessMillis = (int) (mClock.millis() - startTimeMillis); 286 mExportImportLogger.logExportStatus( 287 DATA_EXPORT_ERROR_NONE, 288 timeToSuccessMillis, 289 originalDataSizeKb, 290 compressedDataSizeKb); 291 } 292 recordError( int exportStatus, long startTimeMillis, int originalDataSizeKb, int compressedDataSizeKb)293 protected void recordError( 294 int exportStatus, 295 long startTimeMillis, 296 int originalDataSizeKb, 297 int compressedDataSizeKb) { 298 @DataExportError int previousError = mExportImportSettingsStorage.getLastExportError(); 299 300 if (extendExportImportTelemetry()) { 301 // Only start counting duplicate errors the second time the same error happens. 302 if (exportStatus != previousError) { 303 mExportImportSettingsStorage.resetExportRepeatErrorOnRetryCount(); 304 } else { 305 mExportImportSettingsStorage.increaseExportRepeatErrorOnRetryCount(); 306 } 307 } 308 309 mExportImportSettingsStorage.setLastExportError(exportStatus, mClock.instant()); 310 311 // Convert to int to save on logs storage, int can hold about 68 years 312 int timeToErrorMillis = (int) (mClock.millis() - startTimeMillis); 313 mExportImportLogger.logExportStatus( 314 exportStatus, timeToErrorMillis, originalDataSizeKb, compressedDataSizeKb); 315 } 316 deleteLocalExportFiles(UserHandle userHandle)317 void deleteLocalExportFiles(UserHandle userHandle) { 318 Slog.i(TAG, "Delete local export files started."); 319 HealthConnectContext dbContext = 320 HealthConnectContext.create( 321 mContext, userHandle, LOCAL_EXPORT_DIR_NAME, mEnvironmentDataDirectory); 322 File localExportDbFile = getLocalExportDbFile(dbContext); 323 File localExportZipFile = getLocalExportZipFile(dbContext); 324 if (localExportDbFile.exists()) { 325 SQLiteDatabase.deleteDatabase(localExportDbFile); 326 } 327 if (localExportZipFile.exists()) { 328 localExportZipFile.delete(); 329 } 330 Slog.i(TAG, "Delete local export files completed."); 331 } 332 getLocalExportDbFile(HealthConnectContext dbContext)333 private File getLocalExportDbFile(HealthConnectContext dbContext) { 334 return new File(dbContext.getDataDir(), LOCAL_EXPORT_DATABASE_FILE_NAME); 335 } 336 getLocalExportZipFile(HealthConnectContext dbContext)337 private File getLocalExportZipFile(HealthConnectContext dbContext) { 338 return new File(dbContext.getDataDir(), LOCAL_EXPORT_ZIP_FILE_NAME); 339 } 340 exportLocally(File destination)341 private void exportLocally(File destination) throws IOException { 342 Slog.i(TAG, "Local export started."); 343 344 if (!destination.exists() && !destination.mkdirs()) { 345 throw new IOException("Unable to create directory for local export."); 346 } 347 348 Files.copy( 349 mTransactionManager.getDatabasePath().toPath(), 350 destination.toPath(), 351 StandardCopyOption.REPLACE_EXISTING); 352 353 Slog.i(TAG, "Local export completed: " + destination.toPath().toAbsolutePath()); 354 } 355 exportToUri(HealthConnectContext dbContext, File source, Uri destination)356 private void exportToUri(HealthConnectContext dbContext, File source, Uri destination) 357 throws IOException { 358 Slog.i(TAG, "Export to URI started."); 359 try (OutputStream outputStream = 360 dbContext.getContentResolver().openOutputStream(destination)) { 361 if (outputStream == null) { 362 throw new IOException("Unable to copy data to URI for export."); 363 } 364 Files.copy(source.toPath(), outputStream); 365 Slog.i(TAG, "Export to URI completed."); 366 } 367 } 368 369 // TODO(b/325599879): Double check if we need to vacuum the database after clearing the tables. deleteLogTablesContent(HealthConnectContext dbContext)370 private void deleteLogTablesContent(HealthConnectContext dbContext) { 371 // Throwing a exception when calling this method implies that it was not possible to 372 // create a HC database from the file and, therefore, most probably the database was 373 // corrupted during the file copy. 374 try (HealthConnectDatabase exportDatabase = 375 new HealthConnectDatabase(dbContext, LOCAL_EXPORT_DATABASE_FILE_NAME)) { 376 for (String tableName : TABLES_TO_CLEAR) { 377 exportDatabase.getWritableDatabase().execSQL("DELETE FROM " + tableName + ";"); 378 } 379 } 380 Slog.i(TAG, "Drop log tables completed."); 381 } 382 deletePhrTablesContent(HealthConnectContext dbContext)383 private void deletePhrTablesContent(HealthConnectContext dbContext) { 384 try (HealthConnectDatabase exportDatabase = 385 new HealthConnectDatabase(dbContext, LOCAL_EXPORT_DATABASE_FILE_NAME)) { 386 for (String tableName : PHR_TABLES_TO_CLEAR) { 387 exportDatabase.getWritableDatabase().execSQL("DELETE FROM " + tableName + ";"); 388 } 389 } 390 Slog.i(TAG, "Drop phr tables completed."); 391 } 392 393 /*** 394 * Returns the size of a file in Kb for logging 395 * 396 * To keep the log size small, the data type is an int32 rather than a long (int64). 397 * Using an int allows logging sizes up to 2TB, which is sufficient for our use cases, 398 */ intSizeInKb(File file)399 private int intSizeInKb(File file) { 400 return (int) (file.length() / 1024.0); 401 } 402 403 /** Sends export status notification if export_import_fast_follow flag enabled. */ sendNotificationIfEnabled(UserHandle userHandle, int notificationType)404 private void sendNotificationIfEnabled(UserHandle userHandle, int notificationType) { 405 if (exportImportFastFollow()) { 406 mNotificationSender.sendNotificationAsUser(notificationType, userHandle); 407 } 408 } 409 410 /** Helper class to report errors with exporting. */ 411 static class ErrorReporter { 412 static final int LOCAL_FILE = 1; 413 static final int URI_EXPORT = 2; 414 static final int LOST_EXPORT_LOCATION_ACCESS = 3; 415 static final int COMPRESSION = 4; 416 static final int CLEAR_PHR_TABLES = 5; 417 static final int CLEAR_LOG_TABLES = 6; 418 419 /** 420 * Report there was a problem with export. 421 * 422 * @param stage the stage of the failure 423 * @param cause the exception that caused the failure 424 * @param originalFileSize the size of the existing file 425 */ failed(int stage, Exception cause, int originalFileSize)426 public void failed(int stage, Exception cause, int originalFileSize) { 427 switch (stage) { 428 case LOCAL_FILE -> Slog.e(TAG, "Failed to create local file for export", cause); 429 case URI_EXPORT -> Slog.e(TAG, "Failed to export to URI", cause); 430 case LOST_EXPORT_LOCATION_ACCESS -> 431 Slog.e(TAG, "Lost access to export location", cause); 432 case COMPRESSION -> Slog.e(TAG, "Failed to compress local file for export", cause); 433 case CLEAR_PHR_TABLES -> 434 Slog.e(TAG, "Failed to clear phr tables in preparation for export", cause); 435 case CLEAR_LOG_TABLES -> 436 Slog.e(TAG, "Failed to clear log tables in preparation for export", cause); 437 } 438 Slog.d(TAG, "Original file size: " + originalFileSize); 439 } 440 } 441 } 442