• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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