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