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