• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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