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