• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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)