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.UPSIDE_DOWN_CAKE;
20 
21 import static com.android.safetycenter.internaldata.SafetyCenterIds.toUserFriendlyString;
22 
23 import static java.util.Collections.emptyList;
24 import static java.util.Collections.emptyMap;
25 import static java.util.Collections.unmodifiableList;
26 import static java.util.Collections.unmodifiableMap;
27 import static java.util.Collections.unmodifiableSet;
28 
29 import android.annotation.UserIdInt;
30 import android.safetycenter.config.SafetySourcesGroup;
31 import android.util.ArrayMap;
32 import android.util.ArraySet;
33 import android.util.Log;
34 
35 import androidx.annotation.Nullable;
36 import androidx.annotation.RequiresApi;
37 
38 import com.android.safetycenter.SafetySourceIssueInfo;
39 import com.android.safetycenter.internaldata.SafetyCenterIssueKey;
40 
41 import java.util.ArrayList;
42 import java.util.List;
43 import java.util.Map;
44 import java.util.Objects;
45 import java.util.Set;
46 
47 import javax.annotation.concurrent.NotThreadSafe;
48 
49 /** Deduplicates issues based on deduplication info provided by the source and the issue. */
50 @NotThreadSafe
51 final class SafetyCenterIssueDeduplicator {
52 
53     private static final String TAG = "SafetyCenterDedup";
54 
55     private final SafetyCenterIssueDismissalRepository mSafetyCenterIssueDismissalRepository;
56 
SafetyCenterIssueDeduplicator( SafetyCenterIssueDismissalRepository safetyCenterIssueDismissalRepository)57     SafetyCenterIssueDeduplicator(
58             SafetyCenterIssueDismissalRepository safetyCenterIssueDismissalRepository) {
59         this.mSafetyCenterIssueDismissalRepository = safetyCenterIssueDismissalRepository;
60     }
61 
62     /**
63      * Accepts a list of issues sorted by priority and filters out duplicates.
64      *
65      * <p>Issues are considered duplicate if they have the same deduplication id and were sent by
66      * sources which are part of the same deduplication group. All but the highest priority
67      * duplicate issue will be filtered out.
68      *
69      * <p>In case any issue, in the bucket of duplicate issues, was dismissed, all issues of the
70      * same or lower severity will be dismissed as well.
71      *
72      * @return deduplicated list of issues, and some other information gathered in the deduplication
73      *     process
74      */
75     @RequiresApi(UPSIDE_DOWN_CAKE)
deduplicateIssues(List<SafetySourceIssueInfo> sortedIssues)76     DeduplicationInfo deduplicateIssues(List<SafetySourceIssueInfo> sortedIssues) {
77         // (dedup key) -> list(issues)
78         ArrayMap<DeduplicationKey, List<SafetySourceIssueInfo>> dedupBuckets =
79                 createDedupBuckets(sortedIssues);
80 
81         // There is no further work to do when there are no dedup buckets
82         if (dedupBuckets.isEmpty()) {
83             return new DeduplicationInfo(new ArrayList<>(sortedIssues), emptyList(), emptyMap());
84         }
85 
86         alignAllDismissals(dedupBuckets);
87 
88         ArraySet<SafetyCenterIssueKey> duplicatesToFilterOut =
89                 getDuplicatesToFilterOut(dedupBuckets);
90 
91         resurfaceHiddenIssuesIfNeeded(dedupBuckets);
92 
93         if (duplicatesToFilterOut.isEmpty()) {
94             return new DeduplicationInfo(new ArrayList<>(sortedIssues), emptyList(), emptyMap());
95         }
96 
97         ArrayMap<SafetyCenterIssueKey, Set<String>> issueToGroupMap =
98                 getTopIssueToGroupMapping(dedupBuckets);
99 
100         List<SafetySourceIssueInfo> filteredOut = new ArrayList<>(duplicatesToFilterOut.size());
101         List<SafetySourceIssueInfo> deduplicatedIssues = new ArrayList<>();
102         for (int i = 0; i < sortedIssues.size(); i++) {
103             SafetySourceIssueInfo issueInfo = sortedIssues.get(i);
104             SafetyCenterIssueKey issueKey = issueInfo.getSafetyCenterIssueKey();
105             if (duplicatesToFilterOut.contains(issueKey)) {
106                 filteredOut.add(issueInfo);
107                 // mark as temporarily hidden, which will delay showing these issues if the top
108                 // issue gets resolved.
109                 mSafetyCenterIssueDismissalRepository.hideIssue(issueKey);
110             } else {
111                 deduplicatedIssues.add(issueInfo);
112             }
113         }
114 
115         return new DeduplicationInfo(deduplicatedIssues, filteredOut, issueToGroupMap);
116     }
117 
resurfaceHiddenIssuesIfNeeded( ArrayMap<DeduplicationKey, List<SafetySourceIssueInfo>> dedupBuckets)118     private void resurfaceHiddenIssuesIfNeeded(
119             ArrayMap<DeduplicationKey, List<SafetySourceIssueInfo>> dedupBuckets) {
120         for (int i = 0; i < dedupBuckets.size(); i++) {
121             List<SafetySourceIssueInfo> duplicates = dedupBuckets.valueAt(i);
122             if (duplicates.isEmpty()) {
123                 Log.w(TAG, "List of duplicates in a deduplication bucket is empty");
124                 continue;
125             }
126 
127             // top issue in the bucket, if hidden, should resurface after certain period
128             SafetyCenterIssueKey topIssueKey = duplicates.get(0).getSafetyCenterIssueKey();
129             if (mSafetyCenterIssueDismissalRepository.isIssueHidden(topIssueKey)) {
130                 mSafetyCenterIssueDismissalRepository.resurfaceHiddenIssueAfterPeriod(topIssueKey);
131             }
132         }
133     }
134 
135     /**
136      * Creates a mapping from the top issue in each dedupBucket to all groups in that dedupBucket.
137      */
getTopIssueToGroupMapping( ArrayMap<DeduplicationKey, List<SafetySourceIssueInfo>> dedupBuckets)138     private ArrayMap<SafetyCenterIssueKey, Set<String>> getTopIssueToGroupMapping(
139             ArrayMap<DeduplicationKey, List<SafetySourceIssueInfo>> dedupBuckets) {
140         ArrayMap<SafetyCenterIssueKey, Set<String>> issueToGroupMap = new ArrayMap<>();
141         for (int i = 0; i < dedupBuckets.size(); i++) {
142             List<SafetySourceIssueInfo> duplicates = dedupBuckets.valueAt(i);
143 
144             boolean noMappingBecauseNoDuplicates = duplicates.size() < 2;
145             if (noMappingBecauseNoDuplicates) {
146                 continue;
147             }
148 
149             SafetyCenterIssueKey topIssueKey = duplicates.get(0).getSafetyCenterIssueKey();
150             for (int j = 0; j < duplicates.size(); j++) {
151                 Set<String> groups = issueToGroupMap.getOrDefault(topIssueKey, new ArraySet<>());
152                 groups.add(duplicates.get(j).getSafetySourcesGroup().getId());
153                 if (j == duplicates.size() - 1) { // last element, no more modifications
154                     groups = unmodifiableSet(groups);
155                 }
156                 issueToGroupMap.put(topIssueKey, groups);
157             }
158         }
159 
160         return issueToGroupMap;
161     }
162 
163     /**
164      * Handles dismissals logic: in each bucket, dismissal details of the highest priority (top)
165      * dismissed issue will be copied to all other duplicate issues in that bucket, that are of
166      * equal or lower severity (not priority). Notification-dismissal details are handled similarly.
167      */
168     private void alignAllDismissals(
169             ArrayMap<DeduplicationKey, List<SafetySourceIssueInfo>> dedupBuckets) {
170         for (int i = 0; i < dedupBuckets.size(); i++) {
171             List<SafetySourceIssueInfo> duplicates = dedupBuckets.valueAt(i);
172             if (duplicates.size() < 2) {
173                 continue;
174             }
175             SafetySourceIssueInfo topDismissed = getHighestPriorityDismissedIssue(duplicates);
176             SafetySourceIssueInfo topNotificationDismissed =
177                     getHighestPriorityNotificationDismissedIssue(duplicates);
178             alignDismissalsInBucket(topDismissed, duplicates);
179             alignNotificationDismissalsInBucket(topNotificationDismissed, duplicates);
180         }
181     }
182 
183     /**
184      * Dismisses all recipient issues of lower or equal severity than the given top dismissed issue
185      * in the bucket.
186      */
187     private void alignDismissalsInBucket(
188             @Nullable SafetySourceIssueInfo topDismissed, List<SafetySourceIssueInfo> duplicates) {
189         if (topDismissed == null) {
190             return;
191         }
192         SafetyCenterIssueKey topDismissedKey = topDismissed.getSafetyCenterIssueKey();
193         List<SafetyCenterIssueKey> recipients = getRecipientKeys(topDismissed, duplicates);
194         for (int i = 0; i < recipients.size(); i++) {
195             mSafetyCenterIssueDismissalRepository.copyDismissalData(
196                     topDismissedKey, recipients.get(i));
197         }
198     }
199 
200     /**
201      * Dismisses notifications for all recipient issues of lower or equal severity than the given
202      * top notification-dismissed issue in the bucket.
203      */
204     private void alignNotificationDismissalsInBucket(
205             @Nullable SafetySourceIssueInfo topNotificationDismissed,
206             List<SafetySourceIssueInfo> duplicates) {
207         if (topNotificationDismissed == null) {
208             return;
209         }
210         SafetyCenterIssueKey topNotificationDismissedKey =
211                 topNotificationDismissed.getSafetyCenterIssueKey();
212         List<SafetyCenterIssueKey> recipients =
213                 getRecipientKeys(topNotificationDismissed, duplicates);
214         for (int i = 0; i < recipients.size(); i++) {
215             mSafetyCenterIssueDismissalRepository.copyNotificationDismissalData(
216                     topNotificationDismissedKey, recipients.get(i));
217         }
218     }
219 
220     /**
221      * Returns the "recipient" issues for the given top issue from a bucket of duplicates.
222      * Recipients are those issues with a lower or equal severity level. The top issue is not its
223      * own recipient.
224      */
225     private List<SafetyCenterIssueKey> getRecipientKeys(
226             SafetySourceIssueInfo topIssue, List<SafetySourceIssueInfo> duplicates) {
227         ArrayList<SafetyCenterIssueKey> recipients = new ArrayList<>();
228         SafetyCenterIssueKey topKey = topIssue.getSafetyCenterIssueKey();
229         int topSeverity = topIssue.getSafetySourceIssue().getSeverityLevel();
230 
231         for (int i = 0; i < duplicates.size(); i++) {
232             SafetySourceIssueInfo issueInfo = duplicates.get(i);
233             SafetyCenterIssueKey issueKey = issueInfo.getSafetyCenterIssueKey();
234             if (!issueKey.equals(topKey)
235                     && issueInfo.getSafetySourceIssue().getSeverityLevel() <= topSeverity) {
236                 recipients.add(issueKey);
237             }
238         }
239         return recipients;
240     }
241 
242     @Nullable
243     private SafetySourceIssueInfo getHighestPriorityDismissedIssue(
244             List<SafetySourceIssueInfo> duplicates) {
245         for (int i = 0; i < duplicates.size(); i++) {
246             SafetySourceIssueInfo issueInfo = duplicates.get(i);
247             if (mSafetyCenterIssueDismissalRepository.isIssueDismissed(
248                     issueInfo.getSafetyCenterIssueKey(),
249                     issueInfo.getSafetySourceIssue().getSeverityLevel())) {
250                 return issueInfo;
251             }
252         }
253 
254         return null;
255     }
256 
257     @Nullable
258     private SafetySourceIssueInfo getHighestPriorityNotificationDismissedIssue(
259             List<SafetySourceIssueInfo> duplicates) {
260         for (int i = 0; i < duplicates.size(); i++) {
261             SafetySourceIssueInfo issueInfo = duplicates.get(i);
262             if (mSafetyCenterIssueDismissalRepository.isNotificationDismissedNow(
263                     issueInfo.getSafetyCenterIssueKey(),
264                     issueInfo.getSafetySourceIssue().getSeverityLevel())) {
265                 return issueInfo;
266             }
267         }
268 
269         return null;
270     }
271 
272     /** Returns a set of duplicate issues that need to be filtered out. */
273     private ArraySet<SafetyCenterIssueKey> getDuplicatesToFilterOut(
274             ArrayMap<DeduplicationKey, List<SafetySourceIssueInfo>> dedupBuckets) {
275         ArraySet<SafetyCenterIssueKey> duplicatesToFilterOut = new ArraySet<>();
276 
277         for (int i = 0; i < dedupBuckets.size(); i++) {
278             List<SafetySourceIssueInfo> duplicates = dedupBuckets.valueAt(i);
279 
280             // all but the top one in the bucket
281             for (int j = 1; j < duplicates.size(); j++) {
282                 SafetyCenterIssueKey issueKey = duplicates.get(j).getSafetyCenterIssueKey();
283                 duplicatesToFilterOut.add(issueKey);
284             }
285         }
286 
287         return duplicatesToFilterOut;
288     }
289 
290     /** Returns a mapping (dedup key) -> list(issues). */
291     private static ArrayMap<DeduplicationKey, List<SafetySourceIssueInfo>> createDedupBuckets(
292             List<SafetySourceIssueInfo> sortedIssues) {
293         ArrayMap<DeduplicationKey, List<SafetySourceIssueInfo>> dedupBuckets = new ArrayMap<>();
294 
295         for (int i = 0; i < sortedIssues.size(); i++) {
296             SafetySourceIssueInfo issueInfo = sortedIssues.get(i);
297             DeduplicationKey dedupKey = getDedupKey(issueInfo);
298             if (dedupKey == null) {
299                 continue;
300             }
301 
302             // each bucket will remain sorted
303             List<SafetySourceIssueInfo> bucket =
304                     dedupBuckets.getOrDefault(dedupKey, new ArrayList<>());
305             bucket.add(issueInfo);
306 
307             dedupBuckets.put(dedupKey, bucket);
308         }
309 
310         return dedupBuckets;
311     }
312 
313     /** Returns deduplication key of the given {@code issueInfo}. */
314     @Nullable
315     private static DeduplicationKey getDedupKey(SafetySourceIssueInfo issueInfo) {
316         String deduplicationGroup = issueInfo.getSafetySource().getDeduplicationGroup();
317         String deduplicationId = issueInfo.getSafetySourceIssue().getDeduplicationId();
318 
319         if (deduplicationGroup == null || deduplicationId == null) {
320             return null;
321         }
322         return new DeduplicationKey(
323                 deduplicationGroup,
324                 deduplicationId,
325                 issueInfo.getSafetyCenterIssueKey().getUserId());
326     }
327 
328     /** Encapsulates deduplication result along with some additional information. */
329     static final class DeduplicationInfo {
330         private final List<SafetySourceIssueInfo> mDeduplicatedIssues;
331         private final List<SafetySourceIssueInfo> mFilteredOutDuplicates;
332         private final Map<SafetyCenterIssueKey, Set<String>> mIssueToGroup;
333 
334         /** Creates a new {@link DeduplicationInfo}. */
335         DeduplicationInfo(
336                 List<SafetySourceIssueInfo> deduplicatedIssues,
337                 List<SafetySourceIssueInfo> filteredOutDuplicates,
338                 Map<SafetyCenterIssueKey, Set<String>> issueToGroup) {
339             mDeduplicatedIssues = unmodifiableList(deduplicatedIssues);
340             mFilteredOutDuplicates = unmodifiableList(filteredOutDuplicates);
341             mIssueToGroup = unmodifiableMap(issueToGroup);
342         }
343 
344         /**
345          * Returns the list of issues which were removed from the given list of issues in the most
346          * recent {@link SafetyCenterIssueDeduplicator#deduplicateIssues} call. These issues were
347          * removed because they were duplicates of other issues.
348          */
349         List<SafetySourceIssueInfo> getFilteredOutDuplicateIssues() {
350             return mFilteredOutDuplicates;
351         }
352 
353         /**
354          * Returns a mapping between a {@link SafetyCenterIssueKey} and {@link SafetySourcesGroup}
355          * IDs, that was a result of the most recent {@link
356          * SafetyCenterIssueDeduplicator#deduplicateIssues} call.
357          *
358          * <p>If present, such an entry represents an issue mapping to all the safety source groups
359          * of others issues which were filtered out as its duplicates. It also contains a mapping to
360          * its own source group.
361          *
362          * <p>If an issue didn't have any duplicates, it won't be present in the result.
363          */
364         Map<SafetyCenterIssueKey, Set<String>> getIssueToGroupMapping() {
365             return mIssueToGroup;
366         }
367 
368         /** Returns the deduplication result, the deduplicated list of issues. */
369         List<SafetySourceIssueInfo> getDeduplicatedIssues() {
370             return mDeduplicatedIssues;
371         }
372 
373         @Override
374         public boolean equals(Object o) {
375             if (this == o) return true;
376             if (!(o instanceof DeduplicationInfo)) return false;
377             DeduplicationInfo that = (DeduplicationInfo) o;
378             return mDeduplicatedIssues.equals(that.mDeduplicatedIssues)
379                     && mFilteredOutDuplicates.equals(that.mFilteredOutDuplicates)
380                     && mIssueToGroup.equals(that.mIssueToGroup);
381         }
382 
383         @Override
384         public int hashCode() {
385             return Objects.hash(mDeduplicatedIssues, mFilteredOutDuplicates, mIssueToGroup);
386         }
387 
388         @Override
389         public String toString() {
390             StringBuilder sb = new StringBuilder("DeduplicationInfo:");
391 
392             sb.append("\n\tDeduplicatedIssues:");
393             for (int i = 0; i < mDeduplicatedIssues.size(); i++) {
394                 sb.append("\n\t\tSafetySourceIssueInfo=").append(mDeduplicatedIssues.get(i));
395             }
396 
397             sb.append("\n\tFilteredOutDuplicates:");
398             for (int i = 0; i < mFilteredOutDuplicates.size(); i++) {
399                 sb.append("\n\t\tSafetySourceIssueInfo=").append(mFilteredOutDuplicates.get(i));
400             }
401 
402             sb.append("\n\tIssueToGroupMapping");
403             for (Map.Entry<SafetyCenterIssueKey, Set<String>> entry : mIssueToGroup.entrySet()) {
404                 sb.append("\n\t\tSafetyCenterIssueKey=")
405                         .append(toUserFriendlyString(entry.getKey()))
406                         .append(" maps to groups: ");
407                 for (String group : entry.getValue()) {
408                     sb.append(group).append(",");
409                 }
410             }
411 
412             return sb.toString();
413         }
414     }
415 
416     private static final class DeduplicationKey {
417 
418         private final String mDeduplicationGroup;
419         private final String mDeduplicationId;
420         private final int mUserId;
421 
422         private DeduplicationKey(
423                 String deduplicationGroup, String deduplicationId, @UserIdInt int userId) {
424             mDeduplicationGroup = deduplicationGroup;
425             mDeduplicationId = deduplicationId;
426             mUserId = userId;
427         }
428 
429         @Override
430         public int hashCode() {
431             return Objects.hash(mDeduplicationGroup, mDeduplicationId, mUserId);
432         }
433 
434         @Override
435         public boolean equals(Object o) {
436             if (this == o) return true;
437             if (!(o instanceof DeduplicationKey)) return false;
438             DeduplicationKey dedupKey = (DeduplicationKey) o;
439             return mDeduplicationGroup.equals(dedupKey.mDeduplicationGroup)
440                     && mDeduplicationId.equals(dedupKey.mDeduplicationId)
441                     && mUserId == dedupKey.mUserId;
442         }
443     }
444 }
445