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