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