• 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 package com.android.server.healthconnect.backuprestore;
17 
18 import static android.health.connect.Constants.DEFAULT_PAGE_SIZE;
19 import static android.health.connect.PageTokenWrapper.EMPTY_PAGE_TOKEN;
20 import static android.health.connect.datatypes.RecordTypeIdentifier.RECORD_TYPE_UNKNOWN;
21 
22 import static com.android.server.healthconnect.backuprestore.RecordProtoConverter.PROTO_VERSION;
23 import static com.android.server.healthconnect.exportimport.DatabaseMerger.RECORD_TYPE_MIGRATION_ORDERING_OVERRIDES;
24 import static com.android.server.healthconnect.storage.datatypehelpers.RecordHelper.PRIMARY_COLUMN_NAME;
25 import static com.android.server.healthconnect.storage.utils.WhereClauses.LogicalOperator.AND;
26 
27 import android.annotation.Nullable;
28 import android.database.Cursor;
29 import android.health.connect.PageTokenWrapper;
30 import android.health.connect.ReadRecordsRequestUsingFilters;
31 import android.health.connect.backuprestore.BackupChange;
32 import android.health.connect.backuprestore.GetChangesForBackupResponse;
33 import android.health.connect.changelog.ChangeLogsRequest;
34 import android.health.connect.changelog.ChangeLogsResponse;
35 import android.health.connect.datatypes.Record;
36 import android.health.connect.datatypes.RecordTypeIdentifier;
37 import android.health.connect.internal.datatypes.PlannedExerciseSessionRecordInternal;
38 import android.health.connect.internal.datatypes.RecordInternal;
39 import android.health.connect.internal.datatypes.utils.HealthConnectMappings;
40 import android.util.Pair;
41 import android.util.Slog;
42 
43 import com.android.server.healthconnect.fitness.FitnessRecordReadHelper;
44 import com.android.server.healthconnect.proto.backuprestore.BackupData;
45 import com.android.server.healthconnect.storage.TransactionManager;
46 import com.android.server.healthconnect.storage.datatypehelpers.AppInfoHelper;
47 import com.android.server.healthconnect.storage.datatypehelpers.BackupChangeTokenHelper;
48 import com.android.server.healthconnect.storage.datatypehelpers.ChangeLogsHelper;
49 import com.android.server.healthconnect.storage.datatypehelpers.ChangeLogsRequestHelper;
50 import com.android.server.healthconnect.storage.datatypehelpers.HealthDataCategoryPriorityHelper;
51 import com.android.server.healthconnect.storage.datatypehelpers.PreferenceHelper;
52 import com.android.server.healthconnect.storage.datatypehelpers.RecordHelper;
53 import com.android.server.healthconnect.storage.request.ReadTableRequest;
54 import com.android.server.healthconnect.storage.utils.InternalHealthConnectMappings;
55 import com.android.server.healthconnect.storage.utils.WhereClauses;
56 
57 import java.util.ArrayList;
58 import java.util.List;
59 import java.util.Map;
60 import java.util.Set;
61 import java.util.UUID;
62 import java.util.stream.Stream;
63 
64 /**
65  * Performs various operations on the Health Connect database for cloud backup.
66  *
67  * @hide
68  */
69 public class CloudBackupDatabaseHelper {
70     private final AppInfoHelper mAppInfoHelper;
71     private final TransactionManager mTransactionManager;
72     private final FitnessRecordReadHelper mFitnessRecordReadHelper;
73     private final HealthConnectMappings mHealthConnectMappings;
74     private final InternalHealthConnectMappings mInternalHealthConnectMappings;
75     private final ChangeLogsHelper mChangeLogsHelper;
76     private final ChangeLogsRequestHelper mChangeLogsRequestHelper;
77     private final RecordProtoConverter mRecordProtoConverter = new RecordProtoConverter();
78     private final CloudBackupSettingsHelper mSettingsHelper;
79     private final List<Integer> mRecordTypes;
80 
81     private static final String TAG = "CloudBackupRestoreDatabaseHelper";
82 
CloudBackupDatabaseHelper( TransactionManager transactionManager, FitnessRecordReadHelper fitnessRecordReadHelper, AppInfoHelper appInfoHelper, HealthConnectMappings healthConnectMappings, InternalHealthConnectMappings internalHealthConnectMappings, ChangeLogsHelper changeLogsHelper, ChangeLogsRequestHelper changeLogsRequestHelper, HealthDataCategoryPriorityHelper priorityHelper, PreferenceHelper preferenceHelper)83     public CloudBackupDatabaseHelper(
84             TransactionManager transactionManager,
85             FitnessRecordReadHelper fitnessRecordReadHelper,
86             AppInfoHelper appInfoHelper,
87             HealthConnectMappings healthConnectMappings,
88             InternalHealthConnectMappings internalHealthConnectMappings,
89             ChangeLogsHelper changeLogsHelper,
90             ChangeLogsRequestHelper changeLogsRequestHelper,
91             HealthDataCategoryPriorityHelper priorityHelper,
92             PreferenceHelper preferenceHelper) {
93         mTransactionManager = transactionManager;
94         mFitnessRecordReadHelper = fitnessRecordReadHelper;
95         mAppInfoHelper = appInfoHelper;
96         mHealthConnectMappings = healthConnectMappings;
97         mInternalHealthConnectMappings = internalHealthConnectMappings;
98         mChangeLogsHelper = changeLogsHelper;
99         mChangeLogsRequestHelper = changeLogsRequestHelper;
100         mSettingsHelper =
101                 new CloudBackupSettingsHelper(priorityHelper, preferenceHelper, appInfoHelper);
102         mRecordTypes =
103                 Stream.concat(
104                                 RECORD_TYPE_MIGRATION_ORDERING_OVERRIDES.stream()
105                                         .flatMap(List::stream),
106                                 mHealthConnectMappings
107                                         .getRecordIdToExternalRecordClassMap()
108                                         .keySet()
109                                         .stream())
110                         .distinct()
111                         .toList();
112     }
113 
114     /**
115      * Verifies whether the provided change logs token is still valid. The token is valid if the
116      * change log which is pointed by the token still exists.
117      */
isChangeLogsTokenValid(@ullable String changeLogsPageToken)118     boolean isChangeLogsTokenValid(@Nullable String changeLogsPageToken) {
119         if (changeLogsPageToken == null) {
120             Slog.i(TAG, "No change logs token found.");
121             return false;
122         }
123         ChangeLogsRequestHelper.TokenRequest tokenRequest =
124                 mChangeLogsRequestHelper.getRequest(/* packageName= */ "", changeLogsPageToken);
125         WhereClauses whereClauses =
126                 new WhereClauses(AND)
127                         .addWhereEqualsClause(
128                                 PRIMARY_COLUMN_NAME,
129                                 String.valueOf(tokenRequest.getRowIdChangeLogs()));
130         ReadTableRequest readTableRequest =
131                 new ReadTableRequest(ChangeLogsHelper.TABLE_NAME).setWhereClause(whereClauses);
132         try (Cursor cursor = mTransactionManager.read(readTableRequest)) {
133             int count = cursor.getCount();
134             Slog.i(TAG, "The number of matched change logs is: " + count);
135             return count == 1;
136         }
137     }
138 
139     /**
140      * Retrieves backup changes from the data tables, used for the initial call of a full data
141      * backup.
142      */
getChangesAndTokenFromDataTables()143     GetChangesForBackupResponse getChangesAndTokenFromDataTables() {
144         return getChangesAndTokenFromDataTables(
145                 RECORD_TYPE_UNKNOWN, EMPTY_PAGE_TOKEN.encode(), null);
146     }
147 
148     /**
149      * Retrieves backup changes from data tables, used for the subsequent calls of a full data
150      * backup.
151      */
getChangesAndTokenFromDataTables( @ecordTypeIdentifier.RecordType int dataRecordType, long dataTablePageToken, @Nullable String changeLogsPageToken)152     GetChangesForBackupResponse getChangesAndTokenFromDataTables(
153             @RecordTypeIdentifier.RecordType int dataRecordType,
154             long dataTablePageToken,
155             @Nullable String changeLogsPageToken) {
156         // For the first call of a full data backup, page token of the chane logs is passed as null
157         // so we generate one to be used for incremental backups. In subsequent calls of a full data
158         // backup, we just need to preserve the previous page token instead of creating a new one.
159         String changeLogsTablePageToken =
160                 changeLogsPageToken == null ? getChangeLogsPageToken() : changeLogsPageToken;
161 
162         List<BackupChange> backupChanges = new ArrayList<>();
163         long nextDataTablePageToken = dataTablePageToken;
164         int pageSize = DEFAULT_PAGE_SIZE;
165         int nextRecordType = dataRecordType;
166 
167         for (var recordType : mRecordTypes) {
168             RecordHelper<?> recordHelper =
169                     mInternalHealthConnectMappings.getRecordHelper(recordType);
170             if (nextRecordType != RECORD_TYPE_UNKNOWN && recordType != nextRecordType) {
171                 // Skip the current record type as it has already been backed up.
172                 continue;
173             }
174             Set<String> grantedExtraReadPermissions =
175                     Set.copyOf(recordHelper.getExtraReadPermissions());
176             while (pageSize > 0) {
177                 ReadRecordsRequestUsingFilters<? extends Record> readRecordsRequest =
178                         new ReadRecordsRequestUsingFilters.Builder<>(
179                                         mHealthConnectMappings
180                                                 .getRecordIdToExternalRecordClassMap()
181                                                 .get(recordType))
182                                 .setPageSize(pageSize)
183                                 .setPageToken(nextDataTablePageToken)
184                                 .build();
185                 Pair<List<RecordInternal<?>>, PageTokenWrapper> readResult =
186                         mFitnessRecordReadHelper.readRecordsUnrestricted(
187                                 mTransactionManager,
188                                 readRecordsRequest.toReadRecordsRequestParcel(),
189                                 /* packageNamesByAppIds= */ null);
190                 backupChanges.addAll(convertRecordsToBackupChange(readResult.first));
191                 nextDataTablePageToken = readResult.second.encode();
192                 pageSize = DEFAULT_PAGE_SIZE - backupChanges.size();
193                 nextRecordType = recordHelper.getRecordIdentifier();
194                 if (nextDataTablePageToken == EMPTY_PAGE_TOKEN.encode()) {
195                     int recordIndex = mRecordTypes.indexOf(recordType);
196                     // An empty page token indicates no more data in one data table, update the
197                     // data type to the next data type.
198                     if (recordIndex + 1 >= mRecordTypes.size()) {
199                         nextRecordType = RECORD_TYPE_UNKNOWN;
200                     } else {
201                         RecordHelper<?> nextRecordHelper =
202                                 mInternalHealthConnectMappings.getRecordHelper(
203                                         mRecordTypes.get(recordIndex + 1));
204                         nextRecordType = nextRecordHelper.getRecordIdentifier();
205                     }
206                     break;
207                 }
208             }
209             // Retrieved data reaches the max page size.
210             if (pageSize <= 0) {
211                 break;
212             }
213         }
214         String backupChangeTokenRowId =
215                 BackupChangeTokenHelper.getBackupChangeTokenRowId(
216                         mTransactionManager,
217                         nextRecordType,
218                         nextDataTablePageToken,
219                         changeLogsTablePageToken);
220         return new GetChangesForBackupResponse(
221                 PROTO_VERSION, backupChanges, backupChangeTokenRowId);
222     }
223 
getChangeLogsPageToken()224     private String getChangeLogsPageToken() {
225         long rowId = mChangeLogsHelper.getLatestRowId();
226         ChangeLogsRequestHelper.TokenRequest tokenRequest =
227                 new ChangeLogsRequestHelper.TokenRequest(
228                         List.of(),
229                         mRecordTypes,
230                         // Pass empty string to avoid package filters.
231                         /* requestingPackageName= */ "",
232                         rowId);
233         return mChangeLogsRequestHelper.getNextPageToken(tokenRequest, rowId);
234     }
235 
236     /** Gets incremental data changes based on change logs. */
getIncrementalChanges(@ullable String changeLogsPageToken)237     GetChangesForBackupResponse getIncrementalChanges(@Nullable String changeLogsPageToken) {
238         if (changeLogsPageToken == null) {
239             throw new IllegalStateException("No proper change logs token");
240         }
241         ChangeLogsRequestHelper.TokenRequest changeLogsTokenRequest =
242                 mChangeLogsRequestHelper.getRequest(/* packageName= */ "", changeLogsPageToken);
243         // Use the default page size (1000) for now.
244         ChangeLogsRequest request = new ChangeLogsRequest.Builder(changeLogsPageToken).build();
245         ChangeLogsHelper.ChangeLogsResponse changeLogsResponse =
246                 mChangeLogsHelper.getChangeLogs(
247                         mAppInfoHelper, changeLogsTokenRequest, request, mChangeLogsRequestHelper);
248 
249         // Only UUIDs for upsert requests are returned.
250         Map<Integer, List<UUID>> recordTypeToInsertedUuids =
251                 ChangeLogsHelper.getRecordTypeToInsertedUuids(
252                         changeLogsResponse.getChangeLogsMap());
253 
254         List<RecordInternal<?>> internalRecords =
255                 mFitnessRecordReadHelper.readRecordsUnrestricted(
256                         mTransactionManager, recordTypeToInsertedUuids);
257 
258         // Read the exercise sessions that refer to any training plans included in the changes and
259         // append them to the list of changes. This is to always have exercise sessions restore
260         // after planned sessions that they refer to.
261         var sessionIds = new ArrayList<UUID>();
262         for (var record : internalRecords) {
263             if (record instanceof PlannedExerciseSessionRecordInternal plannedSession) {
264                 var completedSessionId = plannedSession.getCompletedExerciseSessionId();
265                 if (completedSessionId != null) {
266                     sessionIds.add(completedSessionId);
267                 }
268             }
269         }
270         List<RecordInternal<?>> exerciseSessions =
271                 mFitnessRecordReadHelper.readRecordsUnrestricted(
272                         mTransactionManager,
273                         Map.of(RecordTypeIdentifier.RECORD_TYPE_EXERCISE_SESSION, sessionIds));
274         internalRecords.addAll(exerciseSessions);
275 
276         List<BackupChange> backupChanges =
277                 new ArrayList<>(convertRecordsToBackupChange(internalRecords));
278 
279         // Include UUIDs for all deleted records.
280         List<ChangeLogsResponse.DeletedLog> deletedLogs =
281                 ChangeLogsHelper.getDeletedLogs(changeLogsResponse.getChangeLogsMap());
282         backupChanges.addAll(convertDeletedLogsToBackupChange(deletedLogs));
283 
284         String backupChangeTokenRowId =
285                 BackupChangeTokenHelper.getBackupChangeTokenRowId(
286                         mTransactionManager,
287                         RECORD_TYPE_UNKNOWN,
288                         EMPTY_PAGE_TOKEN.encode(),
289                         changeLogsResponse.getNextPageToken());
290         return new GetChangesForBackupResponse(
291                 PROTO_VERSION, backupChanges, backupChangeTokenRowId);
292     }
293 
convertRecordsToBackupChange(List<RecordInternal<?>> records)294     private List<BackupChange> convertRecordsToBackupChange(List<RecordInternal<?>> records) {
295         return records.stream()
296                 .map(
297                         record -> {
298                             if (record.getUuid() == null) {
299                                 throw new IllegalStateException(
300                                         "Record does not have a UUID, this should not happen");
301                             }
302                             return BackupChange.ofUpsertion(
303                                     record.getUuid().toString(), serializeRecordInternal(record));
304                         })
305                 .toList();
306     }
307 
308     private List<BackupChange> convertDeletedLogsToBackupChange(
309             List<ChangeLogsResponse.DeletedLog> deletedLogs) {
310         return deletedLogs.stream()
311                 .map(deletedLog -> BackupChange.ofDeletion(deletedLog.getDeletedRecordId()))
312                 .toList();
313     }
314 
315     private byte[] serializeRecordInternal(RecordInternal<?> recordInternal) {
316         return BackupData.newBuilder()
317                 .setRecord(mRecordProtoConverter.toRecordProto(recordInternal))
318                 .build()
319                 .toByteArray();
320     }
321 }
322