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