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.safetycenter; 18 19 import static android.safetycenter.SafetyCenterManager.REFRESH_REASON_RESCAN_BUTTON_CLICK; 20 21 import static com.android.permission.PermissionStatsLog.SAFETY_CENTER_SYSTEM_EVENT_REPORTED__RESULT__TIMEOUT; 22 import static com.android.safetycenter.logging.SafetyCenterStatsdLogger.toSystemEventResult; 23 24 import android.annotation.ElapsedRealtimeLong; 25 import android.annotation.UserIdInt; 26 import android.content.Context; 27 import android.os.SystemClock; 28 import android.safetycenter.SafetyCenterManager.RefreshReason; 29 import android.safetycenter.SafetyCenterStatus; 30 import android.safetycenter.SafetyCenterStatus.RefreshStatus; 31 import android.util.ArrayMap; 32 import android.util.ArraySet; 33 import android.util.Log; 34 35 import androidx.annotation.Nullable; 36 37 import com.android.safetycenter.logging.SafetyCenterStatsdLogger; 38 39 import java.io.PrintWriter; 40 import java.time.Duration; 41 import java.util.List; 42 import java.util.UUID; 43 44 import javax.annotation.concurrent.NotThreadSafe; 45 46 /** 47 * A class to store the state of a refresh of safety sources, if any is ongoing. 48 * 49 * <p>This class isn't thread safe. Thread safety must be handled by the caller. 50 * 51 * @hide 52 */ 53 @NotThreadSafe 54 public final class SafetyCenterRefreshTracker { 55 private static final String TAG = "SafetyCenterRefreshTrac"; 56 57 private final Context mContext; 58 59 @Nullable 60 // TODO(b/229060064): Should we allow one refresh at a time per UserProfileGroup rather than 61 // one global refresh? 62 private RefreshInProgress mRefreshInProgress = null; 63 64 private int mRefreshCounter = 0; 65 SafetyCenterRefreshTracker(Context context)66 SafetyCenterRefreshTracker(Context context) { 67 mContext = context; 68 } 69 70 /** 71 * Reports that a new refresh is in progress and returns the broadcast id associated with this 72 * refresh. 73 */ reportRefreshInProgress( @efreshReason int refreshReason, UserProfileGroup userProfileGroup)74 String reportRefreshInProgress( 75 @RefreshReason int refreshReason, UserProfileGroup userProfileGroup) { 76 if (mRefreshInProgress != null) { 77 Log.i(TAG, "Replacing ongoing refresh with id: " + mRefreshInProgress.getId()); 78 } 79 80 String refreshBroadcastId = UUID.randomUUID() + "_" + mRefreshCounter++; 81 Log.d( 82 TAG, 83 "Starting a new refresh with reason: " 84 + refreshReason 85 + ", and id: " 86 + refreshBroadcastId); 87 88 mRefreshInProgress = 89 new RefreshInProgress( 90 refreshBroadcastId, 91 refreshReason, 92 userProfileGroup, 93 SafetyCenterFlags.getUntrackedSourceIds()); 94 95 return refreshBroadcastId; 96 } 97 98 /** Returns the current refresh status. */ 99 @RefreshStatus getRefreshStatus()100 int getRefreshStatus() { 101 if (mRefreshInProgress == null || mRefreshInProgress.isComplete()) { 102 return SafetyCenterStatus.REFRESH_STATUS_NONE; 103 } 104 105 if (mRefreshInProgress.getReason() == REFRESH_REASON_RESCAN_BUTTON_CLICK) { 106 return SafetyCenterStatus.REFRESH_STATUS_FULL_RESCAN_IN_PROGRESS; 107 } 108 return SafetyCenterStatus.REFRESH_STATUS_DATA_FETCH_IN_PROGRESS; 109 } 110 111 /** 112 * Returns the {@link RefreshReason} for the current refresh, or {@code null} if none is in 113 * progress. 114 */ 115 @RefreshReason 116 @Nullable getRefreshReason()117 public Integer getRefreshReason() { 118 if (mRefreshInProgress != null) { 119 return mRefreshInProgress.getReason(); 120 } else { 121 return null; 122 } 123 } 124 125 /** 126 * Reports that refresh requests have been sent to a collection of sources. 127 * 128 * <p>When those sources respond call {@link #reportSourceRefreshCompleted} to mark the request 129 * as complete. 130 */ reportSourceRefreshesInFlight( String refreshBroadcastId, List<String> sourceIds, @UserIdInt int userId)131 void reportSourceRefreshesInFlight( 132 String refreshBroadcastId, List<String> sourceIds, @UserIdInt int userId) { 133 RefreshInProgress refreshInProgress = 134 getRefreshInProgressWithId("reportSourceRefreshesInFlight", refreshBroadcastId); 135 if (refreshInProgress == null) { 136 return; 137 } 138 for (int i = 0; i < sourceIds.size(); i++) { 139 SafetySourceKey key = SafetySourceKey.of(sourceIds.get(i), userId); 140 refreshInProgress.markSourceRefreshInFlight(key); 141 } 142 } 143 144 /** 145 * Reports that a source has completed its refresh, and returns {@code true} if the whole 146 * current refresh is now complete. 147 * 148 * <p>If a source calls {@code reportSafetySourceError}, then this method is also used to mark 149 * the refresh as completed. The {@code successful} parameter indicates whether the refresh 150 * completed successfully or not. The {@code dataChanged} parameter indicates whether this 151 * source's data changed or not. 152 * 153 * <p>Completed refreshes are logged to statsd. 154 */ reportSourceRefreshCompleted( String refreshBroadcastId, SafetySourceKey safetySourceKey, boolean successful, boolean dataChanged)155 public boolean reportSourceRefreshCompleted( 156 String refreshBroadcastId, 157 SafetySourceKey safetySourceKey, 158 boolean successful, 159 boolean dataChanged) { 160 RefreshInProgress refreshInProgress = 161 getRefreshInProgressWithId("reportSourceRefreshCompleted", refreshBroadcastId); 162 if (refreshInProgress == null) { 163 return false; 164 } 165 166 Duration duration = 167 refreshInProgress.markSourceRefreshComplete( 168 safetySourceKey, successful, dataChanged); 169 int refreshReason = refreshInProgress.getReason(); 170 int requestType = RefreshReasons.toRefreshRequestType(refreshReason); 171 172 if (duration != null) { 173 int sourceResult = toSystemEventResult(successful); 174 SafetyCenterStatsdLogger.writeSourceRefreshSystemEvent( 175 requestType, 176 safetySourceKey.getSourceId(), 177 UserProfileGroup.getProfileTypeOfUser(safetySourceKey.getUserId(), mContext), 178 duration, 179 sourceResult, 180 refreshReason, 181 dataChanged); 182 } 183 184 if (!refreshInProgress.isComplete()) { 185 return false; 186 } 187 188 Log.v(TAG, "Refresh with id: " + refreshInProgress.getId() + " completed"); 189 int wholeResult = 190 toSystemEventResult(/* success= */ !refreshInProgress.hasAnyTrackedSourceErrors()); 191 SafetyCenterStatsdLogger.writeWholeRefreshSystemEvent( 192 requestType, 193 refreshInProgress.getDurationSinceStart(), 194 wholeResult, 195 refreshReason, 196 refreshInProgress.hasAnyTrackedSourceDataChanged()); 197 mRefreshInProgress = null; 198 return true; 199 } 200 201 /** 202 * Clears any ongoing refresh in progress, if any. 203 * 204 * <p>Note that this method simply clears the tracking of a refresh, and does not prevent 205 * scheduled broadcasts being sent by {@link 206 * android.safetycenter.SafetyCenterManager#refreshSafetySources}. 207 */ clearRefresh()208 void clearRefresh() { 209 clearRefreshInternal(); 210 } 211 212 /** 213 * Clears the refresh in progress, if there is any with the given id. 214 * 215 * <p>Note that this method simply clears the tracking of a refresh, and does not prevent 216 * scheduled broadcasts being sent by {@link 217 * android.safetycenter.SafetyCenterManager#refreshSafetySources}. 218 */ clearRefresh(String refreshBroadcastId)219 void clearRefresh(String refreshBroadcastId) { 220 if (!checkRefreshInProgress("clearRefresh", refreshBroadcastId)) { 221 return; 222 } 223 clearRefreshInternal(); 224 } 225 226 /** 227 * Clears any ongoing refresh in progress for the given user. 228 * 229 * <p>Note that this method simply clears the tracking of a refresh, and does not prevent 230 * scheduled broadcasts being sent by {@link 231 * android.safetycenter.SafetyCenterManager#refreshSafetySources}. 232 */ clearRefreshForUser(@serIdInt int userId)233 void clearRefreshForUser(@UserIdInt int userId) { 234 if (mRefreshInProgress == null) { 235 Log.d(TAG, "Clear refresh for user called but no refresh in progress"); 236 return; 237 } 238 if (mRefreshInProgress.clearForUser(userId)) { 239 clearRefreshInternal(); 240 } 241 } 242 243 /** 244 * Clears the refresh in progress with the given id, and returns the {@link SafetySourceKey}s 245 * that were still in-flight prior to doing that, if any. 246 * 247 * <p>Returns {@code null} if there was no refresh in progress with the given {@code 248 * refreshBroadcastId}, or if it was already complete. 249 * 250 * <p>Note that this method simply clears the tracking of a refresh, and does not prevent 251 * scheduled broadcasts being sent by {@link 252 * android.safetycenter.SafetyCenterManager#refreshSafetySources}. 253 */ 254 @Nullable timeoutRefresh(String refreshBroadcastId)255 ArraySet<SafetySourceKey> timeoutRefresh(String refreshBroadcastId) { 256 if (!checkRefreshInProgress("timeoutRefresh", refreshBroadcastId)) { 257 return null; 258 } 259 260 RefreshInProgress clearedRefresh = clearRefreshInternal(); 261 262 if (clearedRefresh == null || clearedRefresh.isComplete()) { 263 return null; 264 } 265 266 ArraySet<SafetySourceKey> timedOutSources = clearedRefresh.getSourceRefreshesInFlight(); 267 int refreshReason = clearedRefresh.getReason(); 268 int requestType = RefreshReasons.toRefreshRequestType(refreshReason); 269 270 Log.w( 271 TAG, 272 "Timeout after " 273 + clearedRefresh.getDurationSinceStart() 274 + " for refresh with reason: " 275 + refreshReason 276 + ", and id: " 277 + clearedRefresh.getId()); 278 279 for (int i = 0; i < timedOutSources.size(); i++) { 280 SafetySourceKey sourceKey = timedOutSources.valueAt(i); 281 Duration duration = clearedRefresh.getDurationSinceSourceStart(sourceKey); 282 if (duration != null) { 283 SafetyCenterStatsdLogger.writeSourceRefreshSystemEvent( 284 requestType, 285 sourceKey.getSourceId(), 286 UserProfileGroup.getProfileTypeOfUser(sourceKey.getUserId(), mContext), 287 duration, 288 SAFETY_CENTER_SYSTEM_EVENT_REPORTED__RESULT__TIMEOUT, 289 refreshReason, 290 false); 291 } 292 293 Log.w( 294 TAG, 295 "Refresh with id: " 296 + clearedRefresh.getId() 297 + " timed out for tracked source id: " 298 + sourceKey.getSourceId() 299 + ", and user id: " 300 + sourceKey.getUserId()); 301 } 302 303 SafetyCenterStatsdLogger.writeWholeRefreshSystemEvent( 304 requestType, 305 clearedRefresh.getDurationSinceStart(), 306 SAFETY_CENTER_SYSTEM_EVENT_REPORTED__RESULT__TIMEOUT, 307 refreshReason, 308 clearedRefresh.hasAnyTrackedSourceDataChanged()); 309 310 return timedOutSources; 311 } 312 313 /** 314 * Clears the refresh in progress and returns it for the caller to do what it needs to. 315 * 316 * <p>If there was no refresh in progress then {@code null} is returned. 317 */ 318 @Nullable clearRefreshInternal()319 private RefreshInProgress clearRefreshInternal() { 320 RefreshInProgress refreshToClear = mRefreshInProgress; 321 if (refreshToClear == null) { 322 Log.d(TAG, "Clear refresh called but no refresh in progress"); 323 return null; 324 } 325 326 Log.v(TAG, "Clearing refresh with id: " + refreshToClear.getId()); 327 mRefreshInProgress = null; 328 return refreshToClear; 329 } 330 331 /** 332 * Returns the current {@link RefreshInProgress} if it has the given ID, or logs and returns 333 * {@code null} if not. 334 */ 335 @Nullable getRefreshInProgressWithId( String methodName, String refreshBroadcastId)336 private RefreshInProgress getRefreshInProgressWithId( 337 String methodName, String refreshBroadcastId) { 338 RefreshInProgress refreshInProgress = mRefreshInProgress; 339 if (refreshInProgress == null || !refreshInProgress.getId().equals(refreshBroadcastId)) { 340 Log.i(TAG, methodName + " called with invalid refresh id: " + refreshBroadcastId); 341 return null; 342 } 343 return refreshInProgress; 344 } 345 checkRefreshInProgress(String methodName, String refreshBroadcastId)346 private boolean checkRefreshInProgress(String methodName, String refreshBroadcastId) { 347 return getRefreshInProgressWithId(methodName, refreshBroadcastId) != null; 348 } 349 350 /** Dumps state for debugging purposes. */ dump(PrintWriter fout)351 void dump(PrintWriter fout) { 352 fout.println( 353 "REFRESH IN PROGRESS (" 354 + (mRefreshInProgress != null) 355 + ", counter=" 356 + mRefreshCounter 357 + ")"); 358 if (mRefreshInProgress != null) { 359 fout.println("\t" + mRefreshInProgress); 360 } 361 fout.println(); 362 } 363 364 /** Class representing the state of a refresh in progress. */ 365 private static final class RefreshInProgress { 366 367 private final String mId; 368 @RefreshReason private final int mReason; 369 private final UserProfileGroup mUserProfileGroup; 370 private final ArraySet<String> mUntrackedSourcesIds; 371 @ElapsedRealtimeLong private final long mStartElapsedMillis; 372 373 // The values in this map are the start times of each source refresh. The alternative of 374 // using mStartTime as the start time of all source refreshes was considered, but this 375 // approach is less sensitive to delays/implementation changes in broadcast dispatch. 376 private final ArrayMap<SafetySourceKey, Long> mSourceRefreshesInFlight = new ArrayMap<>(); 377 378 private boolean mAnyTrackedSourceErrors = false; 379 private boolean mAnyTrackedSourceDataChanged = false; 380 RefreshInProgress( String id, @RefreshReason int reason, UserProfileGroup userProfileGroup, ArraySet<String> untrackedSourceIds)381 RefreshInProgress( 382 String id, 383 @RefreshReason int reason, 384 UserProfileGroup userProfileGroup, 385 ArraySet<String> untrackedSourceIds) { 386 mId = id; 387 mReason = reason; 388 mUserProfileGroup = userProfileGroup; 389 mUntrackedSourcesIds = untrackedSourceIds; 390 mStartElapsedMillis = SystemClock.elapsedRealtime(); 391 } 392 393 /** 394 * Returns the id of the {@link RefreshInProgress}, which corresponds to the {@link 395 * android.safetycenter.SafetyCenterManager#EXTRA_REFRESH_SAFETY_SOURCES_BROADCAST_ID} used 396 * in the refresh. 397 */ getId()398 private String getId() { 399 return mId; 400 } 401 402 /** Returns the {@link RefreshReason} that was given for this {@link RefreshInProgress}. */ 403 @RefreshReason getReason()404 private int getReason() { 405 return mReason; 406 } 407 408 /** Returns the {@link Duration} since this refresh started. */ getDurationSinceStart()409 private Duration getDurationSinceStart() { 410 return Duration.ofMillis(SystemClock.elapsedRealtime() - mStartElapsedMillis); 411 } 412 413 @Nullable getDurationSinceSourceStart(SafetySourceKey safetySourceKey)414 private Duration getDurationSinceSourceStart(SafetySourceKey safetySourceKey) { 415 Long startElapsedMillis = mSourceRefreshesInFlight.get(safetySourceKey); 416 if (startElapsedMillis == null) { 417 return null; 418 } 419 return Duration.ofMillis(SystemClock.elapsedRealtime() - startElapsedMillis); 420 } 421 422 /** Returns the {@link SafetySourceKey} of all in-flight source refreshes. */ getSourceRefreshesInFlight()423 private ArraySet<SafetySourceKey> getSourceRefreshesInFlight() { 424 return new ArraySet<>(mSourceRefreshesInFlight.keySet()); 425 } 426 427 /** Returns {@code true} if any refresh of a tracked source completed with an error. */ hasAnyTrackedSourceErrors()428 private boolean hasAnyTrackedSourceErrors() { 429 return mAnyTrackedSourceErrors; 430 } 431 432 /** Returns {@code true} if any refresh of a tracked source changed that source's data. */ hasAnyTrackedSourceDataChanged()433 private boolean hasAnyTrackedSourceDataChanged() { 434 return mAnyTrackedSourceDataChanged; 435 } 436 markSourceRefreshInFlight(SafetySourceKey safetySourceKey)437 private void markSourceRefreshInFlight(SafetySourceKey safetySourceKey) { 438 boolean tracked = isTracked(safetySourceKey); 439 long currentElapsedMillis = SystemClock.elapsedRealtime(); 440 if (tracked) { 441 mSourceRefreshesInFlight.put(safetySourceKey, currentElapsedMillis); 442 } 443 Log.v( 444 TAG, 445 "Refresh with id: " 446 + mId 447 + " started for source id: " 448 + safetySourceKey.getSourceId() 449 + ", user id: " 450 + safetySourceKey.getUserId() 451 + ", elapsed millis: " 452 + currentElapsedMillis 453 + ", tracking: " 454 + tracked 455 + ", now " 456 + mSourceRefreshesInFlight.size() 457 + " tracked sources in flight"); 458 } 459 460 @Nullable markSourceRefreshComplete( SafetySourceKey safetySourceKey, boolean successful, boolean dataChanged)461 private Duration markSourceRefreshComplete( 462 SafetySourceKey safetySourceKey, boolean successful, boolean dataChanged) { 463 Long startElapsedMillis = mSourceRefreshesInFlight.remove(safetySourceKey); 464 465 boolean tracked = isTracked(safetySourceKey); 466 mAnyTrackedSourceErrors |= (tracked && !successful); 467 mAnyTrackedSourceDataChanged |= dataChanged; 468 Duration duration = 469 (startElapsedMillis == null) 470 ? null 471 : Duration.ofMillis(SystemClock.elapsedRealtime() - startElapsedMillis); 472 Log.v( 473 TAG, 474 "Refresh with id: " 475 + mId 476 + " completed for source id: " 477 + safetySourceKey.getSourceId() 478 + ", user id: " 479 + safetySourceKey.getUserId() 480 + ", duration: " 481 + duration 482 + ", successful: " 483 + successful 484 + ", data changed: " 485 + dataChanged 486 + ", tracking: " 487 + tracked 488 + ", now " 489 + mSourceRefreshesInFlight.size() 490 + " tracked sources in flight"); 491 return duration; 492 } 493 isTracked(SafetySourceKey safetySourceKey)494 private boolean isTracked(SafetySourceKey safetySourceKey) { 495 return !mUntrackedSourcesIds.contains(safetySourceKey.getSourceId()); 496 } 497 498 /** 499 * Clears the data for the given {@code userId} and returns whether that caused the entire 500 * refresh to complete. 501 */ clearForUser(@serIdInt int userId)502 private boolean clearForUser(@UserIdInt int userId) { 503 if (mUserProfileGroup.getProfileParentUserId() == userId) { 504 return true; 505 } 506 // Loop in reverse index order to be able to remove entries while iterating. 507 for (int i = mSourceRefreshesInFlight.size() - 1; i >= 0; i--) { 508 SafetySourceKey sourceKey = mSourceRefreshesInFlight.keyAt(i); 509 if (sourceKey.getUserId() == userId) { 510 mSourceRefreshesInFlight.removeAt(i); 511 } 512 } 513 return isComplete(); 514 } 515 isComplete()516 private boolean isComplete() { 517 return mSourceRefreshesInFlight.isEmpty(); 518 } 519 520 @Override toString()521 public String toString() { 522 return "RefreshInProgress{" 523 + "mId='" 524 + mId 525 + '\'' 526 + ", mReason=" 527 + mReason 528 + ", mUserProfileGroup=" 529 + mUserProfileGroup 530 + ", mUntrackedSourcesIds=" 531 + mUntrackedSourcesIds 532 + ", mSourceRefreshesInFlight=" 533 + mSourceRefreshesInFlight 534 + ", mStartElapsedMillis=" 535 + mStartElapsedMillis 536 + ", mAnyTrackedSourceErrors=" 537 + mAnyTrackedSourceErrors 538 + ", mAnyTrackedSourceDataChanged=" 539 + mAnyTrackedSourceDataChanged 540 + '}'; 541 } 542 } 543 } 544