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.content.pm.PackageManager.FEATURE_WATCH; 20 21 import android.annotation.SuppressLint; 22 import android.app.ActivityManager; 23 import android.app.Notification; 24 import android.app.NotificationChannel; 25 import android.content.pm.PackageManager; 26 import android.os.Bundle; 27 import android.os.Trace; 28 import android.os.UserHandle; 29 import android.service.notification.Adjustment; 30 import android.service.notification.NotificationAssistantService; 31 import android.service.notification.NotificationStats; 32 import android.service.notification.StatusBarNotification; 33 import android.util.ArrayMap; 34 import android.util.Log; 35 import android.view.textclassifier.TextClassificationManager; 36 37 import androidx.annotation.NonNull; 38 import androidx.annotation.Nullable; 39 import androidx.annotation.VisibleForTesting; 40 41 import com.android.textclassifier.notification.SmartSuggestions; 42 import com.android.textclassifier.notification.SmartSuggestionsHelper; 43 44 import java.util.ArrayList; 45 import java.util.List; 46 import java.util.concurrent.ExecutorService; 47 import java.util.concurrent.Executors; 48 import java.util.concurrent.Future; 49 import java.util.concurrent.ThreadPoolExecutor; 50 51 /** 52 * Notification assistant that provides guidance on notification channel blocking 53 */ 54 @SuppressLint("OverrideAbstract") 55 public class Assistant extends NotificationAssistantService { 56 private static final String TAG = "ExtAssistant"; 57 private static final int MAX_QUEUED_ML_JOBS = 20; 58 private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG); 59 60 // SBN key : entry 61 protected ArrayMap<String, NotificationEntry> mLiveNotifications = new ArrayMap<>(); 62 63 @VisibleForTesting 64 protected boolean mUseTextClassifier = true; 65 66 @VisibleForTesting 67 protected PackageManager mPm; 68 69 @VisibleForTesting 70 protected ActivityManager mAm; 71 72 protected final ExecutorService mSingleThreadExecutor = Executors.newSingleThreadExecutor(); 73 // Using newFixedThreadPool because that returns a ThreadPoolExecutor, allowing us to access 74 // the queue of jobs 75 private final ThreadPoolExecutor mMachineLearningExecutor = 76 (ThreadPoolExecutor) Executors.newFixedThreadPool(1); 77 @VisibleForTesting 78 protected AssistantSettings.Factory mSettingsFactory = AssistantSettings.FACTORY; 79 @VisibleForTesting 80 protected AssistantSettings mSettings; 81 private SmsHelper mSmsHelper; 82 @VisibleForTesting 83 protected SmartSuggestionsHelper mSmartSuggestionsHelper; 84 85 @VisibleForTesting 86 protected TextClassificationManager mTcm; 87 Assistant()88 public Assistant() { 89 } 90 91 @Override onCreate()92 public void onCreate() { 93 super.onCreate(); 94 // Contexts are correctly hooked up by the creation step, which is required for the observer 95 // to be hooked up/initialized. 96 mPm = getPackageManager(); 97 mAm = getSystemService(ActivityManager.class); 98 mTcm = getSystemService(TextClassificationManager.class); 99 mSettings = mSettingsFactory.createAndRegister(); 100 mSmartSuggestionsHelper = new SmartSuggestionsHelper(this, mSettings); 101 mSmsHelper = new SmsHelper(this); 102 mSmsHelper.initialize(); 103 setUseTextClassifier(); 104 } 105 106 @VisibleForTesting setUseTextClassifier()107 protected void setUseTextClassifier() { 108 mUseTextClassifier = !(mAm.isLowRamDevice() || mPm.hasSystemFeature(FEATURE_WATCH)); 109 } 110 111 @Override onDestroy()112 public void onDestroy() { 113 // This null check is only for the unit tests as ServiceTestCase.tearDown calls onDestroy 114 // without having first called onCreate. 115 if (mSmsHelper != null) { 116 mSmsHelper.destroy(); 117 } 118 super.onDestroy(); 119 } 120 121 @Override onNotificationEnqueued(@onNull StatusBarNotification sbn)122 public Adjustment onNotificationEnqueued(@NonNull StatusBarNotification sbn) { 123 // we use the version with channel, so this is never called. 124 return null; 125 } 126 127 @Override onNotificationEnqueued(@onNull StatusBarNotification sbn, @NonNull NotificationChannel channel)128 public Adjustment onNotificationEnqueued(@NonNull StatusBarNotification sbn, 129 @NonNull NotificationChannel channel) { 130 if (DEBUG) Log.i(TAG, "ENQUEUED " + sbn.getKey() + " on " + channel.getId()); 131 if (!isForCurrentUser(sbn)) { 132 return null; 133 } 134 135 final boolean shouldCheckForOtp = 136 NotificationOtpDetectionHelper.shouldCheckForOtp(sbn.getNotification()); 137 boolean foundOtpWithRegex = shouldCheckForOtp 138 && NotificationOtpDetectionHelper 139 .containsOtp(sbn.getNotification(), true, null); 140 Adjustment earlyOtpReturn = null; 141 if (foundOtpWithRegex) { 142 earlyOtpReturn = createNotificationAdjustment(sbn, null, null, true); 143 } 144 145 if (mMachineLearningExecutor.getQueue().size() >= MAX_QUEUED_ML_JOBS) { 146 return earlyOtpReturn; 147 } 148 149 // Ignoring the result of the future 150 Future<?> ignored = mMachineLearningExecutor.submit(() -> { 151 Boolean containsOtp = null; 152 if (shouldCheckForOtp && mUseTextClassifier) { 153 // If we can use the text classifier, do a second pass, using the TC to detect 154 // languages, and potentially using the TC to remove false positives 155 Trace.beginSection(TAG + "_RegexWithTc"); 156 try { 157 containsOtp = NotificationOtpDetectionHelper.containsOtp( 158 sbn.getNotification(), true, mTcm.getTextClassifier()); 159 160 } finally { 161 Trace.endSection(); 162 } 163 } 164 165 // If we found an otp (and didn't already send an adjustment), send an adjustment early 166 if (Boolean.TRUE.equals(containsOtp) && !foundOtpWithRegex) { 167 adjustNotificationIfNotNull( 168 createNotificationAdjustment(sbn, null, null, true)); 169 } 170 171 SmartSuggestions suggestions; 172 Trace.beginSection(TAG + "_SmartSuggestions"); 173 try { 174 suggestions = mSmartSuggestionsHelper.onNotificationEnqueued(sbn); 175 } finally { 176 Trace.endSection(); 177 } 178 179 if (DEBUG) { 180 Log.d(TAG, String.format( 181 "Creating Adjustment for %s, with %d actions, and %d replies.", 182 sbn.getKey(), 183 suggestions.getActions().size(), 184 suggestions.getReplies().size())); 185 } 186 187 adjustNotificationIfNotNull(createNotificationAdjustment( 188 sbn, 189 new ArrayList<>(suggestions.getActions()), 190 new ArrayList<>(suggestions.getReplies()), 191 containsOtp)); 192 }); 193 194 return earlyOtpReturn; 195 } 196 197 // Due to Mockito setup, some methods marked @NonNull can sometimes be called with a 198 // null parameter. This method accounts for that. adjustNotificationIfNotNull(@ullable Adjustment adjustment)199 private void adjustNotificationIfNotNull(@Nullable Adjustment adjustment) { 200 if (adjustment != null) { 201 adjustNotification(adjustment); 202 } 203 } 204 205 /** A convenience helper for creating an adjustment for an SBN. */ 206 @VisibleForTesting createNotificationAdjustment( StatusBarNotification sbn, ArrayList<Notification.Action> smartActions, ArrayList<CharSequence> smartReplies, Boolean hasSensitiveContent)207 protected Adjustment createNotificationAdjustment( 208 StatusBarNotification sbn, 209 ArrayList<Notification.Action> smartActions, 210 ArrayList<CharSequence> smartReplies, 211 Boolean hasSensitiveContent) { 212 if (sbn == null) { 213 // Only happens during mocking tests, when setting up `verify` calls 214 return null; 215 } 216 217 Bundle signals = new Bundle(); 218 219 if (smartActions != null && !smartActions.isEmpty()) { 220 signals.putParcelableArrayList(Adjustment.KEY_CONTEXTUAL_ACTIONS, smartActions); 221 } 222 if (smartReplies != null && !smartReplies.isEmpty()) { 223 signals.putCharSequenceArrayList(Adjustment.KEY_TEXT_REPLIES, smartReplies); 224 } 225 226 if (hasSensitiveContent != null) { 227 signals.putBoolean(Adjustment.KEY_SENSITIVE_CONTENT, hasSensitiveContent); 228 } 229 230 return new Adjustment(sbn.getPackageName(), sbn.getKey(), signals, "", 231 sbn.getUser().getIdentifier()); 232 } 233 234 @Override onNotificationPosted(StatusBarNotification sbn, RankingMap rankingMap)235 public void onNotificationPosted(StatusBarNotification sbn, RankingMap rankingMap) { 236 if (DEBUG) Log.i(TAG, "POSTED " + sbn.getKey()); 237 try { 238 if (!isForCurrentUser(sbn)) { 239 return; 240 } 241 Ranking ranking = new Ranking(); 242 boolean found = rankingMap.getRanking(sbn.getKey(), ranking); 243 if (found && ranking.getChannel() != null) { 244 NotificationEntry entry = new NotificationEntry(this, mPm, 245 sbn, ranking.getChannel(), mSmsHelper); 246 mLiveNotifications.put(sbn.getKey(), entry); 247 } 248 } catch (Throwable e) { 249 Log.e(TAG, "Error occurred processing post", e); 250 } 251 } 252 253 @Override onNotificationRemoved(StatusBarNotification sbn, RankingMap rankingMap, NotificationStats stats, int reason)254 public void onNotificationRemoved(StatusBarNotification sbn, RankingMap rankingMap, 255 NotificationStats stats, int reason) { 256 try { 257 if (!isForCurrentUser(sbn)) { 258 return; 259 } 260 261 mLiveNotifications.remove(sbn.getKey()); 262 263 } catch (Throwable e) { 264 Log.e(TAG, "Error occurred processing removal of " + sbn.getKey(), e); 265 } 266 } 267 268 @Override onNotificationSnoozedUntilContext(@onNull StatusBarNotification sbn, @NonNull String snoozeCriterionId)269 public void onNotificationSnoozedUntilContext(@NonNull StatusBarNotification sbn, 270 @NonNull String snoozeCriterionId) { 271 } 272 273 @Override onNotificationsSeen(@onNull List<String> keys)274 public void onNotificationsSeen(@NonNull List<String> keys) { 275 } 276 277 @Override onNotificationExpansionChanged(@onNull String key, boolean isUserAction, boolean isExpanded)278 public void onNotificationExpansionChanged(@NonNull String key, boolean isUserAction, 279 boolean isExpanded) { 280 if (DEBUG) { 281 Log.d(TAG, "onNotificationExpansionChanged() called with: key = [" + key 282 + "], isUserAction = [" + isUserAction + "], isExpanded = [" + isExpanded 283 + "]"); 284 } 285 NotificationEntry entry = mLiveNotifications.get(key); 286 287 if (entry != null) { 288 mSingleThreadExecutor.submit( 289 () -> mSmartSuggestionsHelper.onNotificationExpansionChanged( 290 entry.getSbn(), isExpanded)); 291 } 292 } 293 294 @Override onNotificationDirectReplied(@onNull String key)295 public void onNotificationDirectReplied(@NonNull String key) { 296 if (DEBUG) Log.i(TAG, "onNotificationDirectReplied " + key); 297 mSingleThreadExecutor.submit( 298 () -> mSmartSuggestionsHelper.onNotificationDirectReplied(key)); 299 } 300 301 @Override onSuggestedReplySent(@onNull String key, @NonNull CharSequence reply, int source)302 public void onSuggestedReplySent(@NonNull String key, @NonNull CharSequence reply, 303 int source) { 304 if (DEBUG) { 305 Log.d(TAG, "onSuggestedReplySent() called with: key = [" + key + "], reply = [" + reply 306 + "], source = [" + source + "]"); 307 } 308 mSingleThreadExecutor.submit( 309 () -> mSmartSuggestionsHelper.onSuggestedReplySent(key, reply, source)); 310 } 311 312 @Override onActionInvoked(@onNull String key, @NonNull Notification.Action action, int source)313 public void onActionInvoked(@NonNull String key, @NonNull Notification.Action action, 314 int source) { 315 if (DEBUG) { 316 Log.d(TAG, 317 "onActionInvoked() called with: key = [" + key + "], action = [" + action.title 318 + "], source = [" + source + "]"); 319 } 320 mSingleThreadExecutor.submit( 321 () -> mSmartSuggestionsHelper.onActionClicked(key, action, source)); 322 } 323 324 @Override onListenerConnected()325 public void onListenerConnected() { 326 if (DEBUG) Log.i(TAG, "Connected"); 327 } 328 329 @Override onListenerDisconnected()330 public void onListenerDisconnected() { 331 } 332 isForCurrentUser(StatusBarNotification sbn)333 private boolean isForCurrentUser(StatusBarNotification sbn) { 334 return sbn != null && sbn.getUserId() == UserHandle.myUserId(); 335 } 336 } 337