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