• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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