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