1 /*
2  * Copyright (C) 2023 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.migration;
18 
19 import android.annotation.NonNull;
20 import android.annotation.Nullable;
21 import android.content.Context;
22 import android.database.sqlite.SQLiteDatabase;
23 import android.health.connect.internal.datatypes.RecordInternal;
24 import android.health.connect.migration.AppInfoMigrationPayload;
25 import android.health.connect.migration.MetadataMigrationPayload;
26 import android.health.connect.migration.MigrationEntity;
27 import android.health.connect.migration.MigrationPayload;
28 import android.health.connect.migration.PermissionMigrationPayload;
29 import android.health.connect.migration.PriorityMigrationPayload;
30 import android.health.connect.migration.RecordMigrationPayload;
31 import android.os.UserHandle;
32 
33 import com.android.internal.annotations.GuardedBy;
34 import com.android.server.healthconnect.permission.FirstGrantTimeManager;
35 import com.android.server.healthconnect.permission.HealthConnectPermissionHelper;
36 import com.android.server.healthconnect.storage.AutoDeleteService;
37 import com.android.server.healthconnect.storage.TransactionManager;
38 import com.android.server.healthconnect.storage.datatypehelpers.ActivityDateHelper;
39 import com.android.server.healthconnect.storage.datatypehelpers.AppInfoHelper;
40 import com.android.server.healthconnect.storage.datatypehelpers.DeviceInfoHelper;
41 import com.android.server.healthconnect.storage.datatypehelpers.HealthDataCategoryPriorityHelper;
42 import com.android.server.healthconnect.storage.datatypehelpers.MigrationEntityHelper;
43 import com.android.server.healthconnect.storage.request.UpsertTableRequest;
44 import com.android.server.healthconnect.storage.utils.RecordHelperProvider;
45 import com.android.server.healthconnect.storage.utils.StorageUtils;
46 
47 import java.util.ArrayList;
48 import java.util.Collection;
49 import java.util.List;
50 import java.util.stream.Collectors;
51 import java.util.stream.Stream;
52 
53 /**
54  * Controls the data migration flow. Accepts and applies collections of {@link MigrationEntity}.
55  *
56  * @hide
57  */
58 public final class DataMigrationManager {
59 
60     private static final Object sLock = new Object();
61 
62     private final Context mUserContext;
63     private final TransactionManager mTransactionManager;
64     private final HealthConnectPermissionHelper mPermissionHelper;
65     private final FirstGrantTimeManager mFirstGrantTimeManager;
66     private final DeviceInfoHelper mDeviceInfoHelper;
67     private final AppInfoHelper mAppInfoHelper;
68     private final PriorityMigrationHelper mPriorityMigrationHelper;
69     private final HealthDataCategoryPriorityHelper mHealthDataCategoryPriorityHelper;
70 
DataMigrationManager( @onNull Context userContext, @NonNull TransactionManager transactionManager, @NonNull HealthConnectPermissionHelper permissionHelper, @NonNull FirstGrantTimeManager firstGrantTimeManager, @NonNull DeviceInfoHelper deviceInfoHelper, @NonNull AppInfoHelper appInfoHelper, @NonNull HealthDataCategoryPriorityHelper healthDataCategoryPriorityHelper, @NonNull PriorityMigrationHelper priorityMigrationHelper)71     public DataMigrationManager(
72             @NonNull Context userContext,
73             @NonNull TransactionManager transactionManager,
74             @NonNull HealthConnectPermissionHelper permissionHelper,
75             @NonNull FirstGrantTimeManager firstGrantTimeManager,
76             @NonNull DeviceInfoHelper deviceInfoHelper,
77             @NonNull AppInfoHelper appInfoHelper,
78             @NonNull HealthDataCategoryPriorityHelper healthDataCategoryPriorityHelper,
79             @NonNull PriorityMigrationHelper priorityMigrationHelper) {
80         mUserContext = userContext;
81         mTransactionManager = transactionManager;
82         mPermissionHelper = permissionHelper;
83         mFirstGrantTimeManager = firstGrantTimeManager;
84         mDeviceInfoHelper = deviceInfoHelper;
85         mAppInfoHelper = appInfoHelper;
86         mHealthDataCategoryPriorityHelper = healthDataCategoryPriorityHelper;
87         mPriorityMigrationHelper = priorityMigrationHelper;
88     }
89 
90     /**
91      * Parses and applies the provided migration entities.
92      *
93      * @param entities a collection of {@link MigrationEntity} to be applied.
94      */
apply(@onNull Collection<MigrationEntity> entities)95     public void apply(@NonNull Collection<MigrationEntity> entities) throws EntityWriteException {
96         synchronized (sLock) {
97             mTransactionManager.runAsTransaction(
98                     db -> {
99                         // Grab the lock again to make sure error-prone is happy, and so that tests
100                         // break if the following code is run asynchronously
101                         synchronized (sLock) {
102                             for (MigrationEntity entity : entities) {
103                                 migrateEntity(db, entity);
104                             }
105                         }
106                     });
107         }
108     }
109 
110     /** Migrates the provided {@link MigrationEntity}. Must be called inside a DB transaction. */
111     @GuardedBy("sLock")
migrateEntity(@onNull SQLiteDatabase db, @NonNull MigrationEntity entity)112     private void migrateEntity(@NonNull SQLiteDatabase db, @NonNull MigrationEntity entity)
113             throws EntityWriteException {
114         try {
115             if (checkEntityForDuplicates(db, entity)) {
116                 return;
117             }
118 
119             final MigrationPayload payload = entity.getPayload();
120             if (payload instanceof RecordMigrationPayload) {
121                 migrateRecord(db, (RecordMigrationPayload) payload);
122             } else if (payload instanceof PermissionMigrationPayload) {
123                 migratePermissions((PermissionMigrationPayload) payload);
124             } else if (payload instanceof AppInfoMigrationPayload) {
125                 migrateAppInfo((AppInfoMigrationPayload) payload);
126             } else if (payload instanceof PriorityMigrationPayload) {
127                 migratePriority((PriorityMigrationPayload) payload);
128             } else if (payload instanceof MetadataMigrationPayload) {
129                 migrateMetadata((MetadataMigrationPayload) payload);
130             } else {
131                 throw new IllegalArgumentException("Unsupported payload type: " + payload);
132             }
133         } catch (RuntimeException e) {
134             throw new EntityWriteException(entity.getEntityId(), e);
135         }
136     }
137 
138     @GuardedBy("sLock")
migrateRecord( @onNull SQLiteDatabase db, @NonNull RecordMigrationPayload payload)139     private void migrateRecord(
140             @NonNull SQLiteDatabase db, @NonNull RecordMigrationPayload payload) {
141         long recordRowId = mTransactionManager.insertOrIgnore(db, parseRecord(payload));
142         if (recordRowId != -1) {
143             mTransactionManager.insertOrIgnore(
144                     db, ActivityDateHelper.getUpsertTableRequest(payload.getRecordInternal()));
145         }
146     }
147 
148     @NonNull
parseRecord(@onNull RecordMigrationPayload payload)149     private UpsertTableRequest parseRecord(@NonNull RecordMigrationPayload payload) {
150         final RecordInternal<?> record = payload.getRecordInternal();
151         mAppInfoHelper.populateAppInfoId(record, mUserContext, false);
152         mDeviceInfoHelper.populateDeviceInfoId(record);
153 
154         if (record.getUuid() == null) {
155             StorageUtils.addNameBasedUUIDTo(record);
156         }
157 
158         return RecordHelperProvider.getRecordHelper(record.getRecordType())
159                 .getUpsertTableRequest(record);
160     }
161 
162     @GuardedBy("sLock")
migratePermissions(@onNull PermissionMigrationPayload payload)163     private void migratePermissions(@NonNull PermissionMigrationPayload payload) {
164         final String packageName = payload.getHoldingPackageName();
165         final List<String> permissions = payload.getPermissions();
166         final UserHandle userHandle = mUserContext.getUser();
167 
168         if (permissions.isEmpty()
169                 || mPermissionHelper.hasGrantedHealthPermissions(packageName, userHandle)) {
170             return;
171         }
172 
173         final List<Exception> errors = new ArrayList<>();
174 
175         for (String permissionName : permissions) {
176             try {
177                 mPermissionHelper.grantHealthPermission(packageName, permissionName, userHandle);
178             } catch (Exception e) {
179                 errors.add(e);
180             }
181         }
182 
183         // Throw if no permissions were migrated
184         if (errors.size() == permissions.size()) {
185             final RuntimeException error =
186                     new RuntimeException(
187                             "Error migrating permissions for "
188                                     + packageName
189                                     + ": "
190                                     + String.join(", ", payload.getPermissions()));
191             for (Exception e : errors) {
192                 error.addSuppressed(e);
193             }
194             throw error;
195         }
196 
197         mFirstGrantTimeManager.setFirstGrantTime(
198                 packageName, payload.getFirstGrantTime(), userHandle);
199     }
200 
201     @GuardedBy("sLock")
migrateAppInfo(@onNull AppInfoMigrationPayload payload)202     private void migrateAppInfo(@NonNull AppInfoMigrationPayload payload) {
203         mAppInfoHelper.addOrUpdateAppInfoIfNotInstalled(
204                 mUserContext,
205                 payload.getPackageName(),
206                 payload.getAppName(),
207                 payload.getAppIcon(),
208                 true /* onlyReplace */);
209     }
210 
211     /**
212      * Checks the provided entity for duplicates by {@code entityId}. Modifies {@link
213      * MigrationEntityHelper} table as a side effect.
214      *
215      * <p>Entities with the following payload types are exempt from deduplication checks (the result
216      * is always {@code false}): {@link RecordMigrationPayload}.
217      *
218      * @return {@code true} if the entity is duplicated and thus should be ignored, {@code false}
219      *     otherwise.
220      */
221     @GuardedBy("sLock")
checkEntityForDuplicates( @onNull SQLiteDatabase db, @NonNull MigrationEntity entity)222     private boolean checkEntityForDuplicates(
223             @NonNull SQLiteDatabase db, @NonNull MigrationEntity entity) {
224         final MigrationPayload payload = entity.getPayload();
225 
226         if (payload instanceof RecordMigrationPayload) {
227             return false; // Do not deduplicate records by entityId
228         }
229 
230         return !insertEntityIdIfNotPresent(db, entity.getEntityId());
231     }
232 
233     /**
234      * Inserts the provided {@code entity} into the database if it doesn't exist yet. Used for data
235      * deduplication.
236      *
237      * @return {@code true} if inserted successfully, {@code false} otherwise.
238      */
239     @GuardedBy("sLock")
insertEntityIdIfNotPresent( @onNull SQLiteDatabase db, @NonNull String entityId)240     private boolean insertEntityIdIfNotPresent(
241             @NonNull SQLiteDatabase db, @NonNull String entityId) {
242         final UpsertTableRequest request = MigrationEntityHelper.getInsertRequest(entityId);
243         return mTransactionManager.insertOrIgnore(db, request) != -1;
244     }
245 
246     /** Indicates an error during entity migration. */
247     public static final class EntityWriteException extends Exception {
248         private final String mEntityId;
249 
EntityWriteException(@onNull String entityId, @Nullable Throwable cause)250         private EntityWriteException(@NonNull String entityId, @Nullable Throwable cause) {
251             super("Error writing entity: " + entityId, cause);
252 
253             mEntityId = entityId;
254         }
255 
256         /**
257          * Returns an identifier of the failed entity, as specified in {@link
258          * MigrationEntity#getEntityId()}.
259          */
260         @NonNull
getEntityId()261         public String getEntityId() {
262             return mEntityId;
263         }
264     }
265 
266     /**
267      * Internal method to migrate priority list of packages for data category
268      *
269      * @param priorityMigrationPayload contains data category and priority list
270      */
migratePriority(@onNull PriorityMigrationPayload priorityMigrationPayload)271     private void migratePriority(@NonNull PriorityMigrationPayload priorityMigrationPayload) {
272         if (priorityMigrationPayload.getDataOrigins().isEmpty()) {
273             return;
274         }
275 
276         List<String> priorityToMigrate =
277                 priorityMigrationPayload.getDataOrigins().stream()
278                         .map(dataOrigin -> dataOrigin.getPackageName())
279                         .toList();
280 
281         List<String> preMigrationPriority =
282                 mAppInfoHelper.getPackageNames(
283                         mPriorityMigrationHelper.getPreMigrationPriority(
284                                 priorityMigrationPayload.getDataCategory()));
285 
286         /*
287         The combined priority would contain priority order from module appended by additional
288         packages from apk priority order.
289         */
290         List<String> combinedPriorityOrder =
291                 Stream.concat(preMigrationPriority.stream(), priorityToMigrate.stream())
292                         .distinct()
293                         .collect(Collectors.toList());
294 
295         /*
296          * setPriorityOrder removes any additional packages that were not present already in
297          * priority, and it adds any package in priority that was present earlier but missing in
298          * updated priority. This means it will remove any package that don't have required
299          * permission for category as well as it will remove any package that is uninstalled.
300          */
301         mHealthDataCategoryPriorityHelper.setPriorityOrder(
302                 priorityMigrationPayload.getDataCategory(), combinedPriorityOrder);
303     }
304 
305     /**
306      * Migrates Metadata like recordRetentionPeriod
307      *
308      * @param payload of type MetadataMigrationPayload having retention period.
309      */
migrateMetadata(MetadataMigrationPayload payload)310     private void migrateMetadata(MetadataMigrationPayload payload) {
311         AutoDeleteService.setRecordRetentionPeriodInDays(payload.getRecordRetentionPeriodDays());
312     }
313 }
314