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