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 }