1 /*
2  * Copyright (C) 2019 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 com.android.systemui.statusbar.policy;
18 
19 import android.annotation.NonNull;
20 import android.annotation.Nullable;
21 import android.app.Notification;
22 import android.app.RemoteInput;
23 import android.content.Context;
24 import android.content.Intent;
25 import android.content.pm.ResolveInfo;
26 import android.os.Build;
27 import android.util.Log;
28 import android.util.Pair;
29 import android.widget.Button;
30 
31 import com.android.internal.annotations.VisibleForTesting;
32 import com.android.internal.util.ArrayUtils;
33 import com.android.systemui.Dependency;
34 import com.android.systemui.shared.system.ActivityManagerWrapper;
35 import com.android.systemui.shared.system.DevicePolicyManagerWrapper;
36 import com.android.systemui.shared.system.PackageManagerWrapper;
37 import com.android.systemui.statusbar.NotificationUiAdjustment;
38 import com.android.systemui.statusbar.SmartReplyController;
39 import com.android.systemui.statusbar.notification.collection.NotificationEntry;
40 
41 import java.util.ArrayList;
42 import java.util.Arrays;
43 import java.util.Collections;
44 import java.util.List;
45 
46 /**
47  * Holder for inflated smart replies and actions. These objects should be inflated on a background
48  * thread, to later be accessed and modified on the (performance critical) UI thread.
49  */
50 public class InflatedSmartReplies {
51     private static final String TAG = "InflatedSmartReplies";
52     private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
53     @Nullable private final SmartReplyView mSmartReplyView;
54     @Nullable private final List<Button> mSmartSuggestionButtons;
55     @NonNull private final SmartRepliesAndActions mSmartRepliesAndActions;
56 
InflatedSmartReplies( @ullable SmartReplyView smartReplyView, @Nullable List<Button> smartSuggestionButtons, @NonNull SmartRepliesAndActions smartRepliesAndActions)57     private InflatedSmartReplies(
58             @Nullable SmartReplyView smartReplyView,
59             @Nullable List<Button> smartSuggestionButtons,
60             @NonNull SmartRepliesAndActions smartRepliesAndActions) {
61         mSmartReplyView = smartReplyView;
62         mSmartSuggestionButtons = smartSuggestionButtons;
63         mSmartRepliesAndActions = smartRepliesAndActions;
64     }
65 
getSmartReplyView()66     @Nullable public SmartReplyView getSmartReplyView() {
67         return mSmartReplyView;
68     }
69 
getSmartSuggestionButtons()70     @Nullable public List<Button> getSmartSuggestionButtons() {
71         return mSmartSuggestionButtons;
72     }
73 
getSmartRepliesAndActions()74     @NonNull public SmartRepliesAndActions getSmartRepliesAndActions() {
75         return mSmartRepliesAndActions;
76     }
77 
78     /**
79      * Inflate a SmartReplyView and its smart suggestions.
80      */
inflate( Context context, Context packageContext, NotificationEntry entry, SmartReplyConstants smartReplyConstants, SmartReplyController smartReplyController, HeadsUpManager headsUpManager, SmartRepliesAndActions existingSmartRepliesAndActions)81     public static InflatedSmartReplies inflate(
82             Context context,
83             Context packageContext,
84             NotificationEntry entry,
85             SmartReplyConstants smartReplyConstants,
86             SmartReplyController smartReplyController,
87             HeadsUpManager headsUpManager,
88             SmartRepliesAndActions existingSmartRepliesAndActions) {
89         SmartRepliesAndActions newSmartRepliesAndActions =
90                 chooseSmartRepliesAndActions(smartReplyConstants, entry);
91         if (!shouldShowSmartReplyView(entry, newSmartRepliesAndActions)) {
92             return new InflatedSmartReplies(null /* smartReplyView */,
93                     null /* smartSuggestionButtons */, newSmartRepliesAndActions);
94         }
95 
96         // Only block clicks if the smart buttons are different from the previous set - to avoid
97         // scenarios where a user incorrectly cannot click smart buttons because the notification is
98         // updated.
99         boolean delayOnClickListener =
100                 !areSuggestionsSimilar(existingSmartRepliesAndActions, newSmartRepliesAndActions);
101 
102         SmartReplyView smartReplyView = SmartReplyView.inflate(context);
103 
104         List<Button> suggestionButtons = new ArrayList<>();
105         if (newSmartRepliesAndActions.smartReplies != null) {
106             suggestionButtons.addAll(smartReplyView.inflateRepliesFromRemoteInput(
107                     newSmartRepliesAndActions.smartReplies, smartReplyController, entry,
108                     delayOnClickListener));
109         }
110         if (newSmartRepliesAndActions.smartActions != null) {
111             suggestionButtons.addAll(
112                     smartReplyView.inflateSmartActions(packageContext,
113                             newSmartRepliesAndActions.smartActions, smartReplyController, entry,
114                             headsUpManager, delayOnClickListener));
115         }
116 
117         return new InflatedSmartReplies(smartReplyView, suggestionButtons,
118                 newSmartRepliesAndActions);
119     }
120 
121     @VisibleForTesting
areSuggestionsSimilar( SmartRepliesAndActions left, SmartRepliesAndActions right)122     static boolean areSuggestionsSimilar(
123             SmartRepliesAndActions left, SmartRepliesAndActions right) {
124         if (left == right) return true;
125         if (left == null || right == null) return false;
126 
127         if (!Arrays.equals(left.getSmartReplies(), right.getSmartReplies())) {
128             return false;
129         }
130 
131         return !NotificationUiAdjustment.areDifferent(
132                 left.getSmartActions(), right.getSmartActions());
133     }
134 
135     /**
136      * Returns whether we should show the smart reply view and its smart suggestions.
137      */
shouldShowSmartReplyView( NotificationEntry entry, SmartRepliesAndActions smartRepliesAndActions)138     public static boolean shouldShowSmartReplyView(
139             NotificationEntry entry,
140             SmartRepliesAndActions smartRepliesAndActions) {
141         if (smartRepliesAndActions.smartReplies == null
142                 && smartRepliesAndActions.smartActions == null) {
143             // There are no smart replies and no smart actions.
144             return false;
145         }
146         // If we are showing the spinner we don't want to add the buttons.
147         boolean showingSpinner = entry.notification.getNotification()
148                 .extras.getBoolean(Notification.EXTRA_SHOW_REMOTE_INPUT_SPINNER, false);
149         if (showingSpinner) {
150             return false;
151         }
152         // If we are keeping the notification around while sending we don't want to add the buttons.
153         boolean hideSmartReplies = entry.notification.getNotification()
154                 .extras.getBoolean(Notification.EXTRA_HIDE_SMART_REPLIES, false);
155         if (hideSmartReplies) {
156             return false;
157         }
158         return true;
159     }
160 
161     /**
162      * Chose what smart replies and smart actions to display. App generated suggestions take
163      * precedence. So if the app provides any smart replies, we don't show any
164      * replies or actions generated by the NotificationAssistantService (NAS), and if the app
165      * provides any smart actions we also don't show any NAS-generated replies or actions.
166      */
167     @NonNull
chooseSmartRepliesAndActions( SmartReplyConstants smartReplyConstants, final NotificationEntry entry)168     public static SmartRepliesAndActions chooseSmartRepliesAndActions(
169             SmartReplyConstants smartReplyConstants,
170             final NotificationEntry entry) {
171         Notification notification = entry.notification.getNotification();
172         Pair<RemoteInput, Notification.Action> remoteInputActionPair =
173                 notification.findRemoteInputActionPair(false /* freeform */);
174         Pair<RemoteInput, Notification.Action> freeformRemoteInputActionPair =
175                 notification.findRemoteInputActionPair(true /* freeform */);
176 
177         if (!smartReplyConstants.isEnabled()) {
178             if (DEBUG) {
179                 Log.d(TAG, "Smart suggestions not enabled, not adding suggestions for "
180                         + entry.notification.getKey());
181             }
182             return new SmartRepliesAndActions(null, null);
183         }
184         // Only use smart replies from the app if they target P or above. We have this check because
185         // the smart reply API has been used for other things (Wearables) in the past. The API to
186         // add smart actions is new in Q so it doesn't require a target-sdk check.
187         boolean enableAppGeneratedSmartReplies = (!smartReplyConstants.requiresTargetingP()
188                 || entry.targetSdk >= Build.VERSION_CODES.P);
189 
190         boolean appGeneratedSmartRepliesExist =
191                 enableAppGeneratedSmartReplies
192                         && remoteInputActionPair != null
193                         && !ArrayUtils.isEmpty(remoteInputActionPair.first.getChoices())
194                         && remoteInputActionPair.second.actionIntent != null;
195 
196         List<Notification.Action> appGeneratedSmartActions = notification.getContextualActions();
197         boolean appGeneratedSmartActionsExist = !appGeneratedSmartActions.isEmpty();
198 
199         SmartReplyView.SmartReplies smartReplies = null;
200         SmartReplyView.SmartActions smartActions = null;
201         if (appGeneratedSmartRepliesExist) {
202             smartReplies = new SmartReplyView.SmartReplies(
203                     remoteInputActionPair.first.getChoices(),
204                     remoteInputActionPair.first,
205                     remoteInputActionPair.second.actionIntent,
206                     false /* fromAssistant */);
207         }
208         if (appGeneratedSmartActionsExist) {
209             smartActions = new SmartReplyView.SmartActions(appGeneratedSmartActions,
210                     false /* fromAssistant */);
211         }
212         // Apps didn't provide any smart replies / actions, use those from NAS (if any).
213         if (!appGeneratedSmartRepliesExist && !appGeneratedSmartActionsExist) {
214             boolean useGeneratedReplies = !ArrayUtils.isEmpty(entry.systemGeneratedSmartReplies)
215                     && freeformRemoteInputActionPair != null
216                     && freeformRemoteInputActionPair.second.getAllowGeneratedReplies()
217                     && freeformRemoteInputActionPair.second.actionIntent != null;
218             if (useGeneratedReplies) {
219                 smartReplies = new SmartReplyView.SmartReplies(
220                         entry.systemGeneratedSmartReplies,
221                         freeformRemoteInputActionPair.first,
222                         freeformRemoteInputActionPair.second.actionIntent,
223                         true /* fromAssistant */);
224             }
225             boolean useSmartActions = !ArrayUtils.isEmpty(entry.systemGeneratedSmartActions)
226                     && notification.getAllowSystemGeneratedContextualActions();
227             if (useSmartActions) {
228                 List<Notification.Action> systemGeneratedActions =
229                         entry.systemGeneratedSmartActions;
230                 // Filter actions if we're in kiosk-mode - we don't care about screen pinning mode,
231                 // since notifications aren't shown there anyway.
232                 ActivityManagerWrapper activityManagerWrapper =
233                         Dependency.get(ActivityManagerWrapper.class);
234                 if (activityManagerWrapper.isLockTaskKioskModeActive()) {
235                     systemGeneratedActions = filterWhiteListedLockTaskApps(systemGeneratedActions);
236                 }
237                 smartActions = new SmartReplyView.SmartActions(
238                         systemGeneratedActions, true /* fromAssistant */);
239             }
240         }
241         return new SmartRepliesAndActions(smartReplies, smartActions);
242     }
243 
244     /**
245      * Filter actions so that only actions pointing to whitelisted apps are allowed.
246      * This filtering is only meaningful when in lock-task mode.
247      */
filterWhiteListedLockTaskApps( List<Notification.Action> actions)248     private static List<Notification.Action> filterWhiteListedLockTaskApps(
249             List<Notification.Action> actions) {
250         PackageManagerWrapper packageManagerWrapper = Dependency.get(PackageManagerWrapper.class);
251         DevicePolicyManagerWrapper devicePolicyManagerWrapper =
252                 Dependency.get(DevicePolicyManagerWrapper.class);
253         List<Notification.Action> filteredActions = new ArrayList<>();
254         for (Notification.Action action : actions) {
255             if (action.actionIntent == null) continue;
256             Intent intent = action.actionIntent.getIntent();
257             //  Only allow actions that are explicit (implicit intents are not handled in lock-task
258             //  mode), and link to whitelisted apps.
259             ResolveInfo resolveInfo = packageManagerWrapper.resolveActivity(intent, 0 /* flags */);
260             if (resolveInfo != null && devicePolicyManagerWrapper.isLockTaskPermitted(
261                     resolveInfo.activityInfo.packageName)) {
262                 filteredActions.add(action);
263             }
264         }
265         return filteredActions;
266     }
267 
268     /**
269      * Returns whether the {@link Notification} represented by entry has a free-form remote input.
270      * Such an input can be used e.g. to implement smart reply buttons - by passing the replies
271      * through the remote input.
272      */
hasFreeformRemoteInput(NotificationEntry entry)273     public static boolean hasFreeformRemoteInput(NotificationEntry entry) {
274         Notification notification = entry.notification.getNotification();
275         return null != notification.findRemoteInputActionPair(true /* freeform */);
276     }
277 
278     /**
279      * A storage for smart replies and smart action.
280      */
281     public static class SmartRepliesAndActions {
282         @Nullable public final SmartReplyView.SmartReplies smartReplies;
283         @Nullable public final SmartReplyView.SmartActions smartActions;
284 
SmartRepliesAndActions( @ullable SmartReplyView.SmartReplies smartReplies, @Nullable SmartReplyView.SmartActions smartActions)285         SmartRepliesAndActions(
286                 @Nullable SmartReplyView.SmartReplies smartReplies,
287                 @Nullable SmartReplyView.SmartActions smartActions) {
288             this.smartReplies = smartReplies;
289             this.smartActions = smartActions;
290         }
291 
getSmartReplies()292         @NonNull public CharSequence[] getSmartReplies() {
293             return smartReplies == null ? new CharSequence[0] : smartReplies.choices;
294         }
295 
getSmartActions()296         @NonNull public List<Notification.Action> getSmartActions() {
297             return smartActions == null ? Collections.emptyList() : smartActions.actions;
298         }
299     }
300 }
301