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