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