/*
* 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.UPSIDE_DOWN_CAKE;
import static com.android.safetycenter.internaldata.SafetyCenterIds.toUserFriendlyString;
import static java.util.Collections.emptyList;
import static java.util.Collections.emptyMap;
import static java.util.Collections.unmodifiableList;
import static java.util.Collections.unmodifiableMap;
import static java.util.Collections.unmodifiableSet;
import android.annotation.UserIdInt;
import android.safetycenter.config.SafetySourcesGroup;
import android.util.ArrayMap;
import android.util.ArraySet;
import android.util.Log;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import com.android.safetycenter.SafetySourceIssueInfo;
import com.android.safetycenter.internaldata.SafetyCenterIssueKey;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import javax.annotation.concurrent.NotThreadSafe;
/** Deduplicates issues based on deduplication info provided by the source and the issue. */
@NotThreadSafe
final class SafetyCenterIssueDeduplicator {
private static final String TAG = "SafetyCenterDedup";
private final SafetyCenterIssueDismissalRepository mSafetyCenterIssueDismissalRepository;
SafetyCenterIssueDeduplicator(
SafetyCenterIssueDismissalRepository safetyCenterIssueDismissalRepository) {
this.mSafetyCenterIssueDismissalRepository = safetyCenterIssueDismissalRepository;
}
/**
* Accepts a list of issues sorted by priority and filters out duplicates.
*
*
Issues are considered duplicate if they have the same deduplication id and were sent by
* sources which are part of the same deduplication group. All but the highest priority
* duplicate issue will be filtered out.
*
*
In case any issue, in the bucket of duplicate issues, was dismissed, all issues of the
* same or lower severity will be dismissed as well.
*
* @return deduplicated list of issues, and some other information gathered in the deduplication
* process
*/
@RequiresApi(UPSIDE_DOWN_CAKE)
DeduplicationInfo deduplicateIssues(List sortedIssues) {
// (dedup key) -> list(issues)
ArrayMap> dedupBuckets =
createDedupBuckets(sortedIssues);
// There is no further work to do when there are no dedup buckets
if (dedupBuckets.isEmpty()) {
return new DeduplicationInfo(new ArrayList<>(sortedIssues), emptyList(), emptyMap());
}
alignAllDismissals(dedupBuckets);
ArraySet duplicatesToFilterOut =
getDuplicatesToFilterOut(dedupBuckets);
resurfaceHiddenIssuesIfNeeded(dedupBuckets);
if (duplicatesToFilterOut.isEmpty()) {
return new DeduplicationInfo(new ArrayList<>(sortedIssues), emptyList(), emptyMap());
}
ArrayMap> issueToGroupMap =
getTopIssueToGroupMapping(dedupBuckets);
List filteredOut = new ArrayList<>(duplicatesToFilterOut.size());
List deduplicatedIssues = new ArrayList<>();
for (int i = 0; i < sortedIssues.size(); i++) {
SafetySourceIssueInfo issueInfo = sortedIssues.get(i);
SafetyCenterIssueKey issueKey = issueInfo.getSafetyCenterIssueKey();
if (duplicatesToFilterOut.contains(issueKey)) {
filteredOut.add(issueInfo);
// mark as temporarily hidden, which will delay showing these issues if the top
// issue gets resolved.
mSafetyCenterIssueDismissalRepository.hideIssue(issueKey);
} else {
deduplicatedIssues.add(issueInfo);
}
}
return new DeduplicationInfo(deduplicatedIssues, filteredOut, issueToGroupMap);
}
private void resurfaceHiddenIssuesIfNeeded(
ArrayMap> dedupBuckets) {
for (int i = 0; i < dedupBuckets.size(); i++) {
List duplicates = dedupBuckets.valueAt(i);
if (duplicates.isEmpty()) {
Log.w(TAG, "List of duplicates in a deduplication bucket is empty");
continue;
}
// top issue in the bucket, if hidden, should resurface after certain period
SafetyCenterIssueKey topIssueKey = duplicates.get(0).getSafetyCenterIssueKey();
if (mSafetyCenterIssueDismissalRepository.isIssueHidden(topIssueKey)) {
mSafetyCenterIssueDismissalRepository.resurfaceHiddenIssueAfterPeriod(topIssueKey);
}
}
}
/**
* Creates a mapping from the top issue in each dedupBucket to all groups in that dedupBucket.
*/
private ArrayMap> getTopIssueToGroupMapping(
ArrayMap> dedupBuckets) {
ArrayMap> issueToGroupMap = new ArrayMap<>();
for (int i = 0; i < dedupBuckets.size(); i++) {
List duplicates = dedupBuckets.valueAt(i);
boolean noMappingBecauseNoDuplicates = duplicates.size() < 2;
if (noMappingBecauseNoDuplicates) {
continue;
}
SafetyCenterIssueKey topIssueKey = duplicates.get(0).getSafetyCenterIssueKey();
for (int j = 0; j < duplicates.size(); j++) {
Set groups = issueToGroupMap.getOrDefault(topIssueKey, new ArraySet<>());
groups.add(duplicates.get(j).getSafetySourcesGroup().getId());
if (j == duplicates.size() - 1) { // last element, no more modifications
groups = unmodifiableSet(groups);
}
issueToGroupMap.put(topIssueKey, groups);
}
}
return issueToGroupMap;
}
/**
* Handles dismissals logic: in each bucket, dismissal details of the highest priority (top)
* dismissed issue will be copied to all other duplicate issues in that bucket, that are of
* equal or lower severity (not priority). Notification-dismissal details are handled similarly.
*/
private void alignAllDismissals(
ArrayMap> dedupBuckets) {
for (int i = 0; i < dedupBuckets.size(); i++) {
List duplicates = dedupBuckets.valueAt(i);
if (duplicates.size() < 2) {
continue;
}
SafetySourceIssueInfo topDismissed = getHighestPriorityDismissedIssue(duplicates);
SafetySourceIssueInfo topNotificationDismissed =
getHighestPriorityNotificationDismissedIssue(duplicates);
alignDismissalsInBucket(topDismissed, duplicates);
alignNotificationDismissalsInBucket(topNotificationDismissed, duplicates);
}
}
/**
* Dismisses all recipient issues of lower or equal severity than the given top dismissed issue
* in the bucket.
*/
private void alignDismissalsInBucket(
@Nullable SafetySourceIssueInfo topDismissed, List duplicates) {
if (topDismissed == null) {
return;
}
SafetyCenterIssueKey topDismissedKey = topDismissed.getSafetyCenterIssueKey();
List recipients = getRecipientKeys(topDismissed, duplicates);
for (int i = 0; i < recipients.size(); i++) {
mSafetyCenterIssueDismissalRepository.copyDismissalData(
topDismissedKey, recipients.get(i));
}
}
/**
* Dismisses notifications for all recipient issues of lower or equal severity than the given
* top notification-dismissed issue in the bucket.
*/
private void alignNotificationDismissalsInBucket(
@Nullable SafetySourceIssueInfo topNotificationDismissed,
List duplicates) {
if (topNotificationDismissed == null) {
return;
}
SafetyCenterIssueKey topNotificationDismissedKey =
topNotificationDismissed.getSafetyCenterIssueKey();
List recipients =
getRecipientKeys(topNotificationDismissed, duplicates);
for (int i = 0; i < recipients.size(); i++) {
mSafetyCenterIssueDismissalRepository.copyNotificationDismissalData(
topNotificationDismissedKey, recipients.get(i));
}
}
/**
* Returns the "recipient" issues for the given top issue from a bucket of duplicates.
* Recipients are those issues with a lower or equal severity level. The top issue is not its
* own recipient.
*/
private List getRecipientKeys(
SafetySourceIssueInfo topIssue, List duplicates) {
ArrayList recipients = new ArrayList<>();
SafetyCenterIssueKey topKey = topIssue.getSafetyCenterIssueKey();
int topSeverity = topIssue.getSafetySourceIssue().getSeverityLevel();
for (int i = 0; i < duplicates.size(); i++) {
SafetySourceIssueInfo issueInfo = duplicates.get(i);
SafetyCenterIssueKey issueKey = issueInfo.getSafetyCenterIssueKey();
if (!issueKey.equals(topKey)
&& issueInfo.getSafetySourceIssue().getSeverityLevel() <= topSeverity) {
recipients.add(issueKey);
}
}
return recipients;
}
@Nullable
private SafetySourceIssueInfo getHighestPriorityDismissedIssue(
List duplicates) {
for (int i = 0; i < duplicates.size(); i++) {
SafetySourceIssueInfo issueInfo = duplicates.get(i);
if (mSafetyCenterIssueDismissalRepository.isIssueDismissed(
issueInfo.getSafetyCenterIssueKey(),
issueInfo.getSafetySourceIssue().getSeverityLevel())) {
return issueInfo;
}
}
return null;
}
@Nullable
private SafetySourceIssueInfo getHighestPriorityNotificationDismissedIssue(
List duplicates) {
for (int i = 0; i < duplicates.size(); i++) {
SafetySourceIssueInfo issueInfo = duplicates.get(i);
if (mSafetyCenterIssueDismissalRepository.isNotificationDismissedNow(
issueInfo.getSafetyCenterIssueKey(),
issueInfo.getSafetySourceIssue().getSeverityLevel())) {
return issueInfo;
}
}
return null;
}
/** Returns a set of duplicate issues that need to be filtered out. */
private ArraySet getDuplicatesToFilterOut(
ArrayMap> dedupBuckets) {
ArraySet duplicatesToFilterOut = new ArraySet<>();
for (int i = 0; i < dedupBuckets.size(); i++) {
List duplicates = dedupBuckets.valueAt(i);
// all but the top one in the bucket
for (int j = 1; j < duplicates.size(); j++) {
SafetyCenterIssueKey issueKey = duplicates.get(j).getSafetyCenterIssueKey();
duplicatesToFilterOut.add(issueKey);
}
}
return duplicatesToFilterOut;
}
/** Returns a mapping (dedup key) -> list(issues). */
private static ArrayMap> createDedupBuckets(
List sortedIssues) {
ArrayMap> dedupBuckets = new ArrayMap<>();
for (int i = 0; i < sortedIssues.size(); i++) {
SafetySourceIssueInfo issueInfo = sortedIssues.get(i);
DeduplicationKey dedupKey = getDedupKey(issueInfo);
if (dedupKey == null) {
continue;
}
// each bucket will remain sorted
List bucket =
dedupBuckets.getOrDefault(dedupKey, new ArrayList<>());
bucket.add(issueInfo);
dedupBuckets.put(dedupKey, bucket);
}
return dedupBuckets;
}
/** Returns deduplication key of the given {@code issueInfo}. */
@Nullable
private static DeduplicationKey getDedupKey(SafetySourceIssueInfo issueInfo) {
String deduplicationGroup = issueInfo.getSafetySource().getDeduplicationGroup();
String deduplicationId = issueInfo.getSafetySourceIssue().getDeduplicationId();
if (deduplicationGroup == null || deduplicationId == null) {
return null;
}
return new DeduplicationKey(
deduplicationGroup,
deduplicationId,
issueInfo.getSafetyCenterIssueKey().getUserId());
}
/** Encapsulates deduplication result along with some additional information. */
static final class DeduplicationInfo {
private final List mDeduplicatedIssues;
private final List mFilteredOutDuplicates;
private final Map> mIssueToGroup;
/** Creates a new {@link DeduplicationInfo}. */
DeduplicationInfo(
List deduplicatedIssues,
List filteredOutDuplicates,
Map> issueToGroup) {
mDeduplicatedIssues = unmodifiableList(deduplicatedIssues);
mFilteredOutDuplicates = unmodifiableList(filteredOutDuplicates);
mIssueToGroup = unmodifiableMap(issueToGroup);
}
/**
* Returns the list of issues which were removed from the given list of issues in the most
* recent {@link SafetyCenterIssueDeduplicator#deduplicateIssues} call. These issues were
* removed because they were duplicates of other issues.
*/
List getFilteredOutDuplicateIssues() {
return mFilteredOutDuplicates;
}
/**
* Returns a mapping between a {@link SafetyCenterIssueKey} and {@link SafetySourcesGroup}
* IDs, that was a result of the most recent {@link
* SafetyCenterIssueDeduplicator#deduplicateIssues} call.
*
* If present, such an entry represents an issue mapping to all the safety source groups
* of others issues which were filtered out as its duplicates. It also contains a mapping to
* its own source group.
*
*
If an issue didn't have any duplicates, it won't be present in the result.
*/
Map> getIssueToGroupMapping() {
return mIssueToGroup;
}
/** Returns the deduplication result, the deduplicated list of issues. */
List getDeduplicatedIssues() {
return mDeduplicatedIssues;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof DeduplicationInfo)) return false;
DeduplicationInfo that = (DeduplicationInfo) o;
return mDeduplicatedIssues.equals(that.mDeduplicatedIssues)
&& mFilteredOutDuplicates.equals(that.mFilteredOutDuplicates)
&& mIssueToGroup.equals(that.mIssueToGroup);
}
@Override
public int hashCode() {
return Objects.hash(mDeduplicatedIssues, mFilteredOutDuplicates, mIssueToGroup);
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder("DeduplicationInfo:");
sb.append("\n\tDeduplicatedIssues:");
for (int i = 0; i < mDeduplicatedIssues.size(); i++) {
sb.append("\n\t\tSafetySourceIssueInfo=").append(mDeduplicatedIssues.get(i));
}
sb.append("\n\tFilteredOutDuplicates:");
for (int i = 0; i < mFilteredOutDuplicates.size(); i++) {
sb.append("\n\t\tSafetySourceIssueInfo=").append(mFilteredOutDuplicates.get(i));
}
sb.append("\n\tIssueToGroupMapping");
for (Map.Entry> entry : mIssueToGroup.entrySet()) {
sb.append("\n\t\tSafetyCenterIssueKey=")
.append(toUserFriendlyString(entry.getKey()))
.append(" maps to groups: ");
for (String group : entry.getValue()) {
sb.append(group).append(",");
}
}
return sb.toString();
}
}
private static final class DeduplicationKey {
private final String mDeduplicationGroup;
private final String mDeduplicationId;
private final int mUserId;
private DeduplicationKey(
String deduplicationGroup, String deduplicationId, @UserIdInt int userId) {
mDeduplicationGroup = deduplicationGroup;
mDeduplicationId = deduplicationId;
mUserId = userId;
}
@Override
public int hashCode() {
return Objects.hash(mDeduplicationGroup, mDeduplicationId, mUserId);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof DeduplicationKey)) return false;
DeduplicationKey dedupKey = (DeduplicationKey) o;
return mDeduplicationGroup.equals(dedupKey.mDeduplicationGroup)
&& mDeduplicationId.equals(dedupKey.mDeduplicationId)
&& mUserId == dedupKey.mUserId;
}
}
}