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