• 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.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