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