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