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