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