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