/* * Copyright (C) 2023 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.safetycenter.data; import static com.android.safetycenter.internaldata.SafetyCenterIds.toUserFriendlyString; import android.annotation.UserIdInt; import android.annotation.WorkerThread; import android.content.ApexEnvironment; import android.os.Handler; import android.safetycenter.SafetySourceData; import android.util.ArrayMap; import android.util.ArraySet; import android.util.Log; import androidx.annotation.Nullable; import com.android.modules.utils.BackgroundThread; import com.android.safetycenter.ApiLock; import com.android.safetycenter.SafetyCenterConfigReader; import com.android.safetycenter.SafetyCenterFlags; import com.android.safetycenter.internaldata.SafetyCenterIds; import com.android.safetycenter.internaldata.SafetyCenterIssueKey; import com.android.safetycenter.persistence.PersistedSafetyCenterIssue; import com.android.safetycenter.persistence.PersistenceException; import com.android.safetycenter.persistence.SafetyCenterIssuesPersistence; import java.io.File; import java.io.FileDescriptor; import java.io.FileOutputStream; import java.io.IOException; import java.io.PrintWriter; import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.Objects; import javax.annotation.concurrent.NotThreadSafe; /** * Repository to manage data about all issue dismissals in Safety Center. * *
It stores the state of this class automatically into a file. After the class is first * instantiated the user should call {@link * SafetyCenterIssueDismissalRepository#loadStateFromFile()} to initialize the state with what was * stored in the file. * *
This class isn't thread safe. Thread safety must be handled by the caller.
*/
@NotThreadSafe
final class SafetyCenterIssueDismissalRepository {
private static final String TAG = "SafetyCenterIssueDis";
/** The APEX name used to retrieve the APEX owned data directories. */
private static final String APEX_MODULE_NAME = "com.android.permission";
/** The name of the file used to persist the {@link SafetyCenterIssueDismissalRepository}. */
private static final String ISSUE_DISMISSAL_REPOSITORY_FILE_NAME = "safety_center_issues.xml";
/** The time delay used to throttle and aggregate writes to disk. */
private static final Duration WRITE_DELAY = Duration.ofMillis(500);
private final Handler mWriteHandler = BackgroundThread.getHandler();
private final ApiLock mApiLock;
private final SafetyCenterConfigReader mSafetyCenterConfigReader;
private final ArrayMap An issue which is dismissed at one time may become "un-dismissed" later, after the
* resurface delay (which depends on severity level) has elapsed.
*
* If the given issue key is not found in the repository this method returns {@code false}.
*/
boolean isIssueDismissed(
SafetyCenterIssueKey safetyCenterIssueKey,
@SafetySourceData.SeverityLevel int safetySourceIssueSeverityLevel) {
IssueData issueData = getOrWarn(safetyCenterIssueKey, "checking if dismissed");
if (issueData == null) {
return false;
}
Instant dismissedAt = issueData.getDismissedAt();
boolean isNotCurrentlyDismissed = dismissedAt == null;
if (isNotCurrentlyDismissed) {
return false;
}
long maxCount = SafetyCenterFlags.getResurfaceIssueMaxCount(safetySourceIssueSeverityLevel);
Duration delay = SafetyCenterFlags.getResurfaceIssueDelay(safetySourceIssueSeverityLevel);
boolean hasAlreadyResurfacedTheMaxAllowedNumberOfTimes =
issueData.getDismissCount() > maxCount;
if (hasAlreadyResurfacedTheMaxAllowedNumberOfTimes) {
return true;
}
Duration timeSinceLastDismissal = Duration.between(dismissedAt, Instant.now());
boolean isTimeToResurface = timeSinceLastDismissal.compareTo(delay) >= 0;
return !isTimeToResurface;
}
/**
* Marks the issue with the given key as dismissed.
*
* That issue's notification (if any) is also marked as dismissed.
*/
void dismissIssue(SafetyCenterIssueKey safetyCenterIssueKey) {
IssueData issueData = getOrWarn(safetyCenterIssueKey, "dismissing");
if (issueData == null) {
return;
}
Instant now = Instant.now();
issueData.setDismissedAt(now);
issueData.setDismissCount(issueData.getDismissCount() + 1);
issueData.setNotificationDismissedAt(now);
scheduleWriteStateToFile();
}
/**
* Copy dismissal data from one issue to the other.
*
* This will align dismissal state of these issues, unless issues are of different
* severities, in which case they can potentially differ in resurface times.
*/
void copyDismissalData(SafetyCenterIssueKey keyFrom, SafetyCenterIssueKey keyTo) {
IssueData dataFrom = getOrWarn(keyFrom, "copying dismissal data");
IssueData dataTo = getOrWarn(keyTo, "copying dismissal data");
if (dataFrom == null || dataTo == null) {
return;
}
dataTo.setDismissedAt(dataFrom.getDismissedAt());
dataTo.setDismissCount(dataFrom.getDismissCount());
scheduleWriteStateToFile();
}
/**
* Copy notification dismissal data from one issue to the other.
*
* This will align notification dismissal state of these issues.
*/
void copyNotificationDismissalData(SafetyCenterIssueKey keyFrom, SafetyCenterIssueKey keyTo) {
IssueData dataFrom = getOrWarn(keyFrom, "copying notification dismissal data");
IssueData dataTo = getOrWarn(keyTo, "copying notification dismissal data");
if (dataFrom == null || dataTo == null) {
return;
}
dataTo.setNotificationDismissedAt(dataFrom.getNotificationDismissedAt());
scheduleWriteStateToFile();
}
/**
* Marks the notification (if any) of the issue with the given key as dismissed.
*
* The issue itself is not marked as dismissed and its warning card can
* still appear in the Safety Center UI.
*/
void dismissNotification(SafetyCenterIssueKey safetyCenterIssueKey) {
IssueData issueData = getOrWarn(safetyCenterIssueKey, "dismissing notification");
if (issueData == null) {
return;
}
issueData.setNotificationDismissedAt(Instant.now());
scheduleWriteStateToFile();
}
/**
* Returns the {@link Instant} when the issue with the given key was first reported to Safety
* Center.
*/
@Nullable
Instant getIssueFirstSeenAt(SafetyCenterIssueKey safetyCenterIssueKey) {
IssueData issueData = getOrWarn(safetyCenterIssueKey, "getting first seen");
if (issueData == null) {
return null;
}
return issueData.getFirstSeenAt();
}
@Nullable
private Instant getNotificationDismissedAt(SafetyCenterIssueKey safetyCenterIssueKey) {
IssueData issueData = getOrWarn(safetyCenterIssueKey, "getting notification dismissed");
if (issueData == null) {
return null;
}
return issueData.getNotificationDismissedAt();
}
/** Returns {@code true} if an issue's notification is dismissed now. */
// TODO(b/259084807): Consider extracting notification dismissal logic to separate class
boolean isNotificationDismissedNow(
SafetyCenterIssueKey issueKey, @SafetySourceData.SeverityLevel int severityLevel) {
// The current code for dismissing an issue/warning card also dismisses any
// corresponding notification, but it is still necessary to check the issue dismissal
// status, in addition to the notification dismissal (below) because issues may have been
// dismissed by an earlier version of the code which lacked this functionality.
if (isIssueDismissed(issueKey, severityLevel)) {
return true;
}
Instant dismissedAt = getNotificationDismissedAt(issueKey);
if (dismissedAt == null) {
// Notification was never dismissed
return false;
}
Duration resurfaceDelay = SafetyCenterFlags.getNotificationResurfaceInterval();
if (resurfaceDelay == null) {
// Null resurface delay means notifications may never resurface
return true;
}
Instant canResurfaceAt = dismissedAt.plus(resurfaceDelay);
return Instant.now().isBefore(canResurfaceAt);
}
/**
* Updates the issue repository to contain exactly the given {@code safetySourceIssueIds} for
* the supplied source and user.
*/
void updateIssuesForSource(
ArraySet If this method is called multiple times in a row, the period will be set by the first call
* and all following calls won't have any effect.
*/
void resurfaceHiddenIssueAfterPeriod(SafetyCenterIssueKey safetyCenterIssueKey) {
IssueData issueData = getOrWarn(safetyCenterIssueKey, "resurfacing hidden issue");
if (issueData == null) {
return;
}
// if timer already started, we don't want to restart
if (issueData.getResurfaceTimerStartTime() == null) {
issueData.setResurfaceTimerStartTime(Instant.now());
}
}
/** Takes a snapshot of the contents of the repository to be written to persistent storage. */
private List