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