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