1 /* 2 * Copyright (C) 2020 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.app.Notification 20 import android.app.Notification.Action.SEMANTIC_ACTION_MARK_CONVERSATION_AS_PRIORITY 21 import android.app.PendingIntent 22 import android.app.RemoteInput 23 import android.content.Context 24 import android.content.Intent 25 import android.os.Build 26 import android.os.Bundle 27 import android.os.SystemClock 28 import android.util.Log 29 import android.view.ContextThemeWrapper 30 import android.view.LayoutInflater 31 import android.view.View 32 import android.view.ViewGroup 33 import android.view.accessibility.AccessibilityNodeInfo 34 import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction 35 import android.widget.Button 36 import com.android.systemui.R 37 import com.android.systemui.plugins.ActivityStarter 38 import com.android.systemui.shared.system.ActivityManagerWrapper 39 import com.android.systemui.shared.system.DevicePolicyManagerWrapper 40 import com.android.systemui.shared.system.PackageManagerWrapper 41 import com.android.systemui.statusbar.NotificationRemoteInputManager 42 import com.android.systemui.statusbar.NotificationUiAdjustment 43 import com.android.systemui.statusbar.SmartReplyController 44 import com.android.systemui.statusbar.notification.collection.NotificationEntry 45 import com.android.systemui.statusbar.notification.logging.NotificationLogger 46 import com.android.systemui.statusbar.phone.KeyguardDismissUtil 47 import com.android.systemui.statusbar.policy.InflatedSmartReplyState.SuppressedActions 48 import com.android.systemui.statusbar.policy.SmartReplyView.SmartActions 49 import com.android.systemui.statusbar.policy.SmartReplyView.SmartButtonType 50 import com.android.systemui.statusbar.policy.SmartReplyView.SmartReplies 51 import javax.inject.Inject 52 53 /** Returns whether we should show the smart reply view and its smart suggestions. */ 54 fun shouldShowSmartReplyView( 55 entry: NotificationEntry, 56 smartReplyState: InflatedSmartReplyState 57 ): Boolean { 58 if (smartReplyState.smartReplies == null && 59 smartReplyState.smartActions == null) { 60 // There are no smart replies and no smart actions. 61 return false 62 } 63 // If we are showing the spinner we don't want to add the buttons. 64 val showingSpinner = entry.sbn.notification.extras 65 .getBoolean(Notification.EXTRA_SHOW_REMOTE_INPUT_SPINNER, false) 66 if (showingSpinner) { 67 return false 68 } 69 // If we are keeping the notification around while sending we don't want to add the buttons. 70 return !entry.sbn.notification.extras 71 .getBoolean(Notification.EXTRA_HIDE_SMART_REPLIES, false) 72 } 73 74 /** Determines if two [InflatedSmartReplyState] are visually similar. */ 75 fun areSuggestionsSimilar( 76 left: InflatedSmartReplyState?, 77 right: InflatedSmartReplyState? 78 ): Boolean = when { 79 left === right -> true 80 left == null || right == null -> false 81 left.hasPhishingAction != right.hasPhishingAction -> false 82 left.smartRepliesList != right.smartRepliesList -> false 83 left.suppressedActionIndices != right.suppressedActionIndices -> false 84 else -> !NotificationUiAdjustment.areDifferent(left.smartActionsList, right.smartActionsList) 85 } 86 87 interface SmartReplyStateInflater { 88 fun inflateSmartReplyState(entry: NotificationEntry): InflatedSmartReplyState 89 90 fun inflateSmartReplyViewHolder( 91 sysuiContext: Context, 92 notifPackageContext: Context, 93 entry: NotificationEntry, 94 existingSmartReplyState: InflatedSmartReplyState?, 95 newSmartReplyState: InflatedSmartReplyState 96 ): InflatedSmartReplyViewHolder 97 } 98 99 /*internal*/ class SmartReplyStateInflaterImpl @Inject constructor( 100 private val constants: SmartReplyConstants, 101 private val activityManagerWrapper: ActivityManagerWrapper, 102 private val packageManagerWrapper: PackageManagerWrapper, 103 private val devicePolicyManagerWrapper: DevicePolicyManagerWrapper, 104 private val smartRepliesInflater: SmartReplyInflater, 105 private val smartActionsInflater: SmartActionInflater 106 ) : SmartReplyStateInflater { 107 108 override fun inflateSmartReplyState(entry: NotificationEntry): InflatedSmartReplyState = 109 chooseSmartRepliesAndActions(entry) 110 111 override fun inflateSmartReplyViewHolder( 112 sysuiContext: Context, 113 notifPackageContext: Context, 114 entry: NotificationEntry, 115 existingSmartReplyState: InflatedSmartReplyState?, 116 newSmartReplyState: InflatedSmartReplyState 117 ): InflatedSmartReplyViewHolder { 118 if (!shouldShowSmartReplyView(entry, newSmartReplyState)) { 119 return InflatedSmartReplyViewHolder( 120 null /* smartReplyView */, 121 null /* smartSuggestionButtons */) 122 } 123 124 // Only block clicks if the smart buttons are different from the previous set - to avoid 125 // scenarios where a user incorrectly cannot click smart buttons because the 126 // notification is updated. 127 val delayOnClickListener = 128 !areSuggestionsSimilar(existingSmartReplyState, newSmartReplyState) 129 130 val smartReplyView = SmartReplyView.inflate(sysuiContext, constants) 131 132 val smartReplies = newSmartReplyState.smartReplies 133 smartReplyView.setSmartRepliesGeneratedByAssistant(smartReplies?.fromAssistant ?: false) 134 val smartReplyButtons = smartReplies?.let { 135 smartReplies.choices.asSequence().mapIndexed { index, choice -> 136 smartRepliesInflater.inflateReplyButton( 137 smartReplyView, 138 entry, 139 smartReplies, 140 index, 141 choice, 142 delayOnClickListener) 143 } 144 } ?: emptySequence() 145 146 val smartActionButtons = newSmartReplyState.smartActions?.let { smartActions -> 147 val themedPackageContext = 148 ContextThemeWrapper(notifPackageContext, sysuiContext.theme) 149 smartActions.actions.asSequence() 150 .filter { it.actionIntent != null } 151 .mapIndexed { index, action -> 152 smartActionsInflater.inflateActionButton( 153 smartReplyView, 154 entry, 155 smartActions, 156 index, 157 action, 158 delayOnClickListener, 159 themedPackageContext) 160 } 161 } ?: emptySequence() 162 163 return InflatedSmartReplyViewHolder( 164 smartReplyView, 165 (smartReplyButtons + smartActionButtons).toList()) 166 } 167 168 /** 169 * Chose what smart replies and smart actions to display. App generated suggestions take 170 * precedence. So if the app provides any smart replies, we don't show any 171 * replies or actions generated by the NotificationAssistantService (NAS), and if the app 172 * provides any smart actions we also don't show any NAS-generated replies or actions. 173 */ 174 fun chooseSmartRepliesAndActions(entry: NotificationEntry): InflatedSmartReplyState { 175 val notification = entry.sbn.notification 176 val remoteInputActionPair = notification.findRemoteInputActionPair(false /* freeform */) 177 val freeformRemoteInputActionPair = 178 notification.findRemoteInputActionPair(true /* freeform */) 179 if (!constants.isEnabled) { 180 if (DEBUG) { 181 Log.d(TAG, "Smart suggestions not enabled, not adding suggestions for " + 182 entry.sbn.key) 183 } 184 return InflatedSmartReplyState(null, null, null, false) 185 } 186 // Only use smart replies from the app if they target P or above. We have this check because 187 // the smart reply API has been used for other things (Wearables) in the past. The API to 188 // add smart actions is new in Q so it doesn't require a target-sdk check. 189 val enableAppGeneratedSmartReplies = (!constants.requiresTargetingP() || 190 entry.targetSdk >= Build.VERSION_CODES.P) 191 val appGeneratedSmartActions = notification.contextualActions 192 193 var smartReplies: SmartReplies? = when { 194 enableAppGeneratedSmartReplies -> remoteInputActionPair?.let { pair -> 195 pair.second.actionIntent?.let { actionIntent -> 196 if (pair.first.choices?.isNotEmpty() == true) 197 SmartReplies( 198 pair.first.choices.asList(), 199 pair.first, 200 actionIntent, 201 false /* fromAssistant */) 202 else null 203 } 204 } 205 else -> null 206 } 207 var smartActions: SmartActions? = when { 208 appGeneratedSmartActions.isNotEmpty() -> 209 SmartActions(appGeneratedSmartActions, false /* fromAssistant */) 210 else -> null 211 } 212 // Apps didn't provide any smart replies / actions, use those from NAS (if any). 213 if (smartReplies == null && smartActions == null) { 214 val entryReplies = entry.smartReplies 215 val entryActions = entry.smartActions 216 if (entryReplies.isNotEmpty() && 217 freeformRemoteInputActionPair != null && 218 freeformRemoteInputActionPair.second.allowGeneratedReplies && 219 freeformRemoteInputActionPair.second.actionIntent != null) { 220 smartReplies = SmartReplies( 221 entryReplies, 222 freeformRemoteInputActionPair.first, 223 freeformRemoteInputActionPair.second.actionIntent, 224 true /* fromAssistant */) 225 } 226 if (entryActions.isNotEmpty() && 227 notification.allowSystemGeneratedContextualActions) { 228 val systemGeneratedActions: List<Notification.Action> = when { 229 activityManagerWrapper.isLockTaskKioskModeActive -> 230 // Filter actions if we're in kiosk-mode - we don't care about screen 231 // pinning mode, since notifications aren't shown there anyway. 232 filterAllowlistedLockTaskApps(entryActions) 233 else -> entryActions 234 } 235 smartActions = SmartActions(systemGeneratedActions, true /* fromAssistant */) 236 } 237 } 238 val hasPhishingAction = smartActions?.actions?.any { 239 it.isContextual && it.semanticAction == 240 Notification.Action.SEMANTIC_ACTION_CONVERSATION_IS_PHISHING 241 } ?: false 242 var suppressedActions: SuppressedActions? = null 243 if (hasPhishingAction) { 244 // If there is a phishing action, calculate the indices of the actions with RemoteInput 245 // as those need to be hidden from the view. 246 val suppressedActionIndices = notification.actions.mapIndexedNotNull { index, action -> 247 if (action.remoteInputs?.isNotEmpty() == true) index else null 248 } 249 suppressedActions = SuppressedActions(suppressedActionIndices) 250 } 251 return InflatedSmartReplyState(smartReplies, smartActions, suppressedActions, 252 hasPhishingAction) 253 } 254 255 /** 256 * Filter actions so that only actions pointing to allowlisted apps are permitted. 257 * This filtering is only meaningful when in lock-task mode. 258 */ 259 private fun filterAllowlistedLockTaskApps( 260 actions: List<Notification.Action> 261 ): List<Notification.Action> = actions.filter { action -> 262 // Only allow actions that are explicit (implicit intents are not handled in lock-task 263 // mode), and link to allowlisted apps. 264 action.actionIntent?.intent?.let { intent -> 265 packageManagerWrapper.resolveActivity(intent, 0 /* flags */) 266 }?.let { resolveInfo -> 267 devicePolicyManagerWrapper.isLockTaskPermitted(resolveInfo.activityInfo.packageName) 268 } ?: false 269 } 270 } 271 272 interface SmartActionInflater { 273 fun inflateActionButton( 274 parent: ViewGroup, 275 entry: NotificationEntry, 276 smartActions: SmartActions, 277 actionIndex: Int, 278 action: Notification.Action, 279 delayOnClickListener: Boolean, 280 packageContext: Context 281 ): Button 282 } 283 284 /* internal */ class SmartActionInflaterImpl @Inject constructor( 285 private val constants: SmartReplyConstants, 286 private val activityStarter: ActivityStarter, 287 private val smartReplyController: SmartReplyController, 288 private val headsUpManager: HeadsUpManager 289 ) : SmartActionInflater { 290 291 override fun inflateActionButton( 292 parent: ViewGroup, 293 entry: NotificationEntry, 294 smartActions: SmartActions, 295 actionIndex: Int, 296 action: Notification.Action, 297 delayOnClickListener: Boolean, 298 packageContext: Context 299 ): Button = 300 (LayoutInflater.from(parent.context) 301 .inflate(R.layout.smart_action_button, parent, false) as Button 302 ).apply { 303 text = action.title 304 305 // We received the Icon from the application - so use the Context of the application to 306 // reference icon resources. 307 val iconDrawable = action.getIcon().loadDrawable(packageContext) 308 .apply { 309 val newIconSize: Int = context.resources.getDimensionPixelSize( 310 R.dimen.smart_action_button_icon_size) 311 setBounds(0, 0, newIconSize, newIconSize) 312 } 313 // Add the action icon to the Smart Action button. 314 setCompoundDrawables(iconDrawable, null, null, null) 315 316 val onClickListener = View.OnClickListener { 317 onSmartActionClick(entry, smartActions, actionIndex, action) 318 } 319 setOnClickListener( 320 if (delayOnClickListener) 321 DelayedOnClickListener(onClickListener, constants.onClickInitDelay) 322 else onClickListener) 323 324 // Mark this as an Action button 325 (layoutParams as SmartReplyView.LayoutParams).mButtonType = SmartButtonType.ACTION 326 } 327 328 private fun onSmartActionClick( 329 entry: NotificationEntry, 330 smartActions: SmartActions, 331 actionIndex: Int, 332 action: Notification.Action 333 ) = 334 if (smartActions.fromAssistant && 335 SEMANTIC_ACTION_MARK_CONVERSATION_AS_PRIORITY == action.semanticAction) { 336 entry.row.doSmartActionClick(entry.row.x.toInt() / 2, 337 entry.row.y.toInt() / 2, SEMANTIC_ACTION_MARK_CONVERSATION_AS_PRIORITY) 338 smartReplyController 339 .smartActionClicked(entry, actionIndex, action, smartActions.fromAssistant) 340 } else { 341 activityStarter.startPendingIntentDismissingKeyguard(action.actionIntent, entry.row) { 342 smartReplyController 343 .smartActionClicked(entry, actionIndex, action, smartActions.fromAssistant) 344 } 345 } 346 } 347 348 interface SmartReplyInflater { 349 fun inflateReplyButton( 350 parent: SmartReplyView, 351 entry: NotificationEntry, 352 smartReplies: SmartReplies, 353 replyIndex: Int, 354 choice: CharSequence, 355 delayOnClickListener: Boolean 356 ): Button 357 } 358 359 class SmartReplyInflaterImpl @Inject constructor( 360 private val constants: SmartReplyConstants, 361 private val keyguardDismissUtil: KeyguardDismissUtil, 362 private val remoteInputManager: NotificationRemoteInputManager, 363 private val smartReplyController: SmartReplyController, 364 private val context: Context 365 ) : SmartReplyInflater { 366 367 override fun inflateReplyButton( 368 parent: SmartReplyView, 369 entry: NotificationEntry, 370 smartReplies: SmartReplies, 371 replyIndex: Int, 372 choice: CharSequence, 373 delayOnClickListener: Boolean 374 ): Button = 375 (LayoutInflater.from(parent.context) 376 .inflate(R.layout.smart_reply_button, parent, false) as Button 377 ).apply { 378 text = choice 379 val onClickListener = View.OnClickListener { 380 onSmartReplyClick( 381 entry, 382 smartReplies, 383 replyIndex, 384 parent, 385 this, 386 choice) 387 } 388 setOnClickListener( 389 if (delayOnClickListener) 390 DelayedOnClickListener(onClickListener, constants.onClickInitDelay) 391 else onClickListener) 392 accessibilityDelegate = object : View.AccessibilityDelegate() { 393 override fun onInitializeAccessibilityNodeInfo( 394 host: View, 395 info: AccessibilityNodeInfo 396 ) { 397 super.onInitializeAccessibilityNodeInfo(host, info) 398 val label = parent.resources 399 .getString(R.string.accessibility_send_smart_reply) 400 val action = AccessibilityAction(AccessibilityNodeInfo.ACTION_CLICK, label) 401 info.addAction(action) 402 } 403 } 404 // TODO: probably shouldn't do this here, bad API 405 // Mark this as a Reply button 406 (layoutParams as SmartReplyView.LayoutParams).mButtonType = SmartButtonType.REPLY 407 } 408 409 private fun onSmartReplyClick( 410 entry: NotificationEntry, 411 smartReplies: SmartReplies, 412 replyIndex: Int, 413 smartReplyView: SmartReplyView, 414 button: Button, 415 choice: CharSequence 416 ) = keyguardDismissUtil.executeWhenUnlocked(!entry.isRowPinned) { 417 val canEditBeforeSend = constants.getEffectiveEditChoicesBeforeSending( 418 smartReplies.remoteInput.editChoicesBeforeSending) 419 if (canEditBeforeSend) { 420 remoteInputManager.activateRemoteInput( 421 button, 422 arrayOf(smartReplies.remoteInput), 423 smartReplies.remoteInput, 424 smartReplies.pendingIntent, 425 NotificationEntry.EditedSuggestionInfo(choice, replyIndex)) 426 } else { 427 smartReplyController.smartReplySent( 428 entry, 429 replyIndex, 430 button.text, 431 NotificationLogger.getNotificationLocation(entry).toMetricsEventEnum(), 432 false /* modifiedBeforeSending */) 433 entry.setHasSentReply() 434 try { 435 val intent = createRemoteInputIntent(smartReplies, choice) 436 smartReplies.pendingIntent.send(context, 0, intent) 437 } catch (e: PendingIntent.CanceledException) { 438 Log.w(TAG, "Unable to send smart reply", e) 439 } 440 smartReplyView.hideSmartSuggestions() 441 } 442 false // do not defer 443 } 444 445 private fun createRemoteInputIntent(smartReplies: SmartReplies, choice: CharSequence): Intent { 446 val results = Bundle() 447 results.putString(smartReplies.remoteInput.resultKey, choice.toString()) 448 val intent = Intent().addFlags(Intent.FLAG_RECEIVER_FOREGROUND) 449 RemoteInput.addResultsToIntent(arrayOf(smartReplies.remoteInput), intent, results) 450 RemoteInput.setResultsSource(intent, RemoteInput.SOURCE_CHOICE) 451 return intent 452 } 453 } 454 455 /** 456 * An OnClickListener wrapper that blocks the underlying OnClickListener for a given amount of 457 * time. 458 */ 459 private class DelayedOnClickListener( 460 private val mActualListener: View.OnClickListener, 461 private val mInitDelayMs: Long 462 ) : View.OnClickListener { 463 464 private val mInitTimeMs = SystemClock.elapsedRealtime() 465 466 override fun onClick(v: View) { 467 if (hasFinishedInitialization()) { 468 mActualListener.onClick(v) 469 } else { 470 Log.i(TAG, "Accidental Smart Suggestion click registered, delay: $mInitDelayMs") 471 } 472 } 473 474 private fun hasFinishedInitialization(): Boolean = 475 SystemClock.elapsedRealtime() >= mInitTimeMs + mInitDelayMs 476 } 477 478 private const val TAG = "SmartReplyViewInflater" 479 private val DEBUG = Log.isLoggable(TAG, Log.DEBUG) 480 481 // convenience function that swaps parameter order so that lambda can be placed at the end 482 private fun KeyguardDismissUtil.executeWhenUnlocked( 483 requiresShadeOpen: Boolean, 484 onDismissAction: () -> Boolean 485 ) = executeWhenUnlocked(onDismissAction, requiresShadeOpen, false) 486 487 // convenience function that swaps parameter order so that lambda can be placed at the end 488 private fun ActivityStarter.startPendingIntentDismissingKeyguard( 489 intent: PendingIntent, 490 associatedView: View?, 491 runnable: () -> Unit 492 ) = startPendingIntentDismissingKeyguard(intent, runnable::invoke, associatedView)