1 /*
2  * Copyright (C) 2023 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.aggregation;
18 
19 import static android.health.connect.datatypes.AggregationType.AggregationTypeIdentifier.ACTIVE_CALORIES_BURNED_RECORD_ACTIVE_CALORIES_TOTAL;
20 import static android.health.connect.datatypes.AggregationType.AggregationTypeIdentifier.DISTANCE_RECORD_DISTANCE_TOTAL;
21 import static android.health.connect.datatypes.AggregationType.AggregationTypeIdentifier.ELEVATION_RECORD_ELEVATION_GAINED_TOTAL;
22 import static android.health.connect.datatypes.AggregationType.AggregationTypeIdentifier.EXERCISE_SESSION_DURATION_TOTAL;
23 import static android.health.connect.datatypes.AggregationType.AggregationTypeIdentifier.FLOORS_CLIMBED_RECORD_FLOORS_CLIMBED_TOTAL;
24 import static android.health.connect.datatypes.AggregationType.AggregationTypeIdentifier.SLEEP_SESSION_DURATION_TOTAL;
25 import static android.health.connect.datatypes.AggregationType.AggregationTypeIdentifier.STEPS_RECORD_COUNT_TOTAL;
26 import static android.health.connect.datatypes.AggregationType.AggregationTypeIdentifier.WHEEL_CHAIR_PUSHES_RECORD_COUNT_TOTAL;
27 
28 import android.database.Cursor;
29 import android.health.connect.Constants;
30 import android.health.connect.datatypes.AggregationType;
31 import android.util.ArrayMap;
32 import android.util.Slog;
33 
34 import androidx.annotation.Nullable;
35 
36 import com.android.internal.annotations.VisibleForTesting;
37 import com.android.server.healthconnect.HealthConnectDeviceConfigManager;
38 import com.android.server.healthconnect.storage.request.AggregateParams;
39 
40 import java.time.ZoneOffset;
41 import java.util.List;
42 import java.util.Map;
43 import java.util.TreeSet;
44 
45 /**
46  * Aggregates records with priorities.
47  *
48  * @hide
49  */
50 public class PriorityRecordsAggregator {
51     static final String TAG = "HealthPriorityRecordsAggregator";
52 
53     private final List<Long> mGroupSplits;
54     private final Map<Long, Integer> mAppIdToPriority;
55     private final Map<Integer, Double> mGroupToAggregationResult;
56     private final Map<Integer, ZoneOffset> mGroupToFirstZoneOffset;
57     private final int mNumberOfGroups;
58     private int mCurrentGroup = -1;
59     private long mLatestPopulatedStart = Long.MIN_VALUE;
60     @AggregationType.AggregationTypeIdentifier private final int mAggregationType;
61 
62     private final TreeSet<AggregationTimestamp> mTimestampsBuffer;
63     private final TreeSet<AggregationRecordData> mOpenIntervals;
64 
65     private final AggregateParams.PriorityAggregationExtraParams mExtraParams;
66 
67     private final boolean mUseLocalTime;
68 
PriorityRecordsAggregator( List<Long> groupSplits, List<Long> appIdPriorityList, @AggregationType.AggregationTypeIdentifier int aggregationType, AggregateParams.PriorityAggregationExtraParams extraParams, boolean useLocalTime)69     public PriorityRecordsAggregator(
70             List<Long> groupSplits,
71             List<Long> appIdPriorityList,
72             @AggregationType.AggregationTypeIdentifier int aggregationType,
73             AggregateParams.PriorityAggregationExtraParams extraParams,
74             boolean useLocalTime) {
75         mGroupSplits = groupSplits;
76         mAggregationType = aggregationType;
77         mExtraParams = extraParams;
78         mAppIdToPriority = new ArrayMap<>();
79         for (int i = 0; i < appIdPriorityList.size(); i++) {
80             // Add to the map with -index, so app with higher priority has higher value in the map.
81             mAppIdToPriority.put(appIdPriorityList.get(i), appIdPriorityList.size() - i);
82         }
83         mUseLocalTime = useLocalTime;
84         mTimestampsBuffer = new TreeSet<>();
85         mNumberOfGroups = mGroupSplits.size() - 1;
86         mGroupToFirstZoneOffset = new ArrayMap<>(mNumberOfGroups);
87         mOpenIntervals = new TreeSet<>();
88         mGroupToAggregationResult = new ArrayMap<>(mGroupSplits.size());
89 
90         if (Constants.DEBUG) {
91             Slog.d(
92                     TAG,
93                     "Aggregation request for splits: "
94                             + mGroupSplits
95                             + " with priorities: "
96                             + appIdPriorityList);
97         }
98     }
99 
100     /** Calculates aggregation result for each group. */
calculateAggregation(Cursor cursor)101     public void calculateAggregation(Cursor cursor) {
102         initialiseTimestampsBuffer(cursor);
103         populateTimestampBuffer(cursor);
104         AggregationTimestamp scanPoint, nextPoint;
105         while (mTimestampsBuffer.size() > 1) {
106             scanPoint = mTimestampsBuffer.pollFirst();
107             nextPoint = mTimestampsBuffer.first();
108             if (scanPoint.getType() == AggregationTimestamp.GROUP_BORDER) {
109                 mCurrentGroup += 1;
110             } else if (scanPoint.getType() == AggregationTimestamp.INTERVAL_START) {
111                 mOpenIntervals.add(scanPoint.getParentData());
112             } else if (scanPoint.getType() == AggregationTimestamp.INTERVAL_END) {
113                 mOpenIntervals.remove(scanPoint.getParentData());
114             } else {
115                 throw new UnsupportedOperationException(
116                         "Unknown aggregation timestamp type: " + scanPoint.getType());
117             }
118             updateAggregationResult(scanPoint, nextPoint);
119             populateTimestampBuffer(cursor);
120         }
121 
122         if (Constants.DEBUG) {
123             Slog.d(TAG, "Aggregation result: " + mGroupToAggregationResult.toString());
124         }
125     }
126 
populateTimestampBuffer(Cursor cursor)127     private void populateTimestampBuffer(Cursor cursor) {
128         // Buffer populating strategy guarantees that at the moment we start to process the earliest
129         // record, we added to the buffer later overlapping records and the first non-overlapping
130         // record. It guarantees that the aggregation score can be calculated correctly for any
131         // timestamp within the earliest record interval.
132         if (mTimestampsBuffer.first().getType() != AggregationTimestamp.INTERVAL_START) {
133             return;
134         }
135 
136         // Add record timestamps to buffer until latest buffer record do not overlap with earliest
137         // buffer record.
138         long expansionBorder = mTimestampsBuffer.first().getParentData().getEndTime();
139         if (Constants.DEBUG) {
140             Slog.d(
141                     TAG,
142                     "Try to update buffer exp border: "
143                             + expansionBorder
144                             + " latest start: "
145                             + mLatestPopulatedStart);
146         }
147 
148         while (mLatestPopulatedStart <= expansionBorder && cursor.moveToNext()) {
149             AggregationRecordData data = readNewDataAndMaybeAddToBuffer(cursor);
150             if (data != null) {
151                 mLatestPopulatedStart = data.getStartTime();
152 
153                 if (Constants.DEBUG) {
154                     Slog.d(TAG, "Updated buffer with : " + data);
155                 }
156             }
157         }
158 
159         if (Constants.DEBUG) {
160             Slog.d(TAG, "Timestamps buffer: " + mTimestampsBuffer);
161         }
162     }
163 
initialiseTimestampsBuffer(Cursor cursor)164     private void initialiseTimestampsBuffer(Cursor cursor) {
165         for (Long groupSplit : mGroupSplits) {
166             mTimestampsBuffer.add(
167                     new AggregationTimestamp(AggregationTimestamp.GROUP_BORDER, groupSplit));
168         }
169 
170         while (cursor.moveToNext()) {
171             AggregationRecordData data = readNewDataAndMaybeAddToBuffer(cursor);
172             if (data != null) {
173                 break;
174             }
175         }
176 
177         if (Constants.DEBUG) {
178             Slog.d(TAG, "Initialised aggregation buffer: " + mTimestampsBuffer);
179         }
180     }
181 
182     @Nullable
readNewDataAndMaybeAddToBuffer(Cursor cursor)183     private AggregationRecordData readNewDataAndMaybeAddToBuffer(Cursor cursor) {
184         AggregationRecordData data = readNewData(cursor);
185         int priority = data.getPriority();
186 
187         if (HealthConnectDeviceConfigManager.getInitialisedInstance()
188                         .isAggregationSourceControlsEnabled()
189                 && priority == Integer.MIN_VALUE) {
190             return null;
191         }
192 
193         // TODO(b/313924267): workaround for b/308467442, should be remove once we have a long term
194         // solution
195         if (data.getStartTime() > data.getEndTime()) {
196             // skip records with start time > end time to keep the algorithm functional
197             return null;
198         }
199 
200         mTimestampsBuffer.add(data.getStartTimestamp());
201         mTimestampsBuffer.add(data.getEndTimestamp());
202         return data;
203     }
204 
205     @VisibleForTesting
readNewData(Cursor cursor)206     AggregationRecordData readNewData(Cursor cursor) {
207         AggregationRecordData data = createAggregationRecordData();
208         data.populateAggregationData(cursor, mUseLocalTime, mAppIdToPriority);
209         return data;
210     }
211 
212     /** Returns result for the given group */
213     @SuppressWarnings("NullAway") // TODO(b/317029272): fix this suppression
getResultForGroup(Integer groupNumber)214     public Double getResultForGroup(Integer groupNumber) {
215         return mGroupToAggregationResult.get(groupNumber);
216     }
217 
218     /** Returns start time zone offset for the given group */
219     @SuppressWarnings("NullAway") // TODO(b/317029272): fix this suppression
getZoneOffsetForGroup(Integer groupNumber)220     public ZoneOffset getZoneOffsetForGroup(Integer groupNumber) {
221         return mGroupToFirstZoneOffset.get(groupNumber);
222     }
223 
createAggregationRecordData()224     private AggregationRecordData createAggregationRecordData() {
225         return switch (mAggregationType) {
226             case STEPS_RECORD_COUNT_TOTAL,
227                             ACTIVE_CALORIES_BURNED_RECORD_ACTIVE_CALORIES_TOTAL,
228                             DISTANCE_RECORD_DISTANCE_TOTAL,
229                             ELEVATION_RECORD_ELEVATION_GAINED_TOTAL,
230                             FLOORS_CLIMBED_RECORD_FLOORS_CLIMBED_TOTAL,
231                             WHEEL_CHAIR_PUSHES_RECORD_COUNT_TOTAL ->
232                     new ValueColumnAggregationData(
233                             mExtraParams.getColumnToAggregateName(),
234                             mExtraParams.getColumnToAggregateType());
235             case SLEEP_SESSION_DURATION_TOTAL, EXERCISE_SESSION_DURATION_TOTAL ->
236                     new SessionDurationAggregationData(
237                             mExtraParams.getExcludeIntervalStartColumnName(),
238                             mExtraParams.getExcludeIntervalEndColumnName());
239             default ->
240                     throw new UnsupportedOperationException(
241                             "Priority aggregation do not support type: " + mAggregationType);
242         };
243     }
244 
updateAggregationResult( AggregationTimestamp startPoint, AggregationTimestamp endPoint)245     private void updateAggregationResult(
246             AggregationTimestamp startPoint, AggregationTimestamp endPoint) {
247         if (Constants.DEBUG) {
248             Slog.d(
249                     TAG,
250                     "Updating result for group "
251                             + mCurrentGroup
252                             + " for interval: ("
253                             + startPoint.getTime()
254                             + ", "
255                             + endPoint.getTime()
256                             + ")");
257         }
258 
259         if (mOpenIntervals.isEmpty() || mCurrentGroup < 0 || mCurrentGroup >= mNumberOfGroups) {
260             if (Constants.DEBUG) {
261                 Slog.d(TAG, "No open intervals or current group: " + mCurrentGroup);
262             }
263             return;
264         }
265 
266         if (startPoint.getTime() == endPoint.getTime()
267                 && startPoint.getType() == AggregationTimestamp.GROUP_BORDER
268                 && endPoint.getType() == AggregationTimestamp.INTERVAL_END) {
269             // Don't create new aggregation result as no open intervals in this group so far.
270             return;
271         }
272 
273         if (!mGroupToAggregationResult.containsKey(mCurrentGroup)) {
274             mGroupToAggregationResult.put(mCurrentGroup, 0.0d);
275         }
276 
277         if (Constants.DEBUG) {
278             Slog.d(TAG, "Update result with: " + mOpenIntervals.last());
279         }
280 
281         mGroupToAggregationResult.put(
282                 mCurrentGroup,
283                 mGroupToAggregationResult.get(mCurrentGroup)
284                         + mOpenIntervals.last().getResultOnInterval(startPoint, endPoint));
285 
286         if (mCurrentGroup >= 0
287                 && !mGroupToFirstZoneOffset.containsKey(mCurrentGroup)
288                 && !mOpenIntervals.isEmpty()) {
289             mGroupToFirstZoneOffset.put(mCurrentGroup, getZoneOffsetOfEarliestOpenInterval());
290         }
291     }
292 
getZoneOffsetOfEarliestOpenInterval()293     private ZoneOffset getZoneOffsetOfEarliestOpenInterval() {
294         AggregationRecordData earliestInterval = mOpenIntervals.first();
295         for (AggregationRecordData data : mOpenIntervals) {
296             if (data.getStartTime() < earliestInterval.getStartTime()) {
297                 earliestInterval = data;
298             }
299         }
300         return earliestInterval.getStartTimeZoneOffset();
301     }
302 }
303