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