/* * 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 android.os.Build.VERSION_CODES.TIRAMISU; import static com.android.safetycenter.internaldata.SafetyCenterIds.toUserFriendlyString; import android.annotation.Nullable; 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.RequiresApi; 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.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.
 */
@RequiresApi(TIRAMISU)
@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;
        if (isTimeToResurface) {
            return false;
        }
        return true;
    }
    /**
     * 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, "resurfaceIssueAfterPeriod");
        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