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_INT; 20 import static android.health.connect.Constants.DEFAULT_LONG; 21 import static android.health.connect.Constants.MAXIMUM_ALLOWED_CURSOR_COUNT; 22 import static android.health.connect.Constants.MAXIMUM_PAGE_SIZE; 23 import static android.health.connect.Constants.PARENT_KEY; 24 import static android.health.connect.PageTokenWrapper.EMPTY_PAGE_TOKEN; 25 26 import static com.android.server.healthconnect.storage.datatypehelpers.IntervalRecordHelper.END_TIME_COLUMN_NAME; 27 import static com.android.server.healthconnect.storage.request.ReadTransactionRequest.TYPE_NOT_PRESENT_PACKAGE_NAME; 28 import static com.android.server.healthconnect.storage.utils.StorageUtils.BLOB_UNIQUE_NON_NULL; 29 import static com.android.server.healthconnect.storage.utils.StorageUtils.BLOB_UNIQUE_NULL; 30 import static com.android.server.healthconnect.storage.utils.StorageUtils.INTEGER; 31 import static com.android.server.healthconnect.storage.utils.StorageUtils.PRIMARY_AUTOINCREMENT; 32 import static com.android.server.healthconnect.storage.utils.StorageUtils.TEXT_NULL; 33 import static com.android.server.healthconnect.storage.utils.StorageUtils.getCursorInt; 34 import static com.android.server.healthconnect.storage.utils.StorageUtils.getCursorLong; 35 import static com.android.server.healthconnect.storage.utils.StorageUtils.getCursorString; 36 import static com.android.server.healthconnect.storage.utils.StorageUtils.getCursorUUID; 37 import static com.android.server.healthconnect.storage.utils.StorageUtils.getDedupeByteBuffer; 38 import static com.android.server.healthconnect.storage.utils.StorageUtils.supportsPriority; 39 import static com.android.server.healthconnect.storage.utils.WhereClauses.LogicalOperator.AND; 40 import static com.android.server.healthconnect.storage.utils.WhereClauses.LogicalOperator.OR; 41 42 import android.annotation.NonNull; 43 import android.content.ContentValues; 44 import android.database.Cursor; 45 import android.database.sqlite.SQLiteDatabase; 46 import android.health.connect.AggregateResult; 47 import android.health.connect.PageTokenWrapper; 48 import android.health.connect.aidl.ReadRecordsRequestParcel; 49 import android.health.connect.aidl.RecordIdFiltersParcel; 50 import android.health.connect.datatypes.AggregationType; 51 import android.health.connect.datatypes.RecordTypeIdentifier; 52 import android.health.connect.internal.datatypes.RecordInternal; 53 import android.health.connect.internal.datatypes.utils.RecordMapper; 54 import android.util.ArrayMap; 55 import android.util.Pair; 56 import android.util.Slog; 57 58 import androidx.annotation.Nullable; 59 60 import com.android.server.healthconnect.storage.request.AggregateParams; 61 import com.android.server.healthconnect.storage.request.AggregateTableRequest; 62 import com.android.server.healthconnect.storage.request.CreateTableRequest; 63 import com.android.server.healthconnect.storage.request.DeleteTableRequest; 64 import com.android.server.healthconnect.storage.request.ReadTableRequest; 65 import com.android.server.healthconnect.storage.request.UpsertTableRequest; 66 import com.android.server.healthconnect.storage.utils.OrderByClause; 67 import com.android.server.healthconnect.storage.utils.SqlJoin; 68 import com.android.server.healthconnect.storage.utils.StorageUtils; 69 import com.android.server.healthconnect.storage.utils.WhereClauses; 70 71 import java.lang.reflect.InvocationTargetException; 72 import java.time.Instant; 73 import java.time.temporal.ChronoUnit; 74 import java.util.ArrayList; 75 import java.util.Arrays; 76 import java.util.Collections; 77 import java.util.List; 78 import java.util.Map; 79 import java.util.Objects; 80 import java.util.Set; 81 import java.util.UUID; 82 83 /** 84 * Parent class for all the helper classes for all the records 85 * 86 * @hide 87 */ 88 public abstract class RecordHelper<T extends RecordInternal<?>> { 89 public static final String PRIMARY_COLUMN_NAME = "row_id"; 90 public static final String UUID_COLUMN_NAME = "uuid"; 91 public static final String CLIENT_RECORD_ID_COLUMN_NAME = "client_record_id"; 92 public static final String APP_INFO_ID_COLUMN_NAME = "app_info_id"; 93 public static final String LAST_MODIFIED_TIME_COLUMN_NAME = "last_modified_time"; 94 private static final String CLIENT_RECORD_VERSION_COLUMN_NAME = "client_record_version"; 95 private static final String DEVICE_INFO_ID_COLUMN_NAME = "device_info_id"; 96 private static final String RECORDING_METHOD_COLUMN_NAME = "recording_method"; 97 private static final String DEDUPE_HASH_COLUMN_NAME = "dedupe_hash"; 98 private static final List<Pair<String, Integer>> UNIQUE_COLUMNS_INFO = 99 List.of( 100 new Pair<>(DEDUPE_HASH_COLUMN_NAME, UpsertTableRequest.TYPE_BLOB), 101 new Pair<>(UUID_COLUMN_NAME, UpsertTableRequest.TYPE_BLOB)); 102 @RecordTypeIdentifier.RecordType private final int mRecordIdentifier; 103 RecordHelper(@ecordTypeIdentifier.RecordType int recordIdentifier)104 RecordHelper(@RecordTypeIdentifier.RecordType int recordIdentifier) { 105 mRecordIdentifier = recordIdentifier; 106 } 107 getDeleteRequestForAutoDelete(int recordAutoDeletePeriodInDays)108 public DeleteTableRequest getDeleteRequestForAutoDelete(int recordAutoDeletePeriodInDays) { 109 return new DeleteTableRequest(getMainTableName(), getRecordIdentifier()) 110 .setTimeFilter( 111 getStartTimeColumnName(), 112 Instant.EPOCH.toEpochMilli(), 113 Instant.now() 114 .minus(recordAutoDeletePeriodInDays, ChronoUnit.DAYS) 115 .toEpochMilli()) 116 .setPackageFilter(APP_INFO_ID_COLUMN_NAME, List.of()) 117 .setRequiresUuId(UUID_COLUMN_NAME); 118 } 119 120 /** Database migration. Introduces automatic local time generation. */ applyGeneratedLocalTimeUpgrade(@onNull SQLiteDatabase db)121 public abstract void applyGeneratedLocalTimeUpgrade(@NonNull SQLiteDatabase db); 122 123 @RecordTypeIdentifier.RecordType getRecordIdentifier()124 public int getRecordIdentifier() { 125 return mRecordIdentifier; 126 } 127 128 /** 129 * @return {@link AggregateTableRequest} corresponding to {@code aggregationType} 130 */ getAggregateTableRequest( AggregationType<?> aggregationType, String callingPackage, List<String> packageFilters, long startTime, long endTime, long startDateAccess, boolean useLocalTime)131 public final AggregateTableRequest getAggregateTableRequest( 132 AggregationType<?> aggregationType, 133 String callingPackage, 134 List<String> packageFilters, 135 long startTime, 136 long endTime, 137 long startDateAccess, 138 boolean useLocalTime) { 139 AppInfoHelper appInfoHelper = AppInfoHelper.getInstance(); 140 AggregateParams params = getAggregateParams(aggregationType); 141 String physicalTimeColumnName = getStartTimeColumnName(); 142 String startTimeColumnName = 143 useLocalTime ? getLocalStartTimeColumnName() : physicalTimeColumnName; 144 String endTimeColumnName = 145 useLocalTime ? getLocalEndTimeColumnName() : getEndTimeColumnName(); 146 params.setTimeColumnName(startTimeColumnName); 147 params.setExtraTimeColumn(endTimeColumnName); 148 params.setOffsetColumnToFetch(getZoneOffsetColumnName()); 149 150 if (supportsPriority(mRecordIdentifier, aggregationType.getAggregateOperationType())) { 151 List<String> columns = 152 Arrays.asList( 153 physicalTimeColumnName, 154 END_TIME_COLUMN_NAME, 155 APP_INFO_ID_COLUMN_NAME, 156 LAST_MODIFIED_TIME_COLUMN_NAME); 157 params.appendAdditionalColumns(columns); 158 } 159 if (StorageUtils.isDerivedType(mRecordIdentifier)) { 160 params.appendAdditionalColumns(Collections.singletonList(physicalTimeColumnName)); 161 } 162 163 WhereClauses whereClauses = new WhereClauses(AND); 164 // filters by package names 165 whereClauses.addWhereInLongsClause( 166 APP_INFO_ID_COLUMN_NAME, appInfoHelper.getAppInfoIds(packageFilters)); 167 // filter by start date access 168 whereClauses.addNestedWhereClauses( 169 getFilterByStartAccessDateWhereClauses( 170 appInfoHelper.getAppInfoId(callingPackage), startDateAccess)); 171 // data start time < filter end time 172 whereClauses.addWhereLessThanClause(startTimeColumnName, endTime); 173 if (endTimeColumnName != null) { 174 // for IntervalRecord, filters by overlapping 175 // data end time >= filter start time 176 whereClauses.addWhereGreaterThanOrEqualClause(endTimeColumnName, startTime); 177 } else { 178 // for InstantRecord, filters by whether time falls into [startTime, endTime) 179 whereClauses.addWhereGreaterThanOrEqualClause(startTimeColumnName, startTime); 180 } 181 182 return new AggregateTableRequest(params, aggregationType, this, whereClauses, useLocalTime) 183 .setTimeFilter(startTime, endTime); 184 } 185 186 /** 187 * Used to get the Aggregate result for aggregate types 188 * 189 * @return {@link AggregateResult} for {@link AggregationType} 190 */ 191 @SuppressWarnings("NullAway") // TODO(b/317029272): fix this suppression getAggregateResult( Cursor cursor, AggregationType<?> aggregationType)192 public AggregateResult<?> getAggregateResult( 193 Cursor cursor, AggregationType<?> aggregationType) { 194 return null; 195 } 196 197 /** 198 * Used to get the Aggregate result for aggregate types where the priority of apps is to be 199 * considered for overlapping data for sleep and activity interval records 200 * 201 * @return {@link AggregateResult} for {@link AggregationType} 202 */ 203 @SuppressWarnings("NullAway") // TODO(b/317029272): fix this suppression getAggregateResult( Cursor results, AggregationType<?> aggregationType, double total)204 public AggregateResult<?> getAggregateResult( 205 Cursor results, AggregationType<?> aggregationType, double total) { 206 return null; 207 } 208 209 /** 210 * Used to calculate and get aggregate results for data types that support derived aggregates 211 */ 212 @SuppressWarnings("NullAway") // TODO(b/317029272): fix this suppression deriveAggregate(Cursor cursor, AggregateTableRequest request)213 public double[] deriveAggregate(Cursor cursor, AggregateTableRequest request) { 214 return null; 215 } 216 217 /** 218 * Returns a requests representing the tables that should be created corresponding to this 219 * helper 220 */ 221 @NonNull getCreateTableRequest()222 public final CreateTableRequest getCreateTableRequest() { 223 return new CreateTableRequest(getMainTableName(), getColumnInfo()) 224 .addForeignKey( 225 DeviceInfoHelper.getInstance().getTableName(), 226 Collections.singletonList(DEVICE_INFO_ID_COLUMN_NAME), 227 Collections.singletonList(PRIMARY_COLUMN_NAME)) 228 .addForeignKey( 229 AppInfoHelper.TABLE_NAME, 230 Collections.singletonList(APP_INFO_ID_COLUMN_NAME), 231 Collections.singletonList(PRIMARY_COLUMN_NAME)) 232 .setChildTableRequests(getChildTableCreateRequests()) 233 .setGeneratedColumnInfo(getGeneratedColumnInfo()); 234 } 235 236 /** Gets {@link UpsertTableRequest} from {@code recordInternal}. */ 237 @SuppressWarnings("NullAway") // TODO(b/317029272): fix this suppression getUpsertTableRequest(RecordInternal<?> recordInternal)238 public UpsertTableRequest getUpsertTableRequest(RecordInternal<?> recordInternal) { 239 return getUpsertTableRequest(recordInternal, null); 240 } 241 242 @NonNull 243 @SuppressWarnings("unchecked") getUpsertTableRequest( RecordInternal<?> recordInternal, @Nullable ArrayMap<String, Boolean> extraWritePermissionToStateMap)244 public UpsertTableRequest getUpsertTableRequest( 245 RecordInternal<?> recordInternal, 246 @Nullable ArrayMap<String, Boolean> extraWritePermissionToStateMap) { 247 ContentValues upsertValues = getContentValues((T) recordInternal); 248 updateUpsertValuesIfRequired(upsertValues, extraWritePermissionToStateMap); 249 UpsertTableRequest upsertTableRequest = 250 new UpsertTableRequest(getMainTableName(), upsertValues, UNIQUE_COLUMNS_INFO) 251 .setRequiresUpdateClause( 252 new UpsertTableRequest.IRequiresUpdate() { 253 @Override 254 public boolean requiresUpdate( 255 Cursor cursor, 256 ContentValues contentValues, 257 UpsertTableRequest request) { 258 final UUID newUUID = 259 StorageUtils.convertBytesToUUID( 260 contentValues.getAsByteArray( 261 UUID_COLUMN_NAME)); 262 final UUID oldUUID = 263 StorageUtils.getCursorUUID( 264 cursor, UUID_COLUMN_NAME); 265 266 if (!Objects.equals(newUUID, oldUUID)) { 267 // Use old UUID in case of conflicts on de-dupe. 268 contentValues.put( 269 UUID_COLUMN_NAME, 270 StorageUtils.convertUUIDToBytes(oldUUID)); 271 request.getRecordInternal().setUuid(oldUUID); 272 // This means there was a duplication conflict, we want 273 // to update in this case. 274 return true; 275 } 276 277 long clientRecordVersion = 278 StorageUtils.getCursorLong( 279 cursor, CLIENT_RECORD_VERSION_COLUMN_NAME); 280 long newClientRecordVersion = 281 contentValues.getAsLong( 282 CLIENT_RECORD_VERSION_COLUMN_NAME); 283 284 return newClientRecordVersion >= clientRecordVersion; 285 } 286 }) 287 .setChildTableRequests(getChildTableUpsertRequests((T) recordInternal)) 288 .setPostUpsertCommands(getPostUpsertCommands(recordInternal)) 289 .setHelper(this) 290 .setExtraWritePermissionsStateMapping(extraWritePermissionToStateMap); 291 return upsertTableRequest; 292 } 293 294 /* Updates upsert content values based on extra permissions state. */ updateUpsertValuesIfRequired( @onNull ContentValues values, @Nullable ArrayMap<String, Boolean> extraWritePermissionToStateMap)295 protected void updateUpsertValuesIfRequired( 296 @NonNull ContentValues values, 297 @Nullable ArrayMap<String, Boolean> extraWritePermissionToStateMap) {} 298 299 /** 300 * Returns child tables and the columns within them that references their parents. This is used 301 * during updates to determine which child rows should be deleted. 302 */ getChildTablesWithRowsToBeDeletedDuringUpdate( @ullable ArrayMap<String, Boolean> extraWritePermissionToState)303 public List<TableColumnPair> getChildTablesWithRowsToBeDeletedDuringUpdate( 304 @Nullable ArrayMap<String, Boolean> extraWritePermissionToState) { 305 return getAllChildTables().stream().map(it -> new TableColumnPair(it, PARENT_KEY)).toList(); 306 } 307 308 @NonNull getAllChildTables()309 public List<String> getAllChildTables() { 310 List<String> childTables = new ArrayList<>(); 311 for (CreateTableRequest childTableCreateRequest : getChildTableCreateRequests()) { 312 populateWithTablesNames(childTableCreateRequest, childTables); 313 } 314 315 return childTables; 316 } 317 318 @NonNull getGeneratedColumnInfo()319 protected List<CreateTableRequest.GeneratedColumnInfo> getGeneratedColumnInfo() { 320 return Collections.emptyList(); 321 } 322 populateWithTablesNames( CreateTableRequest childTableCreateRequest, List<String> childTables)323 private void populateWithTablesNames( 324 CreateTableRequest childTableCreateRequest, List<String> childTables) { 325 childTables.add(childTableCreateRequest.getTableName()); 326 for (CreateTableRequest childTableRequest : 327 childTableCreateRequest.getChildTableRequests()) { 328 populateWithTablesNames(childTableRequest, childTables); 329 } 330 } 331 332 /** Returns ReadSingleTableRequest for {@code request} and package name {@code packageName} */ getReadTableRequest( ReadRecordsRequestParcel request, String callingPackageName, boolean enforceSelfRead, long startDateAccessMillis, Set<String> grantedExtraReadPermissions, boolean isInForeground)333 public ReadTableRequest getReadTableRequest( 334 ReadRecordsRequestParcel request, 335 String callingPackageName, 336 boolean enforceSelfRead, 337 long startDateAccessMillis, 338 Set<String> grantedExtraReadPermissions, 339 boolean isInForeground) { 340 return new ReadTableRequest(getMainTableName()) 341 .setJoinClause(getJoinForReadRequest()) 342 .setWhereClause( 343 getReadTableWhereClause( 344 request, 345 callingPackageName, 346 enforceSelfRead, 347 startDateAccessMillis)) 348 .setOrderBy(getOrderByClause(request)) 349 .setLimit(getLimitSize(request)) 350 .setRecordHelper(this) 351 .setExtraReadRequests( 352 getExtraDataReadRequests( 353 request, 354 callingPackageName, 355 startDateAccessMillis, 356 grantedExtraReadPermissions, 357 isInForeground)); 358 } 359 360 /** 361 * Logs metrics specific to a record type's insertion/update. 362 * 363 * @param recordInternals List of records being inserted/updated 364 * @param packageName Caller package name 365 */ logUpsertMetrics( @onNull List<RecordInternal<?>> recordInternals, @NonNull String packageName)366 public void logUpsertMetrics( 367 @NonNull List<RecordInternal<?>> recordInternals, @NonNull String packageName) { 368 // Do nothing, implement in record specific helpers 369 } 370 371 /** 372 * Logs metrics specific to a record type's read. 373 * 374 * @param recordInternals List of records being read 375 * @param packageName Caller package name 376 */ logReadMetrics( @onNull List<RecordInternal<?>> recordInternals, @NonNull String packageName)377 public void logReadMetrics( 378 @NonNull List<RecordInternal<?>> recordInternals, @NonNull String packageName) { 379 // Do nothing, implement in record specific helpers 380 } 381 382 /** Returns ReadTableRequest for {@code uuids} */ getReadTableRequest( String packageName, List<UUID> uuids, long startDateAccess, Set<String> grantedExtraReadPermissions, boolean isInForeground)383 public final ReadTableRequest getReadTableRequest( 384 String packageName, 385 List<UUID> uuids, 386 long startDateAccess, 387 Set<String> grantedExtraReadPermissions, 388 boolean isInForeground) { 389 return new ReadTableRequest(getMainTableName()) 390 .setJoinClause(getJoinForReadRequest()) 391 .setWhereClause( 392 new WhereClauses(AND) 393 .addWhereInClauseWithoutQuotes( 394 UUID_COLUMN_NAME, StorageUtils.getListOfHexStrings(uuids)) 395 .addWhereLaterThanTimeClause( 396 getStartTimeColumnName(), startDateAccess)) 397 .setRecordHelper(this) 398 .setExtraReadRequests( 399 getExtraDataReadRequests( 400 packageName, 401 uuids, 402 startDateAccess, 403 grantedExtraReadPermissions, 404 isInForeground)); 405 } 406 407 /** 408 * Returns a list of ReadSingleTableRequest for {@code request} and package name {@code 409 * packageName} to populate extra data. Called in database read requests. 410 */ getExtraDataReadRequests( ReadRecordsRequestParcel request, String packageName, long startDateAccess, Set<String> grantedExtraReadPermissions, boolean isInForeground)411 List<ReadTableRequest> getExtraDataReadRequests( 412 ReadRecordsRequestParcel request, 413 String packageName, 414 long startDateAccess, 415 Set<String> grantedExtraReadPermissions, 416 boolean isInForeground) { 417 return Collections.emptyList(); 418 } 419 420 /** 421 * Returns a list of ReadSingleTableRequest for {@code uuids} to populate extra data. Called in 422 * change logs read requests. 423 */ getExtraDataReadRequests( String packageName, List<UUID> uuids, long startDateAccess, Set<String> grantedExtraReadPermissions, boolean isInForeground)424 List<ReadTableRequest> getExtraDataReadRequests( 425 String packageName, 426 List<UUID> uuids, 427 long startDateAccess, 428 Set<String> grantedExtraReadPermissions, 429 boolean isInForeground) { 430 return Collections.emptyList(); 431 } 432 433 /** 434 * Returns ReadTableRequest for the record corresponding to this helper with a distinct clause 435 * on the input column names. 436 */ getReadTableRequestWithDistinctAppInfoIds()437 public ReadTableRequest getReadTableRequestWithDistinctAppInfoIds() { 438 return new ReadTableRequest(getMainTableName()) 439 .setColumnNames(new ArrayList<>(List.of(APP_INFO_ID_COLUMN_NAME))) 440 .setDistinctClause(true); 441 } 442 443 /** 444 * Returns List of Internal records from the cursor. If the cursor contains more than {@link 445 * MAXIMUM_ALLOWED_CURSOR_COUNT} records, it throws {@link IllegalArgumentException}. 446 */ getInternalRecords(Cursor cursor)447 public List<RecordInternal<?>> getInternalRecords(Cursor cursor) { 448 if (cursor.getCount() > MAXIMUM_ALLOWED_CURSOR_COUNT) { 449 throw new IllegalArgumentException( 450 "Too many records in the cursor. Max allowed: " + MAXIMUM_ALLOWED_CURSOR_COUNT); 451 } 452 List<RecordInternal<?>> recordInternalList = new ArrayList<>(); 453 while (cursor.moveToNext()) { 454 recordInternalList.add(getRecord(cursor, /* packageNamesByAppIds= */ null)); 455 } 456 return recordInternalList; 457 } 458 459 /** 460 * Returns a list of Internal records from the cursor up to the requested size, with pagination 461 * handled. 462 * 463 * @see #getNextInternalRecordsPageAndToken(Cursor, int, PageTokenWrapper, Map) 464 */ getNextInternalRecordsPageAndToken( Cursor cursor, int requestSize, PageTokenWrapper pageToken)465 public Pair<List<RecordInternal<?>>, PageTokenWrapper> getNextInternalRecordsPageAndToken( 466 Cursor cursor, int requestSize, PageTokenWrapper pageToken) { 467 return getNextInternalRecordsPageAndToken( 468 cursor, requestSize, pageToken, /* packageNamesByAppIds= */ null); 469 } 470 471 /** 472 * Returns List of Internal records from the cursor up to the requested size, with pagination 473 * handled. 474 * 475 * <p>Note that the cursor limit is set to {@code requestSize + offset + 1}, 476 * <li>+ offset: {@code offset} records has already been returned in previous page(s). See 477 * go/hc-page-token for details. 478 * <li>+ 1: if number of records queried is more than pageSize we know there are more records 479 * available to return for the next read. 480 * 481 * <p>Note that the cursor may contain more records that we need to return. Cursor limit set 482 * to sum of the following: 483 * <li>offset: {@code offset} records have already been returned in previous page(s), and should 484 * be skipped from this current page. In rare occasions (e.g. records deleted in between two 485 * reads), there are less than {@code offset} records, an empty list is returned, with no 486 * page token. 487 * <li>requestSize: {@code requestSize} records to return in the response. 488 * <li>one extra record: If there are more records than (offset+requestSize), a page token is 489 * returned for the next page. If not, then a default token is returned. 490 * 491 * @see #getLimitSize(ReadRecordsRequestParcel) 492 */ getNextInternalRecordsPageAndToken( Cursor cursor, int requestSize, PageTokenWrapper prevPageToken, @Nullable Map<Long, String> packageNamesByAppIds)493 public Pair<List<RecordInternal<?>>, PageTokenWrapper> getNextInternalRecordsPageAndToken( 494 Cursor cursor, 495 int requestSize, 496 PageTokenWrapper prevPageToken, 497 @Nullable Map<Long, String> packageNamesByAppIds) { 498 // Ignore <offset> records of the same start time, because it was returned in previous 499 // page(s). 500 // If the offset is greater than number of records in the cursor, it'll move to the last 501 // index and will not enter the while loop below. 502 long prevStartTime; 503 long currentStartTime = DEFAULT_LONG; 504 for (int i = 0; i < prevPageToken.offset(); i++) { 505 if (!cursor.moveToNext()) { 506 break; 507 } 508 prevStartTime = currentStartTime; 509 currentStartTime = getCursorLong(cursor, getStartTimeColumnName()); 510 if (prevStartTime != DEFAULT_LONG && prevStartTime != currentStartTime) { 511 // The current record should not be skipped 512 cursor.moveToPrevious(); 513 break; 514 } 515 } 516 517 currentStartTime = DEFAULT_LONG; 518 int offset = 0; 519 List<RecordInternal<?>> recordInternalList = new ArrayList<>(); 520 PageTokenWrapper nextPageToken = EMPTY_PAGE_TOKEN; 521 while (cursor.moveToNext()) { 522 prevStartTime = currentStartTime; 523 currentStartTime = getCursorLong(cursor, getStartTimeColumnName()); 524 if (currentStartTime != prevStartTime) { 525 offset = 0; 526 } 527 528 if (recordInternalList.size() >= requestSize) { 529 nextPageToken = 530 PageTokenWrapper.of(prevPageToken.isAscending(), currentStartTime, offset); 531 break; 532 } else { 533 T record = getRecord(cursor, packageNamesByAppIds); 534 recordInternalList.add(record); 535 offset++; 536 } 537 } 538 return Pair.create(recordInternalList, nextPageToken); 539 } 540 541 @SuppressWarnings("unchecked") // uncheck cast to T getRecord(Cursor cursor, @Nullable Map<Long, String> packageNamesByAppIds)542 private T getRecord(Cursor cursor, @Nullable Map<Long, String> packageNamesByAppIds) { 543 try { 544 @SuppressWarnings("NullAway") // TODO(b/317029272): fix this suppression 545 T record = 546 (T) 547 RecordMapper.getInstance() 548 .getRecordIdToInternalRecordClassMap() 549 .get(getRecordIdentifier()) 550 .getConstructor() 551 .newInstance(); 552 record.setUuid(getCursorUUID(cursor, UUID_COLUMN_NAME)); 553 record.setLastModifiedTime(getCursorLong(cursor, LAST_MODIFIED_TIME_COLUMN_NAME)); 554 record.setClientRecordId(getCursorString(cursor, CLIENT_RECORD_ID_COLUMN_NAME)); 555 record.setClientRecordVersion(getCursorLong(cursor, CLIENT_RECORD_VERSION_COLUMN_NAME)); 556 record.setRecordingMethod(getCursorInt(cursor, RECORDING_METHOD_COLUMN_NAME)); 557 record.setRowId(getCursorInt(cursor, PRIMARY_COLUMN_NAME)); 558 long deviceInfoId = getCursorLong(cursor, DEVICE_INFO_ID_COLUMN_NAME); 559 DeviceInfoHelper.getInstance().populateRecordWithValue(deviceInfoId, record); 560 long appInfoId = getCursorLong(cursor, APP_INFO_ID_COLUMN_NAME); 561 String packageName = 562 packageNamesByAppIds != null 563 ? packageNamesByAppIds.get(appInfoId) 564 : AppInfoHelper.getInstance().getPackageName(appInfoId); 565 record.setPackageName(packageName); 566 populateRecordValue(cursor, record); 567 568 return record; 569 } catch (InstantiationException 570 | IllegalAccessException 571 | NoSuchMethodException 572 | InvocationTargetException exception) { 573 Slog.e("HealthConnectRecordHelper", "Failed to read", exception); 574 throw new IllegalArgumentException(exception); 575 } 576 } 577 578 /** Returns is the read of this record type is enabled */ isRecordOperationsEnabled()579 public boolean isRecordOperationsEnabled() { 580 return true; 581 } 582 583 /** Populate internalRecords fields using extraDataCursor */ 584 @SuppressWarnings("unchecked") updateInternalRecordsWithExtraFields( List<RecordInternal<?>> internalRecords, Cursor cursorExtraData, String tableName)585 public void updateInternalRecordsWithExtraFields( 586 List<RecordInternal<?>> internalRecords, Cursor cursorExtraData, String tableName) { 587 readExtraData((List<T>) internalRecords, cursorExtraData, tableName); 588 } 589 getDeleteTableRequest( List<String> packageFilters, long startTime, long endTime, boolean usesLocalTimeFilter)590 public DeleteTableRequest getDeleteTableRequest( 591 List<String> packageFilters, 592 long startTime, 593 long endTime, 594 boolean usesLocalTimeFilter) { 595 final String timeColumnName = 596 usesLocalTimeFilter ? getLocalStartTimeColumnName() : getStartTimeColumnName(); 597 return new DeleteTableRequest(getMainTableName(), getRecordIdentifier()) 598 .setTimeFilter(timeColumnName, startTime, endTime) 599 .setPackageFilter( 600 APP_INFO_ID_COLUMN_NAME, 601 AppInfoHelper.getInstance().getAppInfoIds(packageFilters)) 602 .setRequiresUuId(UUID_COLUMN_NAME); 603 } 604 getDeleteTableRequest(List<UUID> ids)605 public DeleteTableRequest getDeleteTableRequest(List<UUID> ids) { 606 return new DeleteTableRequest(getMainTableName(), getRecordIdentifier()) 607 .setIds(UUID_COLUMN_NAME, StorageUtils.getListOfHexStrings(ids)) 608 .setRequiresUuId(UUID_COLUMN_NAME) 609 .setEnforcePackageCheck(APP_INFO_ID_COLUMN_NAME, UUID_COLUMN_NAME); 610 } 611 getDurationGroupByColumnName()612 public abstract String getDurationGroupByColumnName(); 613 getPeriodGroupByColumnName()614 public abstract String getPeriodGroupByColumnName(); 615 getStartTimeColumnName()616 public abstract String getStartTimeColumnName(); 617 getLocalStartTimeColumnName()618 public abstract String getLocalStartTimeColumnName(); 619 620 @SuppressWarnings("NullAway") // TODO(b/317029272): fix this suppression getLocalEndTimeColumnName()621 public String getLocalEndTimeColumnName() { 622 return null; 623 } 624 625 @SuppressWarnings("NullAway") // TODO(b/317029272): fix this suppression getEndTimeColumnName()626 public String getEndTimeColumnName() { 627 return null; 628 } 629 630 /** Populate internalRecords with extra data. */ readExtraData(List<T> internalRecords, Cursor cursorExtraData, String tableName)631 void readExtraData(List<T> internalRecords, Cursor cursorExtraData, String tableName) {} 632 633 /** 634 * Child classes should implement this if it wants to create additional tables, apart from the 635 * main table. 636 */ 637 @NonNull getChildTableCreateRequests()638 List<CreateTableRequest> getChildTableCreateRequests() { 639 return Collections.emptyList(); 640 } 641 642 /** Returns the table name to be created corresponding to this helper */ 643 @NonNull getMainTableName()644 public abstract String getMainTableName(); 645 646 /** Returns the information required to perform aggregate operation. */ 647 @SuppressWarnings("NullAway") // TODO(b/317029272): fix this suppression getAggregateParams(AggregationType<?> aggregateRequest)648 AggregateParams getAggregateParams(AggregationType<?> aggregateRequest) { 649 return null; 650 } 651 652 /** 653 * This implementation should return the column names with which the table should be created. 654 * 655 * <p>NOTE: New columns can only be added via onUpgrade. Why? Consider what happens if a table 656 * already exists on the device 657 * 658 * <p>PLEASE DON'T USE THIS METHOD TO ADD NEW COLUMNS 659 */ 660 @NonNull getSpecificColumnInfo()661 abstract List<Pair<String, String>> getSpecificColumnInfo(); 662 663 /** 664 * Child classes implementation should add the values of {@code recordInternal} that needs to be 665 * populated in the DB to {@code contentValues}. 666 */ populateContentValues( @onNull ContentValues contentValues, @NonNull T recordInternal)667 abstract void populateContentValues( 668 @NonNull ContentValues contentValues, @NonNull T recordInternal); 669 670 /** 671 * Child classes implementation should populate the values to the {@code record} using the 672 * cursor {@code cursor} queried from the DB . 673 */ populateRecordValue(@onNull Cursor cursor, @NonNull T recordInternal)674 abstract void populateRecordValue(@NonNull Cursor cursor, @NonNull T recordInternal); 675 getChildTableUpsertRequests(T record)676 List<UpsertTableRequest> getChildTableUpsertRequests(T record) { 677 return Collections.emptyList(); 678 } 679 680 @SuppressWarnings("NullAway") // TODO(b/317029272): fix this suppression getJoinForReadRequest()681 SqlJoin getJoinForReadRequest() { 682 return null; 683 } 684 getLimitSize(ReadRecordsRequestParcel request)685 private static int getLimitSize(ReadRecordsRequestParcel request) { 686 // Querying extra records on top of page size 687 // + pageOffset: <pageOffset> records has already been returned in previous page(s). See 688 // go/hc-page-token for details. 689 // + 1: if number of records queried is more than pageSize we know there are more records 690 // available to return for the next read. 691 if (request.getRecordIdFiltersParcel() == null) { 692 int pageOffset = 693 PageTokenWrapper.from(request.getPageToken(), request.isAscending()).offset(); 694 return request.getPageSize() + pageOffset + 1; 695 } else { 696 return MAXIMUM_PAGE_SIZE; 697 } 698 } 699 getReadTableWhereClause( ReadRecordsRequestParcel request, String callingPackageName, boolean enforceSelfRead, long startDateAccessMillis)700 final WhereClauses getReadTableWhereClause( 701 ReadRecordsRequestParcel request, 702 String callingPackageName, 703 boolean enforceSelfRead, 704 long startDateAccessMillis) { 705 AppInfoHelper appInfoHelper = AppInfoHelper.getInstance(); 706 long callingAppInfoId = appInfoHelper.getAppInfoId(callingPackageName); 707 RecordIdFiltersParcel recordIdFiltersParcel = request.getRecordIdFiltersParcel(); 708 if (recordIdFiltersParcel == null) { 709 List<Long> appInfoIds = 710 appInfoHelper.getAppInfoIds(request.getPackageFilters()).stream() 711 .distinct() 712 .toList(); 713 if (enforceSelfRead) { 714 appInfoIds = Collections.singletonList(callingAppInfoId); 715 } 716 if (appInfoIds.size() == 1 && appInfoIds.get(0) == DEFAULT_INT) { 717 throw new TypeNotPresentException(TYPE_NOT_PRESENT_PACKAGE_NAME, new Throwable()); 718 } 719 720 WhereClauses clauses = new WhereClauses(AND); 721 722 // package names filter 723 clauses.addWhereInLongsClause(APP_INFO_ID_COLUMN_NAME, appInfoIds); 724 725 // page token filter 726 PageTokenWrapper pageToken = 727 PageTokenWrapper.from(request.getPageToken(), request.isAscending()); 728 if (pageToken.isTimestampSet()) { 729 long timestamp = pageToken.timeMillis(); 730 if (pageToken.isAscending()) { 731 clauses.addWhereGreaterThanOrEqualClause(getStartTimeColumnName(), timestamp); 732 } else { 733 clauses.addWhereLessThanOrEqualClause(getStartTimeColumnName(), timestamp); 734 } 735 } 736 737 // start/end time filter 738 String timeColumnName = 739 request.usesLocalTimeFilter() 740 ? getLocalStartTimeColumnName() 741 : getStartTimeColumnName(); 742 long startTimeMillis = request.getStartTime(); 743 long endTimeMillis = request.getEndTime(); 744 if (startTimeMillis != DEFAULT_LONG) { 745 clauses.addWhereGreaterThanOrEqualClause(timeColumnName, startTimeMillis); 746 } 747 if (endTimeMillis != DEFAULT_LONG) { 748 clauses.addWhereLessThanClause(timeColumnName, endTimeMillis); 749 } 750 751 // start date access 752 clauses.addNestedWhereClauses( 753 getFilterByStartAccessDateWhereClauses( 754 callingAppInfoId, startDateAccessMillis)); 755 756 return clauses; 757 } 758 759 // Since for now we don't support mixing IDs and filters, we need to look for IDs now 760 List<UUID> ids = 761 recordIdFiltersParcel.getRecordIdFilters().stream() 762 .map( 763 (recordIdFilter) -> 764 StorageUtils.getUUIDFor(recordIdFilter, callingPackageName)) 765 .toList(); 766 WhereClauses filterByIdsWhereClauses = 767 new WhereClauses(AND) 768 .addWhereInClauseWithoutQuotes( 769 UUID_COLUMN_NAME, StorageUtils.getListOfHexStrings(ids)); 770 771 if (enforceSelfRead) { 772 if (callingAppInfoId == DEFAULT_LONG) { 773 throw new TypeNotPresentException(TYPE_NOT_PRESENT_PACKAGE_NAME, new Throwable()); 774 } 775 // if self read is enforced, startDateAccess must not be applied. 776 return filterByIdsWhereClauses.addWhereInLongsClause( 777 APP_INFO_ID_COLUMN_NAME, Collections.singletonList(callingAppInfoId)); 778 } else { 779 return filterByIdsWhereClauses.addNestedWhereClauses( 780 getFilterByStartAccessDateWhereClauses( 781 callingAppInfoId, startDateAccessMillis)); 782 } 783 } 784 785 /** 786 * Returns a {@link WhereClauses} that takes in to account start date access date & reading own 787 * data. 788 */ getFilterByStartAccessDateWhereClauses( long callingAppInfoId, long startDateAccessMillis)789 private WhereClauses getFilterByStartAccessDateWhereClauses( 790 long callingAppInfoId, long startDateAccessMillis) { 791 WhereClauses resultWhereClauses = new WhereClauses(OR); 792 793 // if the data point belongs to the calling app, then we should not enforce startDateAccess 794 resultWhereClauses.addWhereEqualsClause( 795 APP_INFO_ID_COLUMN_NAME, String.valueOf(callingAppInfoId)); 796 797 // Otherwise, we should enforce startDateAccess. Also we must use physical time column 798 // regardless whether local time filter is used or not. 799 String physicalTimeColumn = getStartTimeColumnName(); 800 resultWhereClauses.addWhereGreaterThanOrEqualClause( 801 physicalTimeColumn, startDateAccessMillis); 802 803 return resultWhereClauses; 804 } 805 getZoneOffsetColumnName()806 abstract String getZoneOffsetColumnName(); 807 getOrderByClause(ReadRecordsRequestParcel request)808 private OrderByClause getOrderByClause(ReadRecordsRequestParcel request) { 809 if (request.getRecordIdFiltersParcel() != null) { 810 return new OrderByClause(); 811 } 812 PageTokenWrapper pageToken = 813 PageTokenWrapper.from(request.getPageToken(), request.isAscending()); 814 return new OrderByClause() 815 .addOrderByClause(getStartTimeColumnName(), pageToken.isAscending()) 816 .addOrderByClause(PRIMARY_COLUMN_NAME, /* isAscending= */ true); 817 } 818 819 @NonNull getContentValues(@onNull T recordInternal)820 private ContentValues getContentValues(@NonNull T recordInternal) { 821 ContentValues recordContentValues = new ContentValues(); 822 823 recordContentValues.put( 824 UUID_COLUMN_NAME, StorageUtils.convertUUIDToBytes(recordInternal.getUuid())); 825 recordContentValues.put( 826 LAST_MODIFIED_TIME_COLUMN_NAME, recordInternal.getLastModifiedTime()); 827 recordContentValues.put(CLIENT_RECORD_ID_COLUMN_NAME, recordInternal.getClientRecordId()); 828 recordContentValues.put( 829 CLIENT_RECORD_VERSION_COLUMN_NAME, recordInternal.getClientRecordVersion()); 830 recordContentValues.put(RECORDING_METHOD_COLUMN_NAME, recordInternal.getRecordingMethod()); 831 recordContentValues.put(DEVICE_INFO_ID_COLUMN_NAME, recordInternal.getDeviceInfoId()); 832 recordContentValues.put(APP_INFO_ID_COLUMN_NAME, recordInternal.getAppInfoId()); 833 recordContentValues.put(DEDUPE_HASH_COLUMN_NAME, getDedupeByteBuffer(recordInternal)); 834 835 populateContentValues(recordContentValues, recordInternal); 836 837 return recordContentValues; 838 } 839 840 /** 841 * This implementation should return the column names with which the table should be created. 842 * 843 * <p>NOTE: New columns can only be added via onUpgrade. Why? Consider what happens if a table 844 * already exists on the device 845 * 846 * <p>PLEASE DON'T USE THIS METHOD TO ADD NEW COLUMNS 847 */ 848 @NonNull getColumnInfo()849 private List<Pair<String, String>> getColumnInfo() { 850 ArrayList<Pair<String, String>> columnInfo = new ArrayList<>(); 851 columnInfo.add(new Pair<>(PRIMARY_COLUMN_NAME, PRIMARY_AUTOINCREMENT)); 852 columnInfo.add(new Pair<>(UUID_COLUMN_NAME, BLOB_UNIQUE_NON_NULL)); 853 columnInfo.add(new Pair<>(LAST_MODIFIED_TIME_COLUMN_NAME, INTEGER)); 854 columnInfo.add(new Pair<>(CLIENT_RECORD_ID_COLUMN_NAME, TEXT_NULL)); 855 columnInfo.add(new Pair<>(CLIENT_RECORD_VERSION_COLUMN_NAME, TEXT_NULL)); 856 columnInfo.add(new Pair<>(DEVICE_INFO_ID_COLUMN_NAME, INTEGER)); 857 columnInfo.add(new Pair<>(APP_INFO_ID_COLUMN_NAME, INTEGER)); 858 columnInfo.add(new Pair<>(RECORDING_METHOD_COLUMN_NAME, INTEGER)); 859 columnInfo.add(new Pair<>(DEDUPE_HASH_COLUMN_NAME, BLOB_UNIQUE_NULL)); 860 861 columnInfo.addAll(getSpecificColumnInfo()); 862 863 return columnInfo; 864 } 865 866 /** Checks that operation with current record type are supported. */ checkRecordOperationsAreEnabled(RecordInternal<?> recordInternal)867 public void checkRecordOperationsAreEnabled(RecordInternal<?> recordInternal) {} 868 869 /** Returns permissions required to read extra record data. */ getExtraReadPermissions()870 public List<String> getExtraReadPermissions() { 871 return Collections.emptyList(); 872 } 873 874 /** Returns all extra permissions associated with current record type. */ getExtraWritePermissions()875 public List<String> getExtraWritePermissions() { 876 return Collections.emptyList(); 877 } 878 879 /** Returns extra permissions required to write given record. */ getRequiredExtraWritePermissions(RecordInternal<?> recordInternal)880 public List<String> getRequiredExtraWritePermissions(RecordInternal<?> recordInternal) { 881 return Collections.emptyList(); 882 } 883 884 /** 885 * Returns any SQL commands that should be executed after the provided record has been upserted. 886 */ getPostUpsertCommands(RecordInternal<?> record)887 List<String> getPostUpsertCommands(RecordInternal<?> record) { 888 return Collections.emptyList(); 889 } 890 891 /** 892 * When a record is deleted, this will be called. The read requests must return a cursor with 893 * {@link #UUID_COLUMN_NAME} and {@link #APP_INFO_ID_COLUMN_NAME} values. This information will 894 * be used to generate modification changelogs for each UUID. 895 * 896 * <p>A concrete example of when this is used is for training plans. The deletion of a training 897 * plan will nullify the 'plannedExerciseSessionId' field of any exercise sessions that 898 * referenced it. When a training plan is deleted, a read request is made on the exercise 899 * session table to find any exercise sessions that referenced it. 900 */ getReadRequestsForRecordsModifiedByDeletion( UUID deletedRecordUuid)901 public List<ReadTableRequest> getReadRequestsForRecordsModifiedByDeletion( 902 UUID deletedRecordUuid) { 903 return Collections.emptyList(); 904 } 905 906 /** 907 * When a record is upserted, this will be called. The read requests must return a cursor with a 908 * {@link #UUID_COLUMN_NAME} and {@link #APP_INFO_ID_COLUMN_NAME} values. This information will 909 * be used to generate modification changelogs for each UUID. 910 * 911 * <p>A concrete example of when this is used is for training plans. The upsertion of an 912 * exercise session may modify the 'completedSessionId' field of any planned sessions that 913 * referenced it. 914 */ getReadRequestsForRecordsModifiedByUpsertion( UUID upsertedRecordId, UpsertTableRequest upsertTableRequest)915 public List<ReadTableRequest> getReadRequestsForRecordsModifiedByUpsertion( 916 UUID upsertedRecordId, UpsertTableRequest upsertTableRequest) { 917 return Collections.emptyList(); 918 } 919 920 /** Represents a table and a column within that table. */ 921 public static final class TableColumnPair { TableColumnPair(String tableName, String columnName)922 TableColumnPair(String tableName, String columnName) { 923 this.mTableName = tableName; 924 this.mColumnName = columnName; 925 } 926 getTableName()927 public String getTableName() { 928 return mTableName; 929 } 930 getColumnName()931 public String getColumnName() { 932 return mColumnName; 933 } 934 935 private final String mTableName; 936 private final String mColumnName; 937 } 938 } 939