• 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_MIN;
20 import static android.service.notification.NotificationListenerService.Ranking.USER_SENTIMENT_NEGATIVE;
21 
22 import android.app.INotificationManager;
23 import android.content.ContentResolver;
24 import android.content.Context;
25 import android.database.ContentObserver;
26 import android.ext.services.R;
27 import android.net.Uri;
28 import android.os.AsyncTask;
29 import android.os.Bundle;
30 import android.os.Environment;
31 import android.os.Handler;
32 import android.os.storage.StorageManager;
33 import android.provider.Settings;
34 import android.service.notification.Adjustment;
35 import android.service.notification.NotificationAssistantService;
36 import android.service.notification.NotificationStats;
37 import android.service.notification.StatusBarNotification;
38 import android.util.ArrayMap;
39 import android.util.AtomicFile;
40 import android.util.Log;
41 import android.util.Slog;
42 import android.util.Xml;
43 
44 import com.android.internal.util.FastXmlSerializer;
45 import com.android.internal.util.XmlUtils;
46 
47 import libcore.io.IoUtils;
48 
49 import org.xmlpull.v1.XmlPullParser;
50 import org.xmlpull.v1.XmlPullParserException;
51 import org.xmlpull.v1.XmlSerializer;
52 
53 import java.io.File;
54 import java.io.FileNotFoundException;
55 import java.io.FileOutputStream;
56 import java.io.IOException;
57 import java.io.InputStream;
58 import java.nio.charset.StandardCharsets;
59 import java.util.ArrayList;
60 import java.util.Map;
61 
62 /**
63  * Notification assistant that provides guidance on notification channel blocking
64  */
65 public class Assistant extends NotificationAssistantService {
66     private static final String TAG = "ExtAssistant";
67     private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
68 
69     private static final String TAG_ASSISTANT = "assistant";
70     private static final String TAG_IMPRESSION = "impression-set";
71     private static final String ATT_KEY = "key";
72     private static final int DB_VERSION = 1;
73     private static final String ATTR_VERSION = "version";
74 
75     private static final ArrayList<Integer> PREJUDICAL_DISMISSALS = new ArrayList<>();
76     static {
77         PREJUDICAL_DISMISSALS.add(REASON_CANCEL);
78         PREJUDICAL_DISMISSALS.add(REASON_LISTENER_CANCEL);
79     }
80 
81     private float mDismissToViewRatioLimit;
82     private int mStreakLimit;
83 
84     // key : impressions tracker
85     // TODO: prune deleted channels and apps
86     final ArrayMap<String, ChannelImpressions> mkeyToImpressions = new ArrayMap<>();
87     // SBN key : channel id
88     ArrayMap<String, String> mLiveNotifications = new ArrayMap<>();
89 
90     private Ranking mFakeRanking = null;
91     private AtomicFile mFile = null;
92 
Assistant()93     public Assistant() {
94     }
95 
96     @Override
onCreate()97     public void onCreate() {
98         super.onCreate();
99         // Contexts are correctly hooked up by the creation step, which is required for the observer
100         // to be hooked up/initialized.
101         new SettingsObserver(mHandler);
102     }
103 
loadFile()104     private void loadFile() {
105         if (DEBUG) Slog.d(TAG, "loadFile");
106         AsyncTask.execute(() -> {
107             InputStream infile = null;
108             try {
109                 infile = mFile.openRead();
110                 readXml(infile);
111             } catch (FileNotFoundException e) {
112                 Log.d(TAG, "File doesn't exist or isn't readable yet");
113             } catch (IOException e) {
114                 Log.e(TAG, "Unable to read channel impressions", e);
115             } catch (NumberFormatException | XmlPullParserException e) {
116                 Log.e(TAG, "Unable to parse channel impressions", e);
117             } finally {
118                 IoUtils.closeQuietly(infile);
119             }
120         });
121     }
122 
readXml(InputStream stream)123     protected void readXml(InputStream stream)
124             throws XmlPullParserException, NumberFormatException, IOException {
125         final XmlPullParser parser = Xml.newPullParser();
126         parser.setInput(stream, StandardCharsets.UTF_8.name());
127         final int outerDepth = parser.getDepth();
128         while (XmlUtils.nextElementWithin(parser, outerDepth)) {
129             if (!TAG_ASSISTANT.equals(parser.getName())) {
130                 continue;
131             }
132             final int impressionOuterDepth = parser.getDepth();
133             while (XmlUtils.nextElementWithin(parser, impressionOuterDepth)) {
134                 if (!TAG_IMPRESSION.equals(parser.getName())) {
135                     continue;
136                 }
137                 String key = parser.getAttributeValue(null, ATT_KEY);
138                 ChannelImpressions ci = createChannelImpressionsWithThresholds();
139                 ci.populateFromXml(parser);
140                 synchronized (mkeyToImpressions) {
141                     ci.append(mkeyToImpressions.get(key));
142                     mkeyToImpressions.put(key, ci);
143                 }
144             }
145         }
146     }
147 
saveFile()148     private void saveFile() throws IOException {
149         AsyncTask.execute(() -> {
150             final FileOutputStream stream;
151             try {
152                 stream = mFile.startWrite();
153             } catch (IOException e) {
154                 Slog.w(TAG, "Failed to save policy file", e);
155                 return;
156             }
157             try {
158                 final XmlSerializer out = new FastXmlSerializer();
159                 out.setOutput(stream, StandardCharsets.UTF_8.name());
160                 writeXml(out);
161                 mFile.finishWrite(stream);
162             } catch (IOException e) {
163                 Slog.w(TAG, "Failed to save impressions file, restoring backup", e);
164                 mFile.failWrite(stream);
165             }
166         });
167     }
168 
writeXml(XmlSerializer out)169     protected void writeXml(XmlSerializer out) throws IOException {
170         out.startDocument(null, true);
171         out.startTag(null, TAG_ASSISTANT);
172         out.attribute(null, ATTR_VERSION, Integer.toString(DB_VERSION));
173         synchronized (mkeyToImpressions) {
174             for (Map.Entry<String, ChannelImpressions> entry
175                     : mkeyToImpressions.entrySet()) {
176                 // TODO: ensure channel still exists
177                 out.startTag(null, TAG_IMPRESSION);
178                 out.attribute(null, ATT_KEY, entry.getKey());
179                 entry.getValue().writeXml(out);
180                 out.endTag(null, TAG_IMPRESSION);
181             }
182         }
183         out.endTag(null, TAG_ASSISTANT);
184         out.endDocument();
185     }
186 
187     @Override
onNotificationEnqueued(StatusBarNotification sbn)188     public Adjustment onNotificationEnqueued(StatusBarNotification sbn) {
189         if (DEBUG) Log.i(TAG, "ENQUEUED " + sbn.getKey());
190         return null;
191     }
192 
193     @Override
onNotificationPosted(StatusBarNotification sbn, RankingMap rankingMap)194     public void onNotificationPosted(StatusBarNotification sbn, RankingMap rankingMap) {
195         if (DEBUG) Log.i(TAG, "POSTED " + sbn.getKey());
196         try {
197             Ranking ranking = getRanking(sbn.getKey(), rankingMap);
198             if (ranking != null && ranking.getChannel() != null) {
199                 String key = getKey(
200                         sbn.getPackageName(), sbn.getUserId(), ranking.getChannel().getId());
201                 ChannelImpressions ci = mkeyToImpressions.getOrDefault(key,
202                         createChannelImpressionsWithThresholds());
203                 if (ranking.getImportance() > IMPORTANCE_MIN && ci.shouldTriggerBlock()) {
204                     adjustNotification(createNegativeAdjustment(
205                             sbn.getPackageName(), sbn.getKey(), sbn.getUserId()));
206                 }
207                 mkeyToImpressions.put(key, ci);
208                 mLiveNotifications.put(sbn.getKey(), ranking.getChannel().getId());
209             }
210         } catch (Throwable e) {
211             Log.e(TAG, "Error occurred processing post", e);
212         }
213     }
214 
215     @Override
onNotificationRemoved(StatusBarNotification sbn, RankingMap rankingMap, NotificationStats stats, int reason)216     public void onNotificationRemoved(StatusBarNotification sbn, RankingMap rankingMap,
217             NotificationStats stats, int reason) {
218         try {
219             boolean updatedImpressions = false;
220             String channelId = mLiveNotifications.remove(sbn.getKey());
221             String key = getKey(sbn.getPackageName(), sbn.getUserId(), channelId);
222             synchronized (mkeyToImpressions) {
223                 ChannelImpressions ci = mkeyToImpressions.getOrDefault(key,
224                         createChannelImpressionsWithThresholds());
225                 if (stats.hasSeen()) {
226                     ci.incrementViews();
227                     updatedImpressions = true;
228                 }
229                 if (PREJUDICAL_DISMISSALS.contains(reason)) {
230                     if ((!sbn.isAppGroup() || sbn.getNotification().isGroupChild())
231                             && !stats.hasInteracted()
232                             && stats.getDismissalSurface() != NotificationStats.DISMISSAL_AOD
233                             && stats.getDismissalSurface() != NotificationStats.DISMISSAL_PEEK
234                             && stats.getDismissalSurface() != NotificationStats.DISMISSAL_OTHER) {
235                         if (DEBUG) Log.i(TAG, "increment dismissals " + key);
236                         ci.incrementDismissals();
237                         updatedImpressions = true;
238                     } else {
239                         if (DEBUG) Slog.i(TAG, "reset streak " + key);
240                         if (ci.getStreak() > 0) {
241                             updatedImpressions = true;
242                         }
243                         ci.resetStreak();
244                     }
245                 }
246                 mkeyToImpressions.put(key, ci);
247             }
248             if (updatedImpressions) {
249                 saveFile();
250             }
251         } catch (Throwable e) {
252             Slog.e(TAG, "Error occurred processing removal", e);
253         }
254     }
255 
256     @Override
onNotificationSnoozedUntilContext(StatusBarNotification sbn, String snoozeCriterionId)257     public void onNotificationSnoozedUntilContext(StatusBarNotification sbn,
258             String snoozeCriterionId) {
259     }
260 
261     @Override
onListenerConnected()262     public void onListenerConnected() {
263         if (DEBUG) Log.i(TAG, "CONNECTED");
264         try {
265             mFile = new AtomicFile(new File(new File(
266                     Environment.getDataUserCePackageDirectory(
267                             StorageManager.UUID_PRIVATE_INTERNAL, getUserId(), getPackageName()),
268                     "assistant"), "blocking_helper_stats.xml"));
269             loadFile();
270             for (StatusBarNotification sbn : getActiveNotifications()) {
271                 onNotificationPosted(sbn);
272             }
273         } catch (Throwable e) {
274             Log.e(TAG, "Error occurred on connection", e);
275         }
276     }
277 
getKey(String pkg, int userId, String channelId)278     protected String getKey(String pkg, int userId, String channelId) {
279         return pkg + "|" + userId + "|" + channelId;
280     }
281 
getRanking(String key, RankingMap rankingMap)282     private Ranking getRanking(String key, RankingMap rankingMap) {
283         if (mFakeRanking != null) {
284             return mFakeRanking;
285         }
286         Ranking ranking = new Ranking();
287         rankingMap.getRanking(key, ranking);
288         return ranking;
289     }
290 
createNegativeAdjustment(String packageName, String key, int user)291     private Adjustment createNegativeAdjustment(String packageName, String key, int user) {
292         if (DEBUG) Log.d(TAG, "User probably doesn't want " + key);
293         Bundle signals = new Bundle();
294         signals.putInt(Adjustment.KEY_USER_SENTIMENT, USER_SENTIMENT_NEGATIVE);
295         return new Adjustment(packageName, key,  signals,
296                 getContext().getString(R.string.prompt_block_reason), user);
297     }
298 
299     // for testing
300 
setFile(AtomicFile file)301     protected void setFile(AtomicFile file) {
302         mFile = file;
303     }
304 
setFakeRanking(Ranking ranking)305     protected void setFakeRanking(Ranking ranking) {
306         mFakeRanking = ranking;
307     }
308 
setNoMan(INotificationManager noMan)309     protected void setNoMan(INotificationManager noMan) {
310         mNoMan = noMan;
311     }
312 
setContext(Context context)313     protected void setContext(Context context) {
314         mSystemContext = context;
315     }
316 
getImpressions(String key)317     protected ChannelImpressions getImpressions(String key) {
318         synchronized (mkeyToImpressions) {
319             return mkeyToImpressions.get(key);
320         }
321     }
322 
insertImpressions(String key, ChannelImpressions ci)323     protected void insertImpressions(String key, ChannelImpressions ci) {
324         synchronized (mkeyToImpressions) {
325             mkeyToImpressions.put(key, ci);
326         }
327     }
328 
createChannelImpressionsWithThresholds()329     private ChannelImpressions createChannelImpressionsWithThresholds() {
330         ChannelImpressions impressions = new ChannelImpressions();
331         impressions.updateThresholds(mDismissToViewRatioLimit, mStreakLimit);
332         return impressions;
333     }
334 
335     /**
336      * Observer for updates on blocking helper threshold values.
337      */
338     private final class SettingsObserver extends ContentObserver {
339         private final Uri STREAK_LIMIT_URI =
340                 Settings.Global.getUriFor(Settings.Global.BLOCKING_HELPER_STREAK_LIMIT);
341         private final Uri DISMISS_TO_VIEW_RATIO_LIMIT_URI =
342                 Settings.Global.getUriFor(
343                         Settings.Global.BLOCKING_HELPER_DISMISS_TO_VIEW_RATIO_LIMIT);
344 
SettingsObserver(Handler handler)345         public SettingsObserver(Handler handler) {
346             super(handler);
347             ContentResolver resolver = getApplicationContext().getContentResolver();
348             resolver.registerContentObserver(
349                     DISMISS_TO_VIEW_RATIO_LIMIT_URI, false, this, getUserId());
350             resolver.registerContentObserver(STREAK_LIMIT_URI, false, this, getUserId());
351 
352             // Update all uris on creation.
353             update(null);
354         }
355 
356         @Override
onChange(boolean selfChange, Uri uri)357         public void onChange(boolean selfChange, Uri uri) {
358             update(uri);
359         }
360 
update(Uri uri)361         private void update(Uri uri) {
362             ContentResolver resolver = getApplicationContext().getContentResolver();
363             if (uri == null || DISMISS_TO_VIEW_RATIO_LIMIT_URI.equals(uri)) {
364                 mDismissToViewRatioLimit = Settings.Global.getFloat(
365                         resolver, Settings.Global.BLOCKING_HELPER_DISMISS_TO_VIEW_RATIO_LIMIT,
366                         ChannelImpressions.DEFAULT_DISMISS_TO_VIEW_RATIO_LIMIT);
367             }
368             if (uri == null || STREAK_LIMIT_URI.equals(uri)) {
369                 mStreakLimit = Settings.Global.getInt(
370                         resolver, Settings.Global.BLOCKING_HELPER_STREAK_LIMIT,
371                         ChannelImpressions.DEFAULT_STREAK_LIMIT);
372             }
373 
374             // Update all existing channel impression objects with any new limits/thresholds.
375             synchronized (mkeyToImpressions) {
376                 for (ChannelImpressions channelImpressions: mkeyToImpressions.values()) {
377                     channelImpressions.updateThresholds(mDismissToViewRatioLimit, mStreakLimit);
378                 }
379             }
380         }
381     }
382 }