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 java.util.Objects.requireNonNull; 20 21 import android.annotation.NonNull; 22 import android.content.Context; 23 import android.database.sqlite.SQLiteDatabase; 24 import android.health.connect.HealthConnectManager; 25 import android.net.Uri; 26 import android.util.Slog; 27 28 import com.android.internal.annotations.VisibleForTesting; 29 import com.android.server.healthconnect.storage.ExportImportSettingsStorage; 30 import com.android.server.healthconnect.storage.HealthConnectDatabase; 31 import com.android.server.healthconnect.storage.TransactionManager; 32 import com.android.server.healthconnect.storage.datatypehelpers.AccessLogsHelper; 33 import com.android.server.healthconnect.storage.datatypehelpers.ChangeLogsHelper; 34 35 import java.io.File; 36 import java.io.FileNotFoundException; 37 import java.io.IOException; 38 import java.io.OutputStream; 39 import java.nio.file.Files; 40 import java.nio.file.StandardCopyOption; 41 import java.time.Clock; 42 import java.util.List; 43 44 /** 45 * Class that manages export related tasks. In this context, export means to make an encrypted copy 46 * of Health Connect data that the user can store in some online storage solution. 47 * 48 * @hide 49 */ 50 public class ExportManager { 51 52 @VisibleForTesting static final String LOCAL_EXPORT_DIR_NAME = "export_import"; 53 54 @VisibleForTesting 55 static final String LOCAL_EXPORT_DATABASE_FILE_NAME = "health_connect_export.db"; 56 57 @VisibleForTesting static final String LOCAL_EXPORT_ZIP_FILE_NAME = "health_connect_export.zip"; 58 59 private static final String TAG = "HealthConnectExportImport"; 60 61 private Clock mClock; 62 63 // Tables to drop instead of tables to keep to avoid risk of bugs if new data types are added. 64 65 /** 66 * Logs size is non-trivial, exporting them would make the process slower and the upload file 67 * would need more storage. Furthermore, logs from a previous device don't provide the user with 68 * useful information. 69 */ 70 @VisibleForTesting 71 public static final List<String> TABLES_TO_CLEAR = 72 List.of(AccessLogsHelper.TABLE_NAME, ChangeLogsHelper.TABLE_NAME); 73 74 private final DatabaseContext mDatabaseContext; 75 ExportManager(@onNull Context context, Clock clock)76 public ExportManager(@NonNull Context context, Clock clock) { 77 requireNonNull(context); 78 requireNonNull(clock); 79 mClock = clock; 80 mDatabaseContext = 81 DatabaseContext.create(context, LOCAL_EXPORT_DIR_NAME, context.getUser()); 82 } 83 84 /** 85 * Makes a local copy of the HC database, deletes the unnecessary data for export and sends the 86 * data to a cloud provider. 87 */ runExport()88 public synchronized boolean runExport() { 89 Slog.i(TAG, "Export started."); 90 File localExportDbFile = 91 new File(mDatabaseContext.getDatabaseDir(), LOCAL_EXPORT_DATABASE_FILE_NAME); 92 File localExportZipFile = 93 new File(mDatabaseContext.getDatabaseDir(), LOCAL_EXPORT_ZIP_FILE_NAME); 94 95 try { 96 try { 97 exportLocally(localExportDbFile); 98 } catch (Exception e) { 99 Slog.e(TAG, "Failed to create local file for export", e); 100 ExportImportSettingsStorage.setLastExportError( 101 HealthConnectManager.DATA_EXPORT_ERROR_UNKNOWN); 102 return false; 103 } 104 105 try { 106 deleteLogTablesContent(); 107 } catch (Exception e) { 108 Slog.e(TAG, "Failed to prepare local file for export", e); 109 ExportImportSettingsStorage.setLastExportError( 110 HealthConnectManager.DATA_EXPORT_ERROR_UNKNOWN); 111 return false; 112 } 113 114 try { 115 Compressor.compress(localExportDbFile, localExportZipFile); 116 } catch (Exception e) { 117 Slog.e(TAG, "Failed to compress local file for export", e); 118 ExportImportSettingsStorage.setLastExportError( 119 HealthConnectManager.DATA_EXPORT_ERROR_UNKNOWN); 120 return false; 121 } 122 123 try { 124 exportToUri(localExportZipFile, ExportImportSettingsStorage.getUri()); 125 } catch (FileNotFoundException e) { 126 Slog.e(TAG, "Lost access to export location", e); 127 ExportImportSettingsStorage.setLastExportError( 128 HealthConnectManager.DATA_EXPORT_LOST_FILE_ACCESS); 129 return false; 130 } catch (Exception e) { 131 Slog.e(TAG, "Failed to export to URI", e); 132 ExportImportSettingsStorage.setLastExportError( 133 HealthConnectManager.DATA_EXPORT_ERROR_UNKNOWN); 134 return false; 135 } 136 137 Slog.i(TAG, "Export completed."); 138 ExportImportSettingsStorage.setLastSuccessfulExport(mClock.instant()); 139 return true; 140 } finally { 141 Slog.i(TAG, "Delete local export files started."); 142 if (localExportDbFile.exists()) { 143 SQLiteDatabase.deleteDatabase(localExportDbFile); 144 } 145 if (localExportZipFile.exists()) { 146 localExportZipFile.delete(); 147 } 148 Slog.i(TAG, "Delete local export files completed."); 149 } 150 } 151 exportLocally(File destination)152 private void exportLocally(File destination) throws IOException { 153 Slog.i(TAG, "Local export started."); 154 155 if (!destination.mkdirs()) { 156 throw new IOException("Unable to create directory for local export."); 157 } 158 159 Files.copy( 160 TransactionManager.getInitialisedInstance().getDatabasePath().toPath(), 161 destination.toPath(), 162 StandardCopyOption.REPLACE_EXISTING); 163 164 Slog.i(TAG, "Local export completed: " + destination.toPath().toAbsolutePath()); 165 } 166 exportToUri(File source, Uri destination)167 private void exportToUri(File source, Uri destination) throws IOException { 168 Slog.i(TAG, "Export to URI started."); 169 try (OutputStream outputStream = 170 mDatabaseContext.getContentResolver().openOutputStream(destination)) { 171 if (outputStream == null) { 172 throw new IOException("Unable to copy data to URI for export."); 173 } 174 Files.copy(source.toPath(), outputStream); 175 Slog.i(TAG, "Export to URI completed."); 176 } 177 } 178 179 // TODO(b/325599879): Double check if we need to vacuum the database after clearing the tables. deleteLogTablesContent()180 private void deleteLogTablesContent() { 181 // Throwing a exception when calling this method implies that it was not possible to 182 // create a HC database from the file and, therefore, most probably the database was 183 // corrupted during the file copy. 184 try (HealthConnectDatabase exportDatabase = 185 new HealthConnectDatabase(mDatabaseContext, LOCAL_EXPORT_DATABASE_FILE_NAME)) { 186 for (String tableName : TABLES_TO_CLEAR) { 187 exportDatabase.getWritableDatabase().execSQL("DELETE FROM " + tableName + ";"); 188 } 189 } 190 Slog.i(TAG, "Drop log tables completed."); 191 } 192 } 193