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.safetycenter.data;
18 
19 import static com.android.safetycenter.internaldata.SafetyCenterIds.toUserFriendlyString;
20 
21 import android.annotation.UserIdInt;
22 import android.annotation.WorkerThread;
23 import android.content.ApexEnvironment;
24 import android.os.Handler;
25 import android.safetycenter.SafetySourceData;
26 import android.util.ArrayMap;
27 import android.util.ArraySet;
28 import android.util.Log;
29 
30 import androidx.annotation.Nullable;
31 
32 import com.android.modules.utils.BackgroundThread;
33 import com.android.safetycenter.ApiLock;
34 import com.android.safetycenter.SafetyCenterConfigReader;
35 import com.android.safetycenter.SafetyCenterFlags;
36 import com.android.safetycenter.internaldata.SafetyCenterIds;
37 import com.android.safetycenter.internaldata.SafetyCenterIssueKey;
38 import com.android.safetycenter.persistence.PersistedSafetyCenterIssue;
39 import com.android.safetycenter.persistence.PersistenceException;
40 import com.android.safetycenter.persistence.SafetyCenterIssuesPersistence;
41 
42 import java.io.File;
43 import java.io.FileDescriptor;
44 import java.io.FileOutputStream;
45 import java.io.IOException;
46 import java.io.PrintWriter;
47 import java.nio.file.Files;
48 import java.nio.file.NoSuchFileException;
49 import java.time.Duration;
50 import java.time.Instant;
51 import java.util.ArrayList;
52 import java.util.List;
53 import java.util.Objects;
54 
55 import javax.annotation.concurrent.NotThreadSafe;
56 
57 /**
58  * Repository to manage data about all issue dismissals in Safety Center.
59  *
60  * <p>It stores the state of this class automatically into a file. After the class is first
61  * instantiated the user should call {@link
62  * SafetyCenterIssueDismissalRepository#loadStateFromFile()} to initialize the state with what was
63  * stored in the file.
64  *
65  * <p>This class isn't thread safe. Thread safety must be handled by the caller.
66  */
67 @NotThreadSafe
68 final class SafetyCenterIssueDismissalRepository {
69 
70     private static final String TAG = "SafetyCenterIssueDis";
71 
72     /** The APEX name used to retrieve the APEX owned data directories. */
73     private static final String APEX_MODULE_NAME = "com.android.permission";
74 
75     /** The name of the file used to persist the {@link SafetyCenterIssueDismissalRepository}. */
76     private static final String ISSUE_DISMISSAL_REPOSITORY_FILE_NAME = "safety_center_issues.xml";
77 
78     /** The time delay used to throttle and aggregate writes to disk. */
79     private static final Duration WRITE_DELAY = Duration.ofMillis(500);
80 
81     private final Handler mWriteHandler = BackgroundThread.getHandler();
82 
83     private final ApiLock mApiLock;
84 
85     private final SafetyCenterConfigReader mSafetyCenterConfigReader;
86 
87     private final ArrayMap<SafetyCenterIssueKey, IssueData> mIssues = new ArrayMap<>();
88     private boolean mWriteStateToFileScheduled = false;
89 
SafetyCenterIssueDismissalRepository( ApiLock apiLock, SafetyCenterConfigReader safetyCenterConfigReader)90     SafetyCenterIssueDismissalRepository(
91             ApiLock apiLock, SafetyCenterConfigReader safetyCenterConfigReader) {
92         mApiLock = apiLock;
93         mSafetyCenterConfigReader = safetyCenterConfigReader;
94     }
95 
96     /**
97      * Returns {@code true} if the issue with the given key and severity level is currently
98      * dismissed.
99      *
100      * <p>An issue which is dismissed at one time may become "un-dismissed" later, after the
101      * resurface delay (which depends on severity level) has elapsed.
102      *
103      * <p>If the given issue key is not found in the repository this method returns {@code false}.
104      */
isIssueDismissed( SafetyCenterIssueKey safetyCenterIssueKey, @SafetySourceData.SeverityLevel int safetySourceIssueSeverityLevel)105     boolean isIssueDismissed(
106             SafetyCenterIssueKey safetyCenterIssueKey,
107             @SafetySourceData.SeverityLevel int safetySourceIssueSeverityLevel) {
108         IssueData issueData = getOrWarn(safetyCenterIssueKey, "checking if dismissed");
109         if (issueData == null) {
110             return false;
111         }
112 
113         Instant dismissedAt = issueData.getDismissedAt();
114         boolean isNotCurrentlyDismissed = dismissedAt == null;
115         if (isNotCurrentlyDismissed) {
116             return false;
117         }
118 
119         long maxCount = SafetyCenterFlags.getResurfaceIssueMaxCount(safetySourceIssueSeverityLevel);
120         Duration delay = SafetyCenterFlags.getResurfaceIssueDelay(safetySourceIssueSeverityLevel);
121 
122         boolean hasAlreadyResurfacedTheMaxAllowedNumberOfTimes =
123                 issueData.getDismissCount() > maxCount;
124         if (hasAlreadyResurfacedTheMaxAllowedNumberOfTimes) {
125             return true;
126         }
127 
128         Duration timeSinceLastDismissal = Duration.between(dismissedAt, Instant.now());
129         boolean isTimeToResurface = timeSinceLastDismissal.compareTo(delay) >= 0;
130         return !isTimeToResurface;
131     }
132 
133     /**
134      * Marks the issue with the given key as dismissed.
135      *
136      * <p>That issue's notification (if any) is also marked as dismissed.
137      */
dismissIssue(SafetyCenterIssueKey safetyCenterIssueKey)138     void dismissIssue(SafetyCenterIssueKey safetyCenterIssueKey) {
139         IssueData issueData = getOrWarn(safetyCenterIssueKey, "dismissing");
140         if (issueData == null) {
141             return;
142         }
143         Instant now = Instant.now();
144         issueData.setDismissedAt(now);
145         issueData.setDismissCount(issueData.getDismissCount() + 1);
146         issueData.setNotificationDismissedAt(now);
147         scheduleWriteStateToFile();
148     }
149 
150     /**
151      * Copy dismissal data from one issue to the other.
152      *
153      * <p>This will align dismissal state of these issues, unless issues are of different
154      * severities, in which case they can potentially differ in resurface times.
155      */
copyDismissalData(SafetyCenterIssueKey keyFrom, SafetyCenterIssueKey keyTo)156     void copyDismissalData(SafetyCenterIssueKey keyFrom, SafetyCenterIssueKey keyTo) {
157         IssueData dataFrom = getOrWarn(keyFrom, "copying dismissal data");
158         IssueData dataTo = getOrWarn(keyTo, "copying dismissal data");
159         if (dataFrom == null || dataTo == null) {
160             return;
161         }
162 
163         dataTo.setDismissedAt(dataFrom.getDismissedAt());
164         dataTo.setDismissCount(dataFrom.getDismissCount());
165         scheduleWriteStateToFile();
166     }
167 
168     /**
169      * Copy notification dismissal data from one issue to the other.
170      *
171      * <p>This will align notification dismissal state of these issues.
172      */
copyNotificationDismissalData(SafetyCenterIssueKey keyFrom, SafetyCenterIssueKey keyTo)173     void copyNotificationDismissalData(SafetyCenterIssueKey keyFrom, SafetyCenterIssueKey keyTo) {
174         IssueData dataFrom = getOrWarn(keyFrom, "copying notification dismissal data");
175         IssueData dataTo = getOrWarn(keyTo, "copying notification dismissal data");
176         if (dataFrom == null || dataTo == null) {
177             return;
178         }
179 
180         dataTo.setNotificationDismissedAt(dataFrom.getNotificationDismissedAt());
181         scheduleWriteStateToFile();
182     }
183 
184     /**
185      * Marks the notification (if any) of the issue with the given key as dismissed.
186      *
187      * <p>The issue itself is <strong>not</strong> marked as dismissed and its warning card can
188      * still appear in the Safety Center UI.
189      */
dismissNotification(SafetyCenterIssueKey safetyCenterIssueKey)190     void dismissNotification(SafetyCenterIssueKey safetyCenterIssueKey) {
191         IssueData issueData = getOrWarn(safetyCenterIssueKey, "dismissing notification");
192         if (issueData == null) {
193             return;
194         }
195         issueData.setNotificationDismissedAt(Instant.now());
196         scheduleWriteStateToFile();
197     }
198 
199     /**
200      * Returns the {@link Instant} when the issue with the given key was first reported to Safety
201      * Center.
202      */
203     @Nullable
getIssueFirstSeenAt(SafetyCenterIssueKey safetyCenterIssueKey)204     Instant getIssueFirstSeenAt(SafetyCenterIssueKey safetyCenterIssueKey) {
205         IssueData issueData = getOrWarn(safetyCenterIssueKey, "getting first seen");
206         if (issueData == null) {
207             return null;
208         }
209         return issueData.getFirstSeenAt();
210     }
211 
212     @Nullable
getNotificationDismissedAt(SafetyCenterIssueKey safetyCenterIssueKey)213     private Instant getNotificationDismissedAt(SafetyCenterIssueKey safetyCenterIssueKey) {
214         IssueData issueData = getOrWarn(safetyCenterIssueKey, "getting notification dismissed");
215         if (issueData == null) {
216             return null;
217         }
218         return issueData.getNotificationDismissedAt();
219     }
220 
221     /** Returns {@code true} if an issue's notification is dismissed now. */
222     // TODO(b/259084807): Consider extracting notification dismissal logic to separate class
isNotificationDismissedNow( SafetyCenterIssueKey issueKey, @SafetySourceData.SeverityLevel int severityLevel)223     boolean isNotificationDismissedNow(
224             SafetyCenterIssueKey issueKey, @SafetySourceData.SeverityLevel int severityLevel) {
225         // The current code for dismissing an issue/warning card also dismisses any
226         // corresponding notification, but it is still necessary to check the issue dismissal
227         // status, in addition to the notification dismissal (below) because issues may have been
228         // dismissed by an earlier version of the code which lacked this functionality.
229         if (isIssueDismissed(issueKey, severityLevel)) {
230             return true;
231         }
232 
233         Instant dismissedAt = getNotificationDismissedAt(issueKey);
234         if (dismissedAt == null) {
235             // Notification was never dismissed
236             return false;
237         }
238 
239         Duration resurfaceDelay = SafetyCenterFlags.getNotificationResurfaceInterval();
240         if (resurfaceDelay == null) {
241             // Null resurface delay means notifications may never resurface
242             return true;
243         }
244 
245         Instant canResurfaceAt = dismissedAt.plus(resurfaceDelay);
246         return Instant.now().isBefore(canResurfaceAt);
247     }
248 
249     /**
250      * Updates the issue repository to contain exactly the given {@code safetySourceIssueIds} for
251      * the supplied source and user.
252      */
updateIssuesForSource( ArraySet<String> safetySourceIssueIds, String safetySourceId, @UserIdInt int userId)253     void updateIssuesForSource(
254             ArraySet<String> safetySourceIssueIds, String safetySourceId, @UserIdInt int userId) {
255         boolean someDataChanged = false;
256 
257         // Remove issues no longer reported by the source.
258         // Loop in reverse index order to be able to remove entries while iterating.
259         for (int i = mIssues.size() - 1; i >= 0; i--) {
260             SafetyCenterIssueKey issueKey = mIssues.keyAt(i);
261             boolean doesNotBelongToUserOrSource =
262                     issueKey.getUserId() != userId
263                             || !Objects.equals(issueKey.getSafetySourceId(), safetySourceId);
264             if (doesNotBelongToUserOrSource) {
265                 continue;
266             }
267             boolean isIssueNoLongerReported =
268                     !safetySourceIssueIds.contains(issueKey.getSafetySourceIssueId());
269             if (isIssueNoLongerReported) {
270                 mIssues.removeAt(i);
271                 someDataChanged = true;
272             }
273         }
274         // Add newly reported issues.
275         for (int i = 0; i < safetySourceIssueIds.size(); i++) {
276             SafetyCenterIssueKey issueKey =
277                     SafetyCenterIssueKey.newBuilder()
278                             .setUserId(userId)
279                             .setSafetySourceId(safetySourceId)
280                             .setSafetySourceIssueId(safetySourceIssueIds.valueAt(i))
281                             .build();
282             boolean isIssueNewlyReported = !mIssues.containsKey(issueKey);
283             if (isIssueNewlyReported) {
284                 mIssues.put(issueKey, new IssueData(Instant.now()));
285                 someDataChanged = true;
286             }
287         }
288         if (someDataChanged) {
289             scheduleWriteStateToFile();
290         }
291     }
292 
293     /** Returns whether the issue is currently hidden. */
isIssueHidden(SafetyCenterIssueKey safetyCenterIssueKey)294     boolean isIssueHidden(SafetyCenterIssueKey safetyCenterIssueKey) {
295         IssueData issueData = getOrWarn(safetyCenterIssueKey, "checking if issue hidden");
296         if (issueData == null || !issueData.isHidden()) {
297             return false;
298         }
299 
300         Instant timerStart = issueData.getResurfaceTimerStartTime();
301         if (timerStart == null) {
302             return true;
303         }
304 
305         Duration delay = SafetyCenterFlags.getTemporarilyHiddenIssueResurfaceDelay();
306         Duration timeSinceTimerStarted = Duration.between(timerStart, Instant.now());
307         boolean isTimeToResurface = timeSinceTimerStarted.compareTo(delay) >= 0;
308 
309         if (isTimeToResurface) {
310             issueData.setHidden(false);
311             issueData.setResurfaceTimerStartTime(null);
312             return false;
313         }
314         return true;
315     }
316 
317     /** Hides the issue with the given {@link SafetyCenterIssueKey}. */
hideIssue(SafetyCenterIssueKey safetyCenterIssueKey)318     void hideIssue(SafetyCenterIssueKey safetyCenterIssueKey) {
319         IssueData issueData = getOrWarn(safetyCenterIssueKey, "hiding issue");
320         if (issueData != null) {
321             issueData.setHidden(true);
322             // to abide by the method was called last: hideIssue or resurfaceHiddenIssueAfterPeriod
323             issueData.setResurfaceTimerStartTime(null);
324         }
325     }
326 
327     /**
328      * The issue with the given {@link SafetyCenterIssueKey} will be resurfaced (marked as not
329      * hidden) after a period of time defined by {@link
330      * SafetyCenterFlags#getTemporarilyHiddenIssueResurfaceDelay()}, such that {@link
331      * SafetyCenterIssueDismissalRepository#isIssueHidden} will start returning {@code false} for
332      * the given issue.
333      *
334      * <p>If this method is called multiple times in a row, the period will be set by the first call
335      * and all following calls won't have any effect.
336      */
resurfaceHiddenIssueAfterPeriod(SafetyCenterIssueKey safetyCenterIssueKey)337     void resurfaceHiddenIssueAfterPeriod(SafetyCenterIssueKey safetyCenterIssueKey) {
338         IssueData issueData = getOrWarn(safetyCenterIssueKey, "resurfacing hidden issue");
339         if (issueData == null) {
340             return;
341         }
342 
343         // if timer already started, we don't want to restart
344         if (issueData.getResurfaceTimerStartTime() == null) {
345             issueData.setResurfaceTimerStartTime(Instant.now());
346         }
347     }
348 
349     /** Takes a snapshot of the contents of the repository to be written to persistent storage. */
snapshot()350     private List<PersistedSafetyCenterIssue> snapshot() {
351         List<PersistedSafetyCenterIssue> persistedIssues = new ArrayList<>();
352         for (int i = 0; i < mIssues.size(); i++) {
353             String encodedKey = SafetyCenterIds.encodeToString(mIssues.keyAt(i));
354             IssueData issueData = mIssues.valueAt(i);
355             persistedIssues.add(issueData.toPersistedIssueBuilder().setKey(encodedKey).build());
356         }
357         return persistedIssues;
358     }
359 
360     /**
361      * Replaces the contents of the repository with the given issues read from persistent storage.
362      */
load(List<PersistedSafetyCenterIssue> persistedSafetyCenterIssues)363     private void load(List<PersistedSafetyCenterIssue> persistedSafetyCenterIssues) {
364         boolean someDataChanged = false;
365         mIssues.clear();
366         for (int i = 0; i < persistedSafetyCenterIssues.size(); i++) {
367             PersistedSafetyCenterIssue persistedIssue = persistedSafetyCenterIssues.get(i);
368             SafetyCenterIssueKey key = SafetyCenterIds.issueKeyFromString(persistedIssue.getKey());
369 
370             // Only load the issues associated with the "real" config. We do not want to keep on
371             // persisting potentially stray issues from tests (they should supposedly be cleared,
372             // but may stick around if the data is not cleared after a test run).
373             // There is a caveat that if a real source was overridden in tests and the override
374             // provided data without clearing it, we will associate this issue with the real source.
375             if (!mSafetyCenterConfigReader.isExternalSafetySourceFromRealConfig(
376                     key.getSafetySourceId())) {
377                 someDataChanged = true;
378                 continue;
379             }
380 
381             IssueData issueData = IssueData.fromPersistedIssue(persistedIssue);
382             mIssues.put(key, issueData);
383         }
384         if (someDataChanged) {
385             scheduleWriteStateToFile();
386         }
387     }
388 
389     /** Clears all the data in the repository. */
clear()390     public void clear() {
391         mIssues.clear();
392         scheduleWriteStateToFile();
393     }
394 
395     /** Clears all the data in the repository for the given user. */
clearForUser(@serIdInt int userId)396     void clearForUser(@UserIdInt int userId) {
397         boolean someDataChanged = false;
398         // Loop in reverse index order to be able to remove entries while iterating.
399         for (int i = mIssues.size() - 1; i >= 0; i--) {
400             SafetyCenterIssueKey issueKey = mIssues.keyAt(i);
401             if (issueKey.getUserId() == userId) {
402                 mIssues.removeAt(i);
403                 someDataChanged = true;
404             }
405         }
406         if (someDataChanged) {
407             scheduleWriteStateToFile();
408         }
409     }
410 
411     /** Dumps state for debugging purposes. */
dump(FileDescriptor fd, PrintWriter fout)412     void dump(FileDescriptor fd, PrintWriter fout) {
413         int issueRepositoryCount = mIssues.size();
414         fout.println(
415                 "ISSUE DISMISSAL REPOSITORY ("
416                         + issueRepositoryCount
417                         + ", mWriteStateToFileScheduled="
418                         + mWriteStateToFileScheduled
419                         + ")");
420         for (int i = 0; i < issueRepositoryCount; i++) {
421             SafetyCenterIssueKey key = mIssues.keyAt(i);
422             IssueData data = mIssues.valueAt(i);
423             fout.println("\t[" + i + "] " + toUserFriendlyString(key) + " -> " + data);
424         }
425         fout.println();
426 
427         File issueDismissalRepositoryFile = getIssueDismissalRepositoryFile();
428         fout.println(
429                 "ISSUE DISMISSAL REPOSITORY FILE ("
430                         + issueDismissalRepositoryFile.getAbsolutePath()
431                         + ")");
432         fout.flush();
433         try {
434             Files.copy(issueDismissalRepositoryFile.toPath(), new FileOutputStream(fd));
435             fout.println();
436         } catch (NoSuchFileException e) {
437             fout.println("<No File> (equivalent to empty issue list)");
438         } catch (IOException e) {
439             printError(e, fout);
440         }
441         fout.println();
442     }
443 
444     // We want to dump the stack trace on a specific PrintWriter here, this is a false positive as
445     // the warning does not consider the overload that takes a PrintWriter as an argument (yet).
446     @SuppressWarnings("CatchAndPrintStackTrace")
printError(Throwable error, PrintWriter fout)447     private void printError(Throwable error, PrintWriter fout) {
448         error.printStackTrace(fout);
449     }
450 
451     @Nullable
getOrWarn(SafetyCenterIssueKey issueKey, String reason)452     private IssueData getOrWarn(SafetyCenterIssueKey issueKey, String reason) {
453         IssueData issueData = mIssues.get(issueKey);
454         if (issueData == null) {
455             Log.w(
456                     TAG,
457                     "Issue missing when reading from dismissal repository for "
458                             + reason
459                             + ": "
460                             + toUserFriendlyString(issueKey));
461             return null;
462         }
463         return issueData;
464     }
465 
466     /** Schedule writing the {@link SafetyCenterIssueDismissalRepository} to file. */
scheduleWriteStateToFile()467     private void scheduleWriteStateToFile() {
468         if (!mWriteStateToFileScheduled) {
469             mWriteHandler.postDelayed(this::writeStateToFile, WRITE_DELAY.toMillis());
470             mWriteStateToFileScheduled = true;
471         }
472     }
473 
474     @WorkerThread
writeStateToFile()475     private void writeStateToFile() {
476         List<PersistedSafetyCenterIssue> persistedSafetyCenterIssues;
477 
478         synchronized (mApiLock) {
479             mWriteStateToFileScheduled = false;
480             persistedSafetyCenterIssues = snapshot();
481             // Since all write operations are scheduled in the same background thread, we can safely
482             // release the lock after creating a snapshot and know that all snapshots will be
483             // written in the correct order even if we are not holding the lock.
484         }
485 
486         SafetyCenterIssuesPersistence.write(
487                 persistedSafetyCenterIssues, getIssueDismissalRepositoryFile());
488     }
489 
490     /** Read the contents of the file and load them into this class. */
loadStateFromFile()491     void loadStateFromFile() {
492         List<PersistedSafetyCenterIssue> persistedSafetyCenterIssues = new ArrayList<>();
493 
494         try {
495             persistedSafetyCenterIssues =
496                     SafetyCenterIssuesPersistence.read(getIssueDismissalRepositoryFile());
497             Log.d(TAG, "Safety Center persisted issues read successfully");
498         } catch (PersistenceException e) {
499             Log.w(TAG, "Cannot read Safety Center persisted issues", e);
500         }
501 
502         load(persistedSafetyCenterIssues);
503         scheduleWriteStateToFile();
504     }
505 
getIssueDismissalRepositoryFile()506     private static File getIssueDismissalRepositoryFile() {
507         ApexEnvironment apexEnvironment = ApexEnvironment.getApexEnvironment(APEX_MODULE_NAME);
508         File dataDirectory = apexEnvironment.getDeviceProtectedDataDir();
509         // It should resolve to /data/misc/apexdata/com.android.permission/safety_center_issues.xml
510         return new File(dataDirectory, ISSUE_DISMISSAL_REPOSITORY_FILE_NAME);
511     }
512 
513     /**
514      * An internal mutable data structure containing issue metadata which is used to determine
515      * whether an issue should be dismissed/hidden from the user.
516      */
517     private static final class IssueData {
518 
fromPersistedIssue(PersistedSafetyCenterIssue persistedIssue)519         private static IssueData fromPersistedIssue(PersistedSafetyCenterIssue persistedIssue) {
520             IssueData issueData = new IssueData(persistedIssue.getFirstSeenAt());
521             issueData.setDismissedAt(persistedIssue.getDismissedAt());
522             issueData.setDismissCount(persistedIssue.getDismissCount());
523             issueData.setNotificationDismissedAt(persistedIssue.getNotificationDismissedAt());
524             return issueData;
525         }
526 
527         private final Instant mFirstSeenAt;
528 
529         @Nullable private Instant mDismissedAt;
530         private int mDismissCount;
531 
532         @Nullable private Instant mNotificationDismissedAt;
533 
534         // TODO(b/270015734): maybe persist those as well
535         private boolean mHidden = false;
536         // Moment when a theoretical timer starts - when it ends the issue gets unmarked as hidden.
537         @Nullable private Instant mResurfaceTimerStartTime;
538 
IssueData(Instant firstSeenAt)539         private IssueData(Instant firstSeenAt) {
540             mFirstSeenAt = firstSeenAt;
541         }
542 
getFirstSeenAt()543         private Instant getFirstSeenAt() {
544             return mFirstSeenAt;
545         }
546 
547         @Nullable
getDismissedAt()548         private Instant getDismissedAt() {
549             return mDismissedAt;
550         }
551 
setDismissedAt(@ullable Instant dismissedAt)552         private void setDismissedAt(@Nullable Instant dismissedAt) {
553             mDismissedAt = dismissedAt;
554         }
555 
getDismissCount()556         private int getDismissCount() {
557             return mDismissCount;
558         }
559 
setDismissCount(int dismissCount)560         private void setDismissCount(int dismissCount) {
561             mDismissCount = dismissCount;
562         }
563 
564         @Nullable
getNotificationDismissedAt()565         private Instant getNotificationDismissedAt() {
566             return mNotificationDismissedAt;
567         }
568 
setNotificationDismissedAt(@ullable Instant notificationDismissedAt)569         private void setNotificationDismissedAt(@Nullable Instant notificationDismissedAt) {
570             mNotificationDismissedAt = notificationDismissedAt;
571         }
572 
isHidden()573         private boolean isHidden() {
574             return mHidden;
575         }
576 
setHidden(boolean hidden)577         private void setHidden(boolean hidden) {
578             mHidden = hidden;
579         }
580 
581         @Nullable
getResurfaceTimerStartTime()582         private Instant getResurfaceTimerStartTime() {
583             return mResurfaceTimerStartTime;
584         }
585 
setResurfaceTimerStartTime(@ullable Instant resurfaceTimerStartTime)586         private void setResurfaceTimerStartTime(@Nullable Instant resurfaceTimerStartTime) {
587             this.mResurfaceTimerStartTime = resurfaceTimerStartTime;
588         }
589 
toPersistedIssueBuilder()590         private PersistedSafetyCenterIssue.Builder toPersistedIssueBuilder() {
591             return new PersistedSafetyCenterIssue.Builder()
592                     .setFirstSeenAt(mFirstSeenAt)
593                     .setDismissedAt(mDismissedAt)
594                     .setDismissCount(mDismissCount)
595                     .setNotificationDismissedAt(mNotificationDismissedAt);
596         }
597 
598         @Override
toString()599         public String toString() {
600             return "SafetySourceIssueInfo{"
601                     + "mFirstSeenAt="
602                     + mFirstSeenAt
603                     + ", mDismissedAt="
604                     + mDismissedAt
605                     + ", mDismissCount="
606                     + mDismissCount
607                     + ", mNotificationDismissedAt="
608                     + mNotificationDismissedAt
609                     + '}';
610         }
611     }
612 }
613