1 /** 2 * Copyright (C) 2017 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 android.ext.services.notification; 18 19 import static android.app.NotificationManager.IMPORTANCE_LOW; 20 import static android.app.NotificationManager.IMPORTANCE_MIN; 21 import static android.service.notification.Adjustment.KEY_IMPORTANCE; 22 import static android.service.notification.NotificationListenerService.Ranking.USER_SENTIMENT_NEGATIVE; 23 24 import android.annotation.NonNull; 25 import android.annotation.Nullable; 26 import android.annotation.SuppressLint; 27 import android.app.ActivityThread; 28 import android.app.INotificationManager; 29 import android.app.Notification; 30 import android.app.NotificationChannel; 31 import android.content.Context; 32 import android.content.pm.IPackageManager; 33 import android.os.AsyncTask; 34 import android.os.Bundle; 35 import android.os.Environment; 36 import android.os.UserHandle; 37 import android.os.storage.StorageManager; 38 import android.service.notification.Adjustment; 39 import android.service.notification.NotificationAssistantService; 40 import android.service.notification.NotificationStats; 41 import android.service.notification.StatusBarNotification; 42 import android.util.ArrayMap; 43 import android.util.AtomicFile; 44 import android.util.Log; 45 import android.util.Slog; 46 import android.util.Xml; 47 48 import com.android.internal.annotations.VisibleForTesting; 49 import com.android.internal.util.FastXmlSerializer; 50 import com.android.internal.util.XmlUtils; 51 52 import libcore.io.IoUtils; 53 54 import org.xmlpull.v1.XmlPullParser; 55 import org.xmlpull.v1.XmlPullParserException; 56 import org.xmlpull.v1.XmlSerializer; 57 58 import java.io.File; 59 import java.io.FileNotFoundException; 60 import java.io.FileOutputStream; 61 import java.io.IOException; 62 import java.io.InputStream; 63 import java.nio.charset.StandardCharsets; 64 import java.util.ArrayList; 65 import java.util.List; 66 import java.util.Map; 67 import java.util.concurrent.ExecutorService; 68 import java.util.concurrent.Executors; 69 70 /** 71 * Notification assistant that provides guidance on notification channel blocking 72 */ 73 @SuppressLint("OverrideAbstract") 74 public class Assistant extends NotificationAssistantService { 75 private static final String TAG = "ExtAssistant"; 76 private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG); 77 78 private static final String TAG_ASSISTANT = "assistant"; 79 private static final String TAG_IMPRESSION = "impression-set"; 80 private static final String ATT_KEY = "key"; 81 private static final int DB_VERSION = 1; 82 private static final String ATTR_VERSION = "version"; 83 private final ExecutorService mSingleThreadExecutor = Executors.newSingleThreadExecutor(); 84 85 private static final ArrayList<Integer> PREJUDICAL_DISMISSALS = new ArrayList<>(); 86 static { 87 PREJUDICAL_DISMISSALS.add(REASON_CANCEL); 88 PREJUDICAL_DISMISSALS.add(REASON_LISTENER_CANCEL); 89 } 90 91 private SmartActionsHelper mSmartActionsHelper; 92 private NotificationCategorizer mNotificationCategorizer; 93 94 // key : impressions tracker 95 // TODO: prune deleted channels and apps 96 private final ArrayMap<String, ChannelImpressions> mkeyToImpressions = new ArrayMap<>(); 97 // SBN key : entry 98 protected ArrayMap<String, NotificationEntry> mLiveNotifications = new ArrayMap<>(); 99 100 private Ranking mFakeRanking = null; 101 private AtomicFile mFile = null; 102 private IPackageManager mPackageManager; 103 104 @VisibleForTesting 105 protected AssistantSettings.Factory mSettingsFactory = AssistantSettings.FACTORY; 106 @VisibleForTesting 107 protected AssistantSettings mSettings; 108 private SmsHelper mSmsHelper; 109 Assistant()110 public Assistant() { 111 } 112 113 @Override onCreate()114 public void onCreate() { 115 super.onCreate(); 116 // Contexts are correctly hooked up by the creation step, which is required for the observer 117 // to be hooked up/initialized. 118 mPackageManager = ActivityThread.getPackageManager(); 119 mSettings = mSettingsFactory.createAndRegister(mHandler, 120 getApplicationContext().getContentResolver(), getUserId(), this::updateThresholds); 121 mSmartActionsHelper = new SmartActionsHelper(getContext(), mSettings); 122 mNotificationCategorizer = new NotificationCategorizer(); 123 mSmsHelper = new SmsHelper(this); 124 mSmsHelper.initialize(); 125 } 126 127 @Override onDestroy()128 public void onDestroy() { 129 // This null check is only for the unit tests as ServiceTestCase.tearDown calls onDestroy 130 // without having first called onCreate. 131 if (mSmsHelper != null) { 132 mSmsHelper.destroy(); 133 } 134 super.onDestroy(); 135 } 136 loadFile()137 private void loadFile() { 138 if (DEBUG) Slog.d(TAG, "loadFile"); 139 AsyncTask.execute(() -> { 140 InputStream infile = null; 141 try { 142 infile = mFile.openRead(); 143 readXml(infile); 144 } catch (FileNotFoundException e) { 145 Log.d(TAG, "File doesn't exist or isn't readable yet"); 146 } catch (IOException e) { 147 Log.e(TAG, "Unable to read channel impressions", e); 148 } catch (NumberFormatException | XmlPullParserException e) { 149 Log.e(TAG, "Unable to parse channel impressions", e); 150 } finally { 151 IoUtils.closeQuietly(infile); 152 } 153 }); 154 } 155 readXml(InputStream stream)156 protected void readXml(InputStream stream) 157 throws XmlPullParserException, NumberFormatException, IOException { 158 final XmlPullParser parser = Xml.newPullParser(); 159 parser.setInput(stream, StandardCharsets.UTF_8.name()); 160 final int outerDepth = parser.getDepth(); 161 while (XmlUtils.nextElementWithin(parser, outerDepth)) { 162 if (!TAG_ASSISTANT.equals(parser.getName())) { 163 continue; 164 } 165 final int impressionOuterDepth = parser.getDepth(); 166 while (XmlUtils.nextElementWithin(parser, impressionOuterDepth)) { 167 if (!TAG_IMPRESSION.equals(parser.getName())) { 168 continue; 169 } 170 String key = parser.getAttributeValue(null, ATT_KEY); 171 ChannelImpressions ci = createChannelImpressionsWithThresholds(); 172 ci.populateFromXml(parser); 173 synchronized (mkeyToImpressions) { 174 ci.append(mkeyToImpressions.get(key)); 175 mkeyToImpressions.put(key, ci); 176 } 177 } 178 } 179 } 180 saveFile()181 private void saveFile() { 182 AsyncTask.execute(() -> { 183 final FileOutputStream stream; 184 try { 185 stream = mFile.startWrite(); 186 } catch (IOException e) { 187 Slog.w(TAG, "Failed to save policy file", e); 188 return; 189 } 190 try { 191 final XmlSerializer out = new FastXmlSerializer(); 192 out.setOutput(stream, StandardCharsets.UTF_8.name()); 193 writeXml(out); 194 mFile.finishWrite(stream); 195 } catch (IOException e) { 196 Slog.w(TAG, "Failed to save impressions file, restoring backup", e); 197 mFile.failWrite(stream); 198 } 199 }); 200 } 201 writeXml(XmlSerializer out)202 protected void writeXml(XmlSerializer out) throws IOException { 203 out.startDocument(null, true); 204 out.startTag(null, TAG_ASSISTANT); 205 out.attribute(null, ATTR_VERSION, Integer.toString(DB_VERSION)); 206 synchronized (mkeyToImpressions) { 207 for (Map.Entry<String, ChannelImpressions> entry 208 : mkeyToImpressions.entrySet()) { 209 // TODO: ensure channel still exists 210 out.startTag(null, TAG_IMPRESSION); 211 out.attribute(null, ATT_KEY, entry.getKey()); 212 entry.getValue().writeXml(out); 213 out.endTag(null, TAG_IMPRESSION); 214 } 215 } 216 out.endTag(null, TAG_ASSISTANT); 217 out.endDocument(); 218 } 219 220 @Override onNotificationEnqueued(StatusBarNotification sbn)221 public Adjustment onNotificationEnqueued(StatusBarNotification sbn) { 222 // we use the version with channel, so this is never called. 223 return null; 224 } 225 226 @Override onNotificationEnqueued(StatusBarNotification sbn, NotificationChannel channel)227 public Adjustment onNotificationEnqueued(StatusBarNotification sbn, 228 NotificationChannel channel) { 229 if (DEBUG) Log.i(TAG, "ENQUEUED " + sbn.getKey() + " on " + channel.getId()); 230 if (!isForCurrentUser(sbn)) { 231 return null; 232 } 233 mSingleThreadExecutor.submit(() -> { 234 NotificationEntry entry = 235 new NotificationEntry(getContext(), mPackageManager, sbn, channel, mSmsHelper); 236 SmartActionsHelper.SmartSuggestions suggestions = mSmartActionsHelper.suggest(entry); 237 if (DEBUG) { 238 Log.d(TAG, String.format( 239 "Creating Adjustment for %s, with %d actions, and %d replies.", 240 sbn.getKey(), suggestions.actions.size(), suggestions.replies.size())); 241 } 242 Adjustment adjustment = createEnqueuedNotificationAdjustment( 243 entry, suggestions.actions, suggestions.replies); 244 adjustNotification(adjustment); 245 }); 246 return null; 247 } 248 249 /** A convenience helper for creating an adjustment for an SBN. */ 250 @VisibleForTesting 251 @Nullable createEnqueuedNotificationAdjustment( @onNull NotificationEntry entry, @NonNull ArrayList<Notification.Action> smartActions, @NonNull ArrayList<CharSequence> smartReplies)252 Adjustment createEnqueuedNotificationAdjustment( 253 @NonNull NotificationEntry entry, 254 @NonNull ArrayList<Notification.Action> smartActions, 255 @NonNull ArrayList<CharSequence> smartReplies) { 256 Bundle signals = new Bundle(); 257 258 if (!smartActions.isEmpty()) { 259 signals.putParcelableArrayList(Adjustment.KEY_CONTEXTUAL_ACTIONS, smartActions); 260 } 261 if (!smartReplies.isEmpty()) { 262 signals.putCharSequenceArrayList(Adjustment.KEY_TEXT_REPLIES, smartReplies); 263 } 264 if (mSettings.mNewInterruptionModel) { 265 if (mNotificationCategorizer.shouldSilence(entry)) { 266 final int importance = entry.getImportance() < IMPORTANCE_LOW 267 ? entry.getImportance() : IMPORTANCE_LOW; 268 signals.putInt(KEY_IMPORTANCE, importance); 269 } else { 270 // Even if no change is made, send an identity adjustment for metric logging. 271 signals.putInt(KEY_IMPORTANCE, entry.getImportance()); 272 } 273 } 274 275 return new Adjustment( 276 entry.getSbn().getPackageName(), 277 entry.getSbn().getKey(), 278 signals, 279 "", 280 entry.getSbn().getUserId()); 281 } 282 283 @Override 284 public void onNotificationPosted(StatusBarNotification sbn, RankingMap rankingMap) { 285 if (DEBUG) Log.i(TAG, "POSTED " + sbn.getKey()); 286 try { 287 if (!isForCurrentUser(sbn)) { 288 return; 289 } 290 Ranking ranking = getRanking(sbn.getKey(), rankingMap); 291 if (ranking != null && ranking.getChannel() != null) { 292 NotificationEntry entry = new NotificationEntry(getContext(), mPackageManager, 293 sbn, ranking.getChannel(), mSmsHelper); 294 String key = getKey( 295 sbn.getPackageName(), sbn.getUserId(), ranking.getChannel().getId()); 296 boolean shouldTriggerBlock; 297 synchronized (mkeyToImpressions) { 298 ChannelImpressions ci = mkeyToImpressions.getOrDefault(key, 299 createChannelImpressionsWithThresholds()); 300 mkeyToImpressions.put(key, ci); 301 shouldTriggerBlock = ci.shouldTriggerBlock(); 302 } 303 if (ranking.getImportance() > IMPORTANCE_MIN && shouldTriggerBlock) { 304 adjustNotification(createNegativeAdjustment( 305 sbn.getPackageName(), sbn.getKey(), sbn.getUserId())); 306 } 307 mLiveNotifications.put(sbn.getKey(), entry); 308 } 309 } catch (Throwable e) { 310 Log.e(TAG, "Error occurred processing post", e); 311 } 312 } 313 314 @Override onNotificationRemoved(StatusBarNotification sbn, RankingMap rankingMap, NotificationStats stats, int reason)315 public void onNotificationRemoved(StatusBarNotification sbn, RankingMap rankingMap, 316 NotificationStats stats, int reason) { 317 try { 318 if (!isForCurrentUser(sbn)) { 319 return; 320 } 321 322 boolean updatedImpressions = false; 323 String channelId = mLiveNotifications.remove(sbn.getKey()).getChannel().getId(); 324 String key = getKey(sbn.getPackageName(), sbn.getUserId(), channelId); 325 synchronized (mkeyToImpressions) { 326 ChannelImpressions ci = mkeyToImpressions.getOrDefault(key, 327 createChannelImpressionsWithThresholds()); 328 if (stats != null && stats.hasSeen()) { 329 ci.incrementViews(); 330 updatedImpressions = true; 331 } 332 if (PREJUDICAL_DISMISSALS.contains(reason)) { 333 if ((!sbn.isAppGroup() || sbn.getNotification().isGroupChild()) 334 && !stats.hasInteracted() 335 && stats.getDismissalSurface() != NotificationStats.DISMISSAL_AOD 336 && stats.getDismissalSurface() != NotificationStats.DISMISSAL_PEEK 337 && stats.getDismissalSurface() != NotificationStats.DISMISSAL_OTHER) { 338 if (DEBUG) Log.i(TAG, "increment dismissals " + key); 339 ci.incrementDismissals(); 340 updatedImpressions = true; 341 } else { 342 if (DEBUG) Slog.i(TAG, "reset streak " + key); 343 if (ci.getStreak() > 0) { 344 updatedImpressions = true; 345 } 346 ci.resetStreak(); 347 } 348 } 349 mkeyToImpressions.put(key, ci); 350 } 351 if (updatedImpressions) { 352 saveFile(); 353 } 354 } catch (Throwable e) { 355 Slog.e(TAG, "Error occurred processing removal of " + sbn, e); 356 } 357 } 358 359 @Override onNotificationSnoozedUntilContext(StatusBarNotification sbn, String snoozeCriterionId)360 public void onNotificationSnoozedUntilContext(StatusBarNotification sbn, 361 String snoozeCriterionId) { 362 } 363 364 @Override onNotificationsSeen(List<String> keys)365 public void onNotificationsSeen(List<String> keys) { 366 } 367 368 @Override onNotificationExpansionChanged(@onNull String key, boolean isUserAction, boolean isExpanded)369 public void onNotificationExpansionChanged(@NonNull String key, boolean isUserAction, 370 boolean isExpanded) { 371 if (DEBUG) { 372 Log.d(TAG, "onNotificationExpansionChanged() called with: key = [" + key 373 + "], isUserAction = [" + isUserAction + "], isExpanded = [" + isExpanded 374 + "]"); 375 } 376 NotificationEntry entry = mLiveNotifications.get(key); 377 378 if (entry != null) { 379 mSingleThreadExecutor.submit( 380 () -> mSmartActionsHelper.onNotificationExpansionChanged(entry, isExpanded)); 381 } 382 } 383 384 @Override onNotificationDirectReplied(@onNull String key)385 public void onNotificationDirectReplied(@NonNull String key) { 386 if (DEBUG) Log.i(TAG, "onNotificationDirectReplied " + key); 387 mSingleThreadExecutor.submit(() -> mSmartActionsHelper.onNotificationDirectReplied(key)); 388 } 389 390 @Override onSuggestedReplySent(@onNull String key, @NonNull CharSequence reply, @Source int source)391 public void onSuggestedReplySent(@NonNull String key, @NonNull CharSequence reply, 392 @Source int source) { 393 if (DEBUG) { 394 Log.d(TAG, "onSuggestedReplySent() called with: key = [" + key + "], reply = [" + reply 395 + "], source = [" + source + "]"); 396 } 397 mSingleThreadExecutor.submit( 398 () -> mSmartActionsHelper.onSuggestedReplySent(key, reply, source)); 399 } 400 401 @Override onActionInvoked(@onNull String key, @NonNull Notification.Action action, @Source int source)402 public void onActionInvoked(@NonNull String key, @NonNull Notification.Action action, 403 @Source int source) { 404 if (DEBUG) { 405 Log.d(TAG, 406 "onActionInvoked() called with: key = [" + key + "], action = [" + action.title 407 + "], source = [" + source + "]"); 408 } 409 mSingleThreadExecutor.submit( 410 () -> mSmartActionsHelper.onActionClicked(key, action, source)); 411 } 412 413 @Override onListenerConnected()414 public void onListenerConnected() { 415 if (DEBUG) Log.i(TAG, "CONNECTED"); 416 try { 417 mFile = new AtomicFile(new File(new File( 418 Environment.getDataUserCePackageDirectory( 419 StorageManager.UUID_PRIVATE_INTERNAL, getUserId(), getPackageName()), 420 "assistant"), "blocking_helper_stats.xml")); 421 loadFile(); 422 for (StatusBarNotification sbn : getActiveNotifications()) { 423 onNotificationPosted(sbn); 424 } 425 } catch (Throwable e) { 426 Log.e(TAG, "Error occurred on connection", e); 427 } 428 } 429 430 @Override onListenerDisconnected()431 public void onListenerDisconnected() { 432 } 433 isForCurrentUser(StatusBarNotification sbn)434 private boolean isForCurrentUser(StatusBarNotification sbn) { 435 return sbn != null && sbn.getUserId() == UserHandle.myUserId(); 436 } 437 getKey(String pkg, int userId, String channelId)438 protected String getKey(String pkg, int userId, String channelId) { 439 return pkg + "|" + userId + "|" + channelId; 440 } 441 getRanking(String key, RankingMap rankingMap)442 private Ranking getRanking(String key, RankingMap rankingMap) { 443 if (mFakeRanking != null) { 444 return mFakeRanking; 445 } 446 Ranking ranking = new Ranking(); 447 rankingMap.getRanking(key, ranking); 448 return ranking; 449 } 450 createNegativeAdjustment(String packageName, String key, int user)451 private Adjustment createNegativeAdjustment(String packageName, String key, int user) { 452 if (DEBUG) Log.d(TAG, "User probably doesn't want " + key); 453 Bundle signals = new Bundle(); 454 signals.putInt(Adjustment.KEY_USER_SENTIMENT, USER_SENTIMENT_NEGATIVE); 455 return new Adjustment(packageName, key, signals, "", user); 456 } 457 458 // for testing 459 460 @VisibleForTesting setFile(AtomicFile file)461 public void setFile(AtomicFile file) { 462 mFile = file; 463 } 464 465 @VisibleForTesting setFakeRanking(Ranking ranking)466 public void setFakeRanking(Ranking ranking) { 467 mFakeRanking = ranking; 468 } 469 470 @VisibleForTesting setNoMan(INotificationManager noMan)471 public void setNoMan(INotificationManager noMan) { 472 mNoMan = noMan; 473 } 474 475 @VisibleForTesting setContext(Context context)476 public void setContext(Context context) { 477 mSystemContext = context; 478 } 479 480 @VisibleForTesting setPackageManager(IPackageManager pm)481 public void setPackageManager(IPackageManager pm) { 482 mPackageManager = pm; 483 } 484 485 @VisibleForTesting getImpressions(String key)486 public ChannelImpressions getImpressions(String key) { 487 synchronized (mkeyToImpressions) { 488 return mkeyToImpressions.get(key); 489 } 490 } 491 492 @VisibleForTesting insertImpressions(String key, ChannelImpressions ci)493 public void insertImpressions(String key, ChannelImpressions ci) { 494 synchronized (mkeyToImpressions) { 495 mkeyToImpressions.put(key, ci); 496 } 497 } 498 createChannelImpressionsWithThresholds()499 private ChannelImpressions createChannelImpressionsWithThresholds() { 500 ChannelImpressions impressions = new ChannelImpressions(); 501 impressions.updateThresholds(mSettings.mDismissToViewRatioLimit, mSettings.mStreakLimit); 502 return impressions; 503 } 504 updateThresholds()505 private void updateThresholds() { 506 // Update all existing channel impression objects with any new limits/thresholds. 507 synchronized (mkeyToImpressions) { 508 for (ChannelImpressions channelImpressions: mkeyToImpressions.values()) { 509 channelImpressions.updateThresholds( 510 mSettings.mDismissToViewRatioLimit, mSettings.mStreakLimit); 511 } 512 } 513 } 514 } 515