| /* |
| * Copyright (C) 2020 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.systemui.statusbar.policy |
| |
| import android.app.Notification |
| import android.app.Notification.Action.SEMANTIC_ACTION_MARK_CONVERSATION_AS_PRIORITY |
| import android.app.PendingIntent |
| import android.app.RemoteInput |
| import android.content.Context |
| import android.content.Intent |
| import android.os.Build |
| import android.os.Bundle |
| import android.os.SystemClock |
| import android.util.Log |
| import android.view.ContextThemeWrapper |
| import android.view.LayoutInflater |
| import android.view.View |
| import android.view.ViewGroup |
| import android.view.accessibility.AccessibilityNodeInfo |
| import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction |
| import android.widget.Button |
| import com.android.systemui.R |
| import com.android.systemui.plugins.ActivityStarter |
| import com.android.systemui.shared.system.ActivityManagerWrapper |
| import com.android.systemui.shared.system.DevicePolicyManagerWrapper |
| import com.android.systemui.shared.system.PackageManagerWrapper |
| import com.android.systemui.statusbar.NotificationRemoteInputManager |
| import com.android.systemui.statusbar.NotificationUiAdjustment |
| import com.android.systemui.statusbar.SmartReplyController |
| import com.android.systemui.statusbar.notification.collection.NotificationEntry |
| import com.android.systemui.statusbar.notification.logging.NotificationLogger |
| import com.android.systemui.statusbar.phone.KeyguardDismissUtil |
| import com.android.systemui.statusbar.policy.InflatedSmartReplyState.SuppressedActions |
| import com.android.systemui.statusbar.policy.SmartReplyView.SmartActions |
| import com.android.systemui.statusbar.policy.SmartReplyView.SmartButtonType |
| import com.android.systemui.statusbar.policy.SmartReplyView.SmartReplies |
| import javax.inject.Inject |
| |
| /** Returns whether we should show the smart reply view and its smart suggestions. */ |
| fun shouldShowSmartReplyView( |
| entry: NotificationEntry, |
| smartReplyState: InflatedSmartReplyState |
| ): Boolean { |
| if (smartReplyState.smartReplies == null && |
| smartReplyState.smartActions == null) { |
| // There are no smart replies and no smart actions. |
| return false |
| } |
| // If we are showing the spinner we don't want to add the buttons. |
| val showingSpinner = entry.sbn.notification.extras |
| .getBoolean(Notification.EXTRA_SHOW_REMOTE_INPUT_SPINNER, false) |
| if (showingSpinner) { |
| return false |
| } |
| // If we are keeping the notification around while sending we don't want to add the buttons. |
| return !entry.sbn.notification.extras |
| .getBoolean(Notification.EXTRA_HIDE_SMART_REPLIES, false) |
| } |
| |
| /** Determines if two [InflatedSmartReplyState] are visually similar. */ |
| fun areSuggestionsSimilar( |
| left: InflatedSmartReplyState?, |
| right: InflatedSmartReplyState? |
| ): Boolean = when { |
| left === right -> true |
| left == null || right == null -> false |
| left.hasPhishingAction != right.hasPhishingAction -> false |
| left.smartRepliesList != right.smartRepliesList -> false |
| left.suppressedActionIndices != right.suppressedActionIndices -> false |
| else -> !NotificationUiAdjustment.areDifferent(left.smartActionsList, right.smartActionsList) |
| } |
| |
| interface SmartReplyStateInflater { |
| fun inflateSmartReplyState(entry: NotificationEntry): InflatedSmartReplyState |
| |
| fun inflateSmartReplyViewHolder( |
| sysuiContext: Context, |
| notifPackageContext: Context, |
| entry: NotificationEntry, |
| existingSmartReplyState: InflatedSmartReplyState?, |
| newSmartReplyState: InflatedSmartReplyState |
| ): InflatedSmartReplyViewHolder |
| } |
| |
| /*internal*/ class SmartReplyStateInflaterImpl @Inject constructor( |
| private val constants: SmartReplyConstants, |
| private val activityManagerWrapper: ActivityManagerWrapper, |
| private val packageManagerWrapper: PackageManagerWrapper, |
| private val devicePolicyManagerWrapper: DevicePolicyManagerWrapper, |
| private val smartRepliesInflater: SmartReplyInflater, |
| private val smartActionsInflater: SmartActionInflater |
| ) : SmartReplyStateInflater { |
| |
| override fun inflateSmartReplyState(entry: NotificationEntry): InflatedSmartReplyState = |
| chooseSmartRepliesAndActions(entry) |
| |
| override fun inflateSmartReplyViewHolder( |
| sysuiContext: Context, |
| notifPackageContext: Context, |
| entry: NotificationEntry, |
| existingSmartReplyState: InflatedSmartReplyState?, |
| newSmartReplyState: InflatedSmartReplyState |
| ): InflatedSmartReplyViewHolder { |
| if (!shouldShowSmartReplyView(entry, newSmartReplyState)) { |
| return InflatedSmartReplyViewHolder( |
| null /* smartReplyView */, |
| null /* smartSuggestionButtons */) |
| } |
| |
| // Only block clicks if the smart buttons are different from the previous set - to avoid |
| // scenarios where a user incorrectly cannot click smart buttons because the |
| // notification is updated. |
| val delayOnClickListener = |
| !areSuggestionsSimilar(existingSmartReplyState, newSmartReplyState) |
| |
| val smartReplyView = SmartReplyView.inflate(sysuiContext, constants) |
| |
| val smartReplies = newSmartReplyState.smartReplies |
| smartReplyView.setSmartRepliesGeneratedByAssistant(smartReplies?.fromAssistant ?: false) |
| val smartReplyButtons = smartReplies?.let { |
| smartReplies.choices.asSequence().mapIndexed { index, choice -> |
| smartRepliesInflater.inflateReplyButton( |
| smartReplyView, |
| entry, |
| smartReplies, |
| index, |
| choice, |
| delayOnClickListener) |
| } |
| } ?: emptySequence() |
| |
| val smartActionButtons = newSmartReplyState.smartActions?.let { smartActions -> |
| val themedPackageContext = |
| ContextThemeWrapper(notifPackageContext, sysuiContext.theme) |
| smartActions.actions.asSequence() |
| .filter { it.actionIntent != null } |
| .mapIndexed { index, action -> |
| smartActionsInflater.inflateActionButton( |
| smartReplyView, |
| entry, |
| smartActions, |
| index, |
| action, |
| delayOnClickListener, |
| themedPackageContext) |
| } |
| } ?: emptySequence() |
| |
| return InflatedSmartReplyViewHolder( |
| smartReplyView, |
| (smartReplyButtons + smartActionButtons).toList()) |
| } |
| |
| /** |
| * Chose what smart replies and smart actions to display. App generated suggestions take |
| * precedence. So if the app provides any smart replies, we don't show any |
| * replies or actions generated by the NotificationAssistantService (NAS), and if the app |
| * provides any smart actions we also don't show any NAS-generated replies or actions. |
| */ |
| fun chooseSmartRepliesAndActions(entry: NotificationEntry): InflatedSmartReplyState { |
| val notification = entry.sbn.notification |
| val remoteInputActionPair = notification.findRemoteInputActionPair(false /* freeform */) |
| val freeformRemoteInputActionPair = |
| notification.findRemoteInputActionPair(true /* freeform */) |
| if (!constants.isEnabled) { |
| if (DEBUG) { |
| Log.d(TAG, "Smart suggestions not enabled, not adding suggestions for " + |
| entry.sbn.key) |
| } |
| return InflatedSmartReplyState(null, null, null, false) |
| } |
| // Only use smart replies from the app if they target P or above. We have this check because |
| // the smart reply API has been used for other things (Wearables) in the past. The API to |
| // add smart actions is new in Q so it doesn't require a target-sdk check. |
| val enableAppGeneratedSmartReplies = (!constants.requiresTargetingP() || |
| entry.targetSdk >= Build.VERSION_CODES.P) |
| val appGeneratedSmartActions = notification.contextualActions |
| |
| var smartReplies: SmartReplies? = when { |
| enableAppGeneratedSmartReplies -> remoteInputActionPair?.let { pair -> |
| pair.second.actionIntent?.let { actionIntent -> |
| if (pair.first.choices?.isNotEmpty() == true) |
| SmartReplies( |
| pair.first.choices.asList(), |
| pair.first, |
| actionIntent, |
| false /* fromAssistant */) |
| else null |
| } |
| } |
| else -> null |
| } |
| var smartActions: SmartActions? = when { |
| appGeneratedSmartActions.isNotEmpty() -> |
| SmartActions(appGeneratedSmartActions, false /* fromAssistant */) |
| else -> null |
| } |
| // Apps didn't provide any smart replies / actions, use those from NAS (if any). |
| if (smartReplies == null && smartActions == null) { |
| val entryReplies = entry.smartReplies |
| val entryActions = entry.smartActions |
| if (entryReplies.isNotEmpty() && |
| freeformRemoteInputActionPair != null && |
| freeformRemoteInputActionPair.second.allowGeneratedReplies && |
| freeformRemoteInputActionPair.second.actionIntent != null) { |
| smartReplies = SmartReplies( |
| entryReplies, |
| freeformRemoteInputActionPair.first, |
| freeformRemoteInputActionPair.second.actionIntent, |
| true /* fromAssistant */) |
| } |
| if (entryActions.isNotEmpty() && |
| notification.allowSystemGeneratedContextualActions) { |
| val systemGeneratedActions: List<Notification.Action> = when { |
| activityManagerWrapper.isLockTaskKioskModeActive -> |
| // Filter actions if we're in kiosk-mode - we don't care about screen |
| // pinning mode, since notifications aren't shown there anyway. |
| filterAllowlistedLockTaskApps(entryActions) |
| else -> entryActions |
| } |
| smartActions = SmartActions(systemGeneratedActions, true /* fromAssistant */) |
| } |
| } |
| val hasPhishingAction = smartActions?.actions?.any { |
| it.isContextual && it.semanticAction == |
| Notification.Action.SEMANTIC_ACTION_CONVERSATION_IS_PHISHING |
| } ?: false |
| var suppressedActions: SuppressedActions? = null |
| if (hasPhishingAction) { |
| // If there is a phishing action, calculate the indices of the actions with RemoteInput |
| // as those need to be hidden from the view. |
| val suppressedActionIndices = notification.actions.mapIndexedNotNull { index, action -> |
| if (action.remoteInputs?.isNotEmpty() == true) index else null |
| } |
| suppressedActions = SuppressedActions(suppressedActionIndices) |
| } |
| return InflatedSmartReplyState(smartReplies, smartActions, suppressedActions, |
| hasPhishingAction) |
| } |
| |
| /** |
| * Filter actions so that only actions pointing to allowlisted apps are permitted. |
| * This filtering is only meaningful when in lock-task mode. |
| */ |
| private fun filterAllowlistedLockTaskApps( |
| actions: List<Notification.Action> |
| ): List<Notification.Action> = actions.filter { action -> |
| // Only allow actions that are explicit (implicit intents are not handled in lock-task |
| // mode), and link to allowlisted apps. |
| action.actionIntent?.intent?.let { intent -> |
| packageManagerWrapper.resolveActivity(intent, 0 /* flags */) |
| }?.let { resolveInfo -> |
| devicePolicyManagerWrapper.isLockTaskPermitted(resolveInfo.activityInfo.packageName) |
| } ?: false |
| } |
| } |
| |
| interface SmartActionInflater { |
| fun inflateActionButton( |
| parent: ViewGroup, |
| entry: NotificationEntry, |
| smartActions: SmartActions, |
| actionIndex: Int, |
| action: Notification.Action, |
| delayOnClickListener: Boolean, |
| packageContext: Context |
| ): Button |
| } |
| |
| /* internal */ class SmartActionInflaterImpl @Inject constructor( |
| private val constants: SmartReplyConstants, |
| private val activityStarter: ActivityStarter, |
| private val smartReplyController: SmartReplyController, |
| private val headsUpManager: HeadsUpManager |
| ) : SmartActionInflater { |
| |
| override fun inflateActionButton( |
| parent: ViewGroup, |
| entry: NotificationEntry, |
| smartActions: SmartActions, |
| actionIndex: Int, |
| action: Notification.Action, |
| delayOnClickListener: Boolean, |
| packageContext: Context |
| ): Button = |
| (LayoutInflater.from(parent.context) |
| .inflate(R.layout.smart_action_button, parent, false) as Button |
| ).apply { |
| text = action.title |
| |
| // We received the Icon from the application - so use the Context of the application to |
| // reference icon resources. |
| val iconDrawable = action.getIcon().loadDrawable(packageContext) |
| .apply { |
| val newIconSize: Int = context.resources.getDimensionPixelSize( |
| R.dimen.smart_action_button_icon_size) |
| setBounds(0, 0, newIconSize, newIconSize) |
| } |
| // Add the action icon to the Smart Action button. |
| setCompoundDrawables(iconDrawable, null, null, null) |
| |
| val onClickListener = View.OnClickListener { |
| onSmartActionClick(entry, smartActions, actionIndex, action) |
| } |
| setOnClickListener( |
| if (delayOnClickListener) |
| DelayedOnClickListener(onClickListener, constants.onClickInitDelay) |
| else onClickListener) |
| |
| // Mark this as an Action button |
| (layoutParams as SmartReplyView.LayoutParams).mButtonType = SmartButtonType.ACTION |
| } |
| |
| private fun onSmartActionClick( |
| entry: NotificationEntry, |
| smartActions: SmartActions, |
| actionIndex: Int, |
| action: Notification.Action |
| ) = |
| if (smartActions.fromAssistant && |
| SEMANTIC_ACTION_MARK_CONVERSATION_AS_PRIORITY == action.semanticAction) { |
| entry.row.doSmartActionClick(entry.row.x.toInt() / 2, |
| entry.row.y.toInt() / 2, SEMANTIC_ACTION_MARK_CONVERSATION_AS_PRIORITY) |
| smartReplyController |
| .smartActionClicked(entry, actionIndex, action, smartActions.fromAssistant) |
| } else { |
| activityStarter.startPendingIntentDismissingKeyguard(action.actionIntent, entry.row) { |
| smartReplyController |
| .smartActionClicked(entry, actionIndex, action, smartActions.fromAssistant) |
| } |
| } |
| } |
| |
| interface SmartReplyInflater { |
| fun inflateReplyButton( |
| parent: SmartReplyView, |
| entry: NotificationEntry, |
| smartReplies: SmartReplies, |
| replyIndex: Int, |
| choice: CharSequence, |
| delayOnClickListener: Boolean |
| ): Button |
| } |
| |
| class SmartReplyInflaterImpl @Inject constructor( |
| private val constants: SmartReplyConstants, |
| private val keyguardDismissUtil: KeyguardDismissUtil, |
| private val remoteInputManager: NotificationRemoteInputManager, |
| private val smartReplyController: SmartReplyController, |
| private val context: Context |
| ) : SmartReplyInflater { |
| |
| override fun inflateReplyButton( |
| parent: SmartReplyView, |
| entry: NotificationEntry, |
| smartReplies: SmartReplies, |
| replyIndex: Int, |
| choice: CharSequence, |
| delayOnClickListener: Boolean |
| ): Button = |
| (LayoutInflater.from(parent.context) |
| .inflate(R.layout.smart_reply_button, parent, false) as Button |
| ).apply { |
| text = choice |
| val onClickListener = View.OnClickListener { |
| onSmartReplyClick( |
| entry, |
| smartReplies, |
| replyIndex, |
| parent, |
| this, |
| choice) |
| } |
| setOnClickListener( |
| if (delayOnClickListener) |
| DelayedOnClickListener(onClickListener, constants.onClickInitDelay) |
| else onClickListener) |
| accessibilityDelegate = object : View.AccessibilityDelegate() { |
| override fun onInitializeAccessibilityNodeInfo( |
| host: View, |
| info: AccessibilityNodeInfo |
| ) { |
| super.onInitializeAccessibilityNodeInfo(host, info) |
| val label = parent.resources |
| .getString(R.string.accessibility_send_smart_reply) |
| val action = AccessibilityAction(AccessibilityNodeInfo.ACTION_CLICK, label) |
| info.addAction(action) |
| } |
| } |
| // TODO: probably shouldn't do this here, bad API |
| // Mark this as a Reply button |
| (layoutParams as SmartReplyView.LayoutParams).mButtonType = SmartButtonType.REPLY |
| } |
| |
| private fun onSmartReplyClick( |
| entry: NotificationEntry, |
| smartReplies: SmartReplies, |
| replyIndex: Int, |
| smartReplyView: SmartReplyView, |
| button: Button, |
| choice: CharSequence |
| ) = keyguardDismissUtil.executeWhenUnlocked(!entry.isRowPinned) { |
| val canEditBeforeSend = constants.getEffectiveEditChoicesBeforeSending( |
| smartReplies.remoteInput.editChoicesBeforeSending) |
| if (canEditBeforeSend) { |
| remoteInputManager.activateRemoteInput( |
| button, |
| arrayOf(smartReplies.remoteInput), |
| smartReplies.remoteInput, |
| smartReplies.pendingIntent, |
| NotificationEntry.EditedSuggestionInfo(choice, replyIndex)) |
| } else { |
| smartReplyController.smartReplySent( |
| entry, |
| replyIndex, |
| button.text, |
| NotificationLogger.getNotificationLocation(entry).toMetricsEventEnum(), |
| false /* modifiedBeforeSending */) |
| entry.setHasSentReply() |
| try { |
| val intent = createRemoteInputIntent(smartReplies, choice) |
| smartReplies.pendingIntent.send(context, 0, intent) |
| } catch (e: PendingIntent.CanceledException) { |
| Log.w(TAG, "Unable to send smart reply", e) |
| } |
| smartReplyView.hideSmartSuggestions() |
| } |
| false // do not defer |
| } |
| |
| private fun createRemoteInputIntent(smartReplies: SmartReplies, choice: CharSequence): Intent { |
| val results = Bundle() |
| results.putString(smartReplies.remoteInput.resultKey, choice.toString()) |
| val intent = Intent().addFlags(Intent.FLAG_RECEIVER_FOREGROUND) |
| RemoteInput.addResultsToIntent(arrayOf(smartReplies.remoteInput), intent, results) |
| RemoteInput.setResultsSource(intent, RemoteInput.SOURCE_CHOICE) |
| return intent |
| } |
| } |
| |
| /** |
| * An OnClickListener wrapper that blocks the underlying OnClickListener for a given amount of |
| * time. |
| */ |
| private class DelayedOnClickListener( |
| private val mActualListener: View.OnClickListener, |
| private val mInitDelayMs: Long |
| ) : View.OnClickListener { |
| |
| private val mInitTimeMs = SystemClock.elapsedRealtime() |
| |
| override fun onClick(v: View) { |
| if (hasFinishedInitialization()) { |
| mActualListener.onClick(v) |
| } else { |
| Log.i(TAG, "Accidental Smart Suggestion click registered, delay: $mInitDelayMs") |
| } |
| } |
| |
| private fun hasFinishedInitialization(): Boolean = |
| SystemClock.elapsedRealtime() >= mInitTimeMs + mInitDelayMs |
| } |
| |
| private const val TAG = "SmartReplyViewInflater" |
| private val DEBUG = Log.isLoggable(TAG, Log.DEBUG) |
| |
| // convenience function that swaps parameter order so that lambda can be placed at the end |
| private fun KeyguardDismissUtil.executeWhenUnlocked( |
| requiresShadeOpen: Boolean, |
| onDismissAction: () -> Boolean |
| ) = executeWhenUnlocked(onDismissAction, requiresShadeOpen, false) |
| |
| // convenience function that swaps parameter order so that lambda can be placed at the end |
| private fun ActivityStarter.startPendingIntentDismissingKeyguard( |
| intent: PendingIntent, |
| associatedView: View?, |
| runnable: () -> Unit |
| ) = startPendingIntentDismissingKeyguard(intent, runnable::invoke, associatedView) |