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