1 /* 2 * Copyright (C) 2022 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.storage.datatypehelpers; 18 19 import static android.health.connect.Constants.DEFAULT_LONG; 20 import static android.health.connect.Constants.DEFAULT_PAGE_SIZE; 21 import static android.health.connect.Constants.DELETE; 22 import static android.health.connect.Constants.UPSERT; 23 24 import static com.android.healthfitness.flags.AconfigFlagHelper.isCloudBackupRestoreEnabled; 25 import static com.android.server.healthconnect.storage.datatypehelpers.ChangeLogsRequestHelper.DEFAULT_CHANGE_LOG_TIME_PERIOD_IN_DAYS; 26 import static com.android.server.healthconnect.storage.datatypehelpers.ChangeLogsRequestHelper.NEW_CHANGE_LOG_TIME_PERIOD_IN_DAYS; 27 import static com.android.server.healthconnect.storage.datatypehelpers.RecordHelper.PRIMARY_COLUMN_NAME; 28 import static com.android.server.healthconnect.storage.utils.StorageUtils.BLOB_NON_NULL; 29 import static com.android.server.healthconnect.storage.utils.StorageUtils.INTEGER; 30 import static com.android.server.healthconnect.storage.utils.StorageUtils.PRIMARY_AUTOINCREMENT; 31 import static com.android.server.healthconnect.storage.utils.StorageUtils.getCursorInt; 32 import static com.android.server.healthconnect.storage.utils.StorageUtils.getCursorLong; 33 import static com.android.server.healthconnect.storage.utils.WhereClauses.LogicalOperator.AND; 34 35 import static java.lang.Integer.min; 36 37 import android.content.ContentValues; 38 import android.database.Cursor; 39 import android.health.connect.accesslog.AccessLog.OperationType; 40 import android.health.connect.changelog.ChangeLogsRequest; 41 import android.health.connect.changelog.ChangeLogsResponse.DeletedLog; 42 import android.health.connect.datatypes.RecordTypeIdentifier; 43 import android.util.ArrayMap; 44 import android.util.Pair; 45 46 import com.android.internal.annotations.VisibleForTesting; 47 import com.android.server.healthconnect.storage.TransactionManager; 48 import com.android.server.healthconnect.storage.request.CreateTableRequest; 49 import com.android.server.healthconnect.storage.request.DeleteTableRequest; 50 import com.android.server.healthconnect.storage.request.ReadTableRequest; 51 import com.android.server.healthconnect.storage.request.UpsertTableRequest; 52 import com.android.server.healthconnect.storage.utils.StorageUtils; 53 import com.android.server.healthconnect.storage.utils.WhereClauses; 54 55 import java.time.Instant; 56 import java.time.temporal.ChronoUnit; 57 import java.util.ArrayList; 58 import java.util.Collection; 59 import java.util.HashSet; 60 import java.util.List; 61 import java.util.Map; 62 import java.util.Objects; 63 import java.util.Set; 64 import java.util.UUID; 65 import java.util.stream.Collectors; 66 67 /** 68 * A helper class to fetch and store the change logs. 69 * 70 * @hide 71 */ 72 public final class ChangeLogsHelper extends DatabaseHelper { 73 public static final String TABLE_NAME = "change_logs_table"; 74 @VisibleForTesting public static final String RECORD_TYPE_COLUMN_NAME = "record_type"; 75 @VisibleForTesting public static final String APP_ID_COLUMN_NAME = "app_id"; 76 @VisibleForTesting public static final String UUIDS_COLUMN_NAME = "uuids"; 77 @VisibleForTesting public static final String OPERATION_TYPE_COLUMN_NAME = "operation_type"; 78 @VisibleForTesting public static final String TIME_COLUMN_NAME = "time"; 79 private static final int NUM_COLS = 5; 80 81 private final TransactionManager mTransactionManager; 82 ChangeLogsHelper( TransactionManager transactionManager, DatabaseHelpers databaseHelpers)83 public ChangeLogsHelper( 84 TransactionManager transactionManager, DatabaseHelpers databaseHelpers) { 85 super(databaseHelpers); 86 mTransactionManager = transactionManager; 87 } 88 getDeleteRequestForAutoDelete()89 public static DeleteTableRequest getDeleteRequestForAutoDelete() { 90 int changeLogTimePeriod = 91 isCloudBackupRestoreEnabled() 92 ? NEW_CHANGE_LOG_TIME_PERIOD_IN_DAYS 93 : DEFAULT_CHANGE_LOG_TIME_PERIOD_IN_DAYS; 94 return new DeleteTableRequest(TABLE_NAME) 95 .setTimeFilter( 96 TIME_COLUMN_NAME, 97 Instant.EPOCH.toEpochMilli(), 98 Instant.now().minus(changeLogTimePeriod, ChronoUnit.DAYS).toEpochMilli()); 99 } 100 getCreateTableRequest()101 public static CreateTableRequest getCreateTableRequest() { 102 return new CreateTableRequest(TABLE_NAME, getColumnInfo()) 103 .createIndexOn(RECORD_TYPE_COLUMN_NAME) 104 .createIndexOn(APP_ID_COLUMN_NAME); 105 } 106 107 /** Returns datatypes being written/updates in past 30 days. */ getRecordTypesWrittenInPast30Days()108 public Set<Integer> getRecordTypesWrittenInPast30Days() { 109 Set<Integer> recordTypesWrittenInPast30Days = new HashSet<>(); 110 WhereClauses whereClauses = 111 new WhereClauses(AND) 112 .addWhereEqualsClause( 113 OPERATION_TYPE_COLUMN_NAME, 114 String.valueOf(OperationType.OPERATION_TYPE_UPSERT)) 115 .addWhereGreaterThanOrEqualClause( 116 TIME_COLUMN_NAME, 117 Instant.now().minus(30, ChronoUnit.DAYS).toEpochMilli()); 118 119 final ReadTableRequest readTableRequest = 120 new ReadTableRequest(TABLE_NAME) 121 .setColumnNames(List.of(RECORD_TYPE_COLUMN_NAME)) 122 .setWhereClause(whereClauses) 123 .setDistinctClause(true); 124 125 try (Cursor cursor = mTransactionManager.read(readTableRequest)) { 126 while (cursor.moveToNext()) { 127 recordTypesWrittenInPast30Days.add(getCursorInt(cursor, RECORD_TYPE_COLUMN_NAME)); 128 } 129 } 130 return recordTypesWrittenInPast30Days; 131 } 132 133 @Override getMainTableName()134 protected String getMainTableName() { 135 return TABLE_NAME; 136 } 137 138 /** Returns change logs post the time when {@code changeLogTokenRequest} was generated */ getChangeLogs( AppInfoHelper appInfoHelper, ChangeLogsRequestHelper.TokenRequest changeLogTokenRequest, ChangeLogsRequest changeLogsRequest, ChangeLogsRequestHelper changeLogsRequestHelper)139 public ChangeLogsResponse getChangeLogs( 140 AppInfoHelper appInfoHelper, 141 ChangeLogsRequestHelper.TokenRequest changeLogTokenRequest, 142 ChangeLogsRequest changeLogsRequest, 143 ChangeLogsRequestHelper changeLogsRequestHelper) { 144 long token = changeLogTokenRequest.getRowIdChangeLogs(); 145 WhereClauses whereClause = 146 new WhereClauses(AND) 147 .addWhereGreaterThanClause(PRIMARY_COLUMN_NAME, String.valueOf(token)); 148 if (!changeLogTokenRequest.getRecordTypes().isEmpty()) { 149 whereClause.addWhereInIntsClause( 150 RECORD_TYPE_COLUMN_NAME, changeLogTokenRequest.getRecordTypes()); 151 } 152 153 if (!changeLogTokenRequest.getPackageNamesToFilter().isEmpty()) { 154 whereClause.addWhereInLongsClause( 155 APP_ID_COLUMN_NAME, 156 appInfoHelper.getAppInfoIds(changeLogTokenRequest.getPackageNamesToFilter())); 157 } 158 159 // We set limit size to requested pageSize plus extra 1 record so that if number of records 160 // queried is more than pageSize we know there are more records available to return for the 161 // next read. 162 int pageSize = changeLogsRequest.getPageSize(); 163 final ReadTableRequest readTableRequest = 164 new ReadTableRequest(TABLE_NAME).setWhereClause(whereClause).setLimit(pageSize + 1); 165 166 Map<Integer, ChangeLogs> operationToChangeLogMap = new ArrayMap<>(); 167 long nextChangesToken = DEFAULT_LONG; 168 boolean hasMoreRecords = false; 169 try (Cursor cursor = mTransactionManager.read(readTableRequest)) { 170 int count = 0; 171 while (cursor.moveToNext()) { 172 if (count >= pageSize) { 173 hasMoreRecords = true; 174 break; 175 } 176 count += addChangeLogs(cursor, operationToChangeLogMap); 177 nextChangesToken = getCursorInt(cursor, PRIMARY_COLUMN_NAME); 178 } 179 } 180 181 String nextToken = 182 nextChangesToken != DEFAULT_LONG 183 ? changeLogsRequestHelper.getNextPageToken( 184 changeLogTokenRequest, nextChangesToken) 185 : changeLogsRequest.getToken(); 186 187 return new ChangeLogsResponse(operationToChangeLogMap, nextToken, hasMoreRecords); 188 } 189 getLatestRowId()190 public long getLatestRowId() { 191 return mTransactionManager.runWithoutTransaction( 192 db -> { 193 return StorageUtils.getLastRowIdFor(db, TABLE_NAME); 194 }); 195 } 196 197 @SuppressWarnings("NullAway") // TODO(b/317029272): fix this suppression addChangeLogs(Cursor cursor, Map<Integer, ChangeLogs> changeLogs)198 private static int addChangeLogs(Cursor cursor, Map<Integer, ChangeLogs> changeLogs) { 199 @RecordTypeIdentifier.RecordType 200 int recordType = getCursorInt(cursor, RECORD_TYPE_COLUMN_NAME); 201 @OperationType.OperationTypes 202 int operationType = getCursorInt(cursor, OPERATION_TYPE_COLUMN_NAME); 203 List<UUID> uuidList = StorageUtils.getCursorUUIDList(cursor, UUIDS_COLUMN_NAME); 204 long appId = getCursorLong(cursor, APP_ID_COLUMN_NAME); 205 changeLogs.putIfAbsent( 206 operationType, 207 new ChangeLogs(operationType, getCursorLong(cursor, TIME_COLUMN_NAME))); 208 changeLogs.get(operationType).addUUIDs(recordType, appId, uuidList); 209 return uuidList.size(); 210 } 211 getColumnInfo()212 private static List<Pair<String, String>> getColumnInfo() { 213 List<Pair<String, String>> columnInfo = new ArrayList<>(NUM_COLS); 214 columnInfo.add(new Pair<>(PRIMARY_COLUMN_NAME, PRIMARY_AUTOINCREMENT)); 215 columnInfo.add(new Pair<>(RECORD_TYPE_COLUMN_NAME, INTEGER)); 216 columnInfo.add(new Pair<>(APP_ID_COLUMN_NAME, INTEGER)); 217 columnInfo.add(new Pair<>(UUIDS_COLUMN_NAME, BLOB_NON_NULL)); 218 columnInfo.add(new Pair<>(OPERATION_TYPE_COLUMN_NAME, INTEGER)); 219 columnInfo.add(new Pair<>(TIME_COLUMN_NAME, INTEGER)); 220 221 return columnInfo; 222 } 223 getDeletedLogs(Map<Integer, ChangeLogs> operationToChangeLogs)224 public static List<DeletedLog> getDeletedLogs(Map<Integer, ChangeLogs> operationToChangeLogs) { 225 ChangeLogs logs = operationToChangeLogs.get(DELETE); 226 227 if (!Objects.isNull(logs)) { 228 List<UUID> ids = logs.getUUIds(); 229 long timeStamp = logs.getChangeLogTimeStamp(); 230 List<DeletedLog> deletedLogs = new ArrayList<>(ids.size()); 231 for (UUID id : ids) { 232 deletedLogs.add(new DeletedLog(id.toString(), timeStamp)); 233 } 234 235 return deletedLogs; 236 } 237 return new ArrayList<>(); 238 } 239 getRecordTypeToInsertedUuids( Map<Integer, ChangeLogs> operationToChangeLogs)240 public static Map<Integer, List<UUID>> getRecordTypeToInsertedUuids( 241 Map<Integer, ChangeLogs> operationToChangeLogs) { 242 ChangeLogs logs = operationToChangeLogs.getOrDefault(UPSERT, null); 243 244 if (!Objects.isNull(logs)) { 245 return logs.getRecordTypeToUUIDMap(); 246 } 247 248 return new ArrayMap<>(0); 249 } 250 251 public static final class ChangeLogs { 252 private final Map<RecordTypeAndAppIdPair, List<UUID>> mRecordTypeAndAppIdToUUIDMap = 253 new ArrayMap<>(); 254 @OperationType.OperationTypes private final int mOperationType; 255 private final long mChangeLogTimeStamp; 256 257 /** 258 * Creates a change logs object used to add a new change log for {@code operationType} 259 * logged at time {@code timeStamp } 260 * 261 * @param operationType Type of the operation for which change log is added whether insert 262 * or delete. 263 * @param timeStamp Time when the change log is added. 264 */ ChangeLogs(@perationType.OperationTypes int operationType, long timeStamp)265 public ChangeLogs(@OperationType.OperationTypes int operationType, long timeStamp) { 266 mOperationType = operationType; 267 mChangeLogTimeStamp = timeStamp; 268 } 269 getRecordTypeToUUIDMap()270 private Map<Integer, List<UUID>> getRecordTypeToUUIDMap() { 271 Map<Integer, List<UUID>> recordTypeToUUIDMap = new ArrayMap<>(); 272 mRecordTypeAndAppIdToUUIDMap.forEach( 273 (recordTypeAndAppIdPair, uuids) -> { 274 recordTypeToUUIDMap.putIfAbsent( 275 recordTypeAndAppIdPair.getRecordType(), new ArrayList<>()); 276 Objects.requireNonNull( 277 recordTypeToUUIDMap.get( 278 recordTypeAndAppIdPair.getRecordType())) 279 .addAll(uuids); 280 }); 281 return recordTypeToUUIDMap; 282 } 283 getUUIds()284 public List<UUID> getUUIds() { 285 return mRecordTypeAndAppIdToUUIDMap.values().stream() 286 .flatMap(Collection::stream) 287 .collect(Collectors.toList()); 288 } 289 getChangeLogTimeStamp()290 public long getChangeLogTimeStamp() { 291 return mChangeLogTimeStamp; 292 } 293 294 /** Function to add an uuid corresponding to given pair of @recordType and @appId */ 295 @SuppressWarnings("NullAway") // TODO(b/317029272): fix this suppression addUUID( @ecordTypeIdentifier.RecordType int recordType, long appId, UUID uuid)296 public void addUUID( 297 @RecordTypeIdentifier.RecordType int recordType, long appId, UUID uuid) { 298 Objects.requireNonNull(uuid); 299 300 RecordTypeAndAppIdPair recordTypeAndAppIdPair = 301 new RecordTypeAndAppIdPair(recordType, appId); 302 mRecordTypeAndAppIdToUUIDMap.putIfAbsent(recordTypeAndAppIdPair, new ArrayList<>()); 303 mRecordTypeAndAppIdToUUIDMap.get(recordTypeAndAppIdPair).add(uuid); 304 } 305 306 /** 307 * @return List of {@link UpsertTableRequest} for change log table as per {@code 308 * mRecordTypeAndAppIdPairToUUIDMap} 309 */ getUpsertTableRequests()310 public List<UpsertTableRequest> getUpsertTableRequests() { 311 List<UpsertTableRequest> requests = 312 new ArrayList<>(mRecordTypeAndAppIdToUUIDMap.size()); 313 mRecordTypeAndAppIdToUUIDMap.forEach( 314 (recordTypeAndAppIdPair, uuids) -> { 315 for (int i = 0; i < uuids.size(); i += DEFAULT_PAGE_SIZE) { 316 ContentValues contentValues = new ContentValues(); 317 contentValues.put( 318 RECORD_TYPE_COLUMN_NAME, 319 recordTypeAndAppIdPair.getRecordType()); 320 contentValues.put( 321 APP_ID_COLUMN_NAME, recordTypeAndAppIdPair.getAppId()); 322 contentValues.put(OPERATION_TYPE_COLUMN_NAME, mOperationType); 323 contentValues.put(TIME_COLUMN_NAME, mChangeLogTimeStamp); 324 contentValues.put( 325 UUIDS_COLUMN_NAME, 326 StorageUtils.getSingleByteArray( 327 uuids.subList( 328 i, min(i + DEFAULT_PAGE_SIZE, uuids.size())))); 329 requests.add(new UpsertTableRequest(TABLE_NAME, contentValues)); 330 } 331 }); 332 return requests; 333 } 334 335 /** Adds {@code uuids} to {@link ChangeLogs}. */ 336 @SuppressWarnings("NullAway") // TODO(b/317029272): fix this suppression addUUIDs( @ecordTypeIdentifier.RecordType int recordType, long appId, List<UUID> uuids)337 public ChangeLogs addUUIDs( 338 @RecordTypeIdentifier.RecordType int recordType, long appId, List<UUID> uuids) { 339 RecordTypeAndAppIdPair recordTypeAndAppIdPair = 340 new RecordTypeAndAppIdPair(recordType, appId); 341 mRecordTypeAndAppIdToUUIDMap.putIfAbsent(recordTypeAndAppIdPair, new ArrayList<>()); 342 mRecordTypeAndAppIdToUUIDMap.get(recordTypeAndAppIdPair).addAll(uuids); 343 return this; 344 } 345 346 /** A helper class to create a pair of recordType and appId */ 347 private static final class RecordTypeAndAppIdPair { 348 private final int mRecordType; 349 private final long mAppId; 350 RecordTypeAndAppIdPair(int recordType, long appId)351 private RecordTypeAndAppIdPair(int recordType, long appId) { 352 mRecordType = recordType; 353 mAppId = appId; 354 } 355 getRecordType()356 public int getRecordType() { 357 return mRecordType; 358 } 359 getAppId()360 public long getAppId() { 361 return mAppId; 362 } 363 equals(Object obj)364 public boolean equals(Object obj) { 365 if (this == obj) return true; 366 if (obj == null || obj.getClass() != this.getClass()) return false; 367 RecordTypeAndAppIdPair recordTypeAndAppIdPair = (RecordTypeAndAppIdPair) obj; 368 return (recordTypeAndAppIdPair.mRecordType == this.mRecordType 369 && recordTypeAndAppIdPair.mAppId == this.mAppId); 370 } 371 hashCode()372 public int hashCode() { 373 return Objects.hash(this.mRecordType, this.mAppId); 374 } 375 } 376 } 377 378 /** A class to represent the token for pagination for the change logs response */ 379 public static final class ChangeLogsResponse { 380 private final Map<Integer, ChangeLogsHelper.ChangeLogs> mChangeLogsMap; 381 private final String mNextPageToken; 382 private final boolean mHasMorePages; 383 ChangeLogsResponse( Map<Integer, ChangeLogsHelper.ChangeLogs> changeLogsMap, String nextPageToken, boolean hasMorePages)384 public ChangeLogsResponse( 385 Map<Integer, ChangeLogsHelper.ChangeLogs> changeLogsMap, 386 String nextPageToken, 387 boolean hasMorePages) { 388 mChangeLogsMap = changeLogsMap; 389 mNextPageToken = nextPageToken; 390 mHasMorePages = hasMorePages; 391 } 392 393 /** Returns map of operation type to change logs */ getChangeLogsMap()394 public Map<Integer, ChangeLogs> getChangeLogsMap() { 395 return mChangeLogsMap; 396 } 397 398 /** Returns the next page token for the change logs */ getNextPageToken()399 public String getNextPageToken() { 400 return mNextPageToken; 401 } 402 403 /** Returns true if there are more change logs to be read */ hasMorePages()404 public boolean hasMorePages() { 405 return mHasMorePages; 406 } 407 } 408 } 409