1 /*
<lambda>null2 * 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. */
areSuggestionsSimilarnull75 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 {
inflateSmartReplyStatenull88 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 {
inflateActionButtonnull273 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 {
inflateReplyButtonnull349 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
onClicknull466 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
hasFinishedInitializationnull474 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)