/*
|
* Copyright (C) 2019 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.annotation.NonNull;
|
import android.annotation.Nullable;
|
import android.app.Notification;
|
import android.app.RemoteInput;
|
import android.content.Context;
|
import android.content.Intent;
|
import android.content.pm.ResolveInfo;
|
import android.os.Build;
|
import android.util.Log;
|
import android.util.Pair;
|
import android.widget.Button;
|
|
import com.android.internal.annotations.VisibleForTesting;
|
import com.android.internal.util.ArrayUtils;
|
import com.android.systemui.Dependency;
|
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.NotificationUiAdjustment;
|
import com.android.systemui.statusbar.SmartReplyController;
|
import com.android.systemui.statusbar.notification.collection.NotificationEntry;
|
|
import java.util.ArrayList;
|
import java.util.Arrays;
|
import java.util.Collections;
|
import java.util.List;
|
|
/**
|
* Holder for inflated smart replies and actions. These objects should be inflated on a background
|
* thread, to later be accessed and modified on the (performance critical) UI thread.
|
*/
|
public class InflatedSmartReplies {
|
private static final String TAG = "InflatedSmartReplies";
|
private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
|
@Nullable private final SmartReplyView mSmartReplyView;
|
@Nullable private final List<Button> mSmartSuggestionButtons;
|
@NonNull private final SmartRepliesAndActions mSmartRepliesAndActions;
|
|
private InflatedSmartReplies(
|
@Nullable SmartReplyView smartReplyView,
|
@Nullable List<Button> smartSuggestionButtons,
|
@NonNull SmartRepliesAndActions smartRepliesAndActions) {
|
mSmartReplyView = smartReplyView;
|
mSmartSuggestionButtons = smartSuggestionButtons;
|
mSmartRepliesAndActions = smartRepliesAndActions;
|
}
|
|
@Nullable public SmartReplyView getSmartReplyView() {
|
return mSmartReplyView;
|
}
|
|
@Nullable public List<Button> getSmartSuggestionButtons() {
|
return mSmartSuggestionButtons;
|
}
|
|
@NonNull public SmartRepliesAndActions getSmartRepliesAndActions() {
|
return mSmartRepliesAndActions;
|
}
|
|
/**
|
* Inflate a SmartReplyView and its smart suggestions.
|
*/
|
public static InflatedSmartReplies inflate(
|
Context context,
|
NotificationEntry entry,
|
SmartReplyConstants smartReplyConstants,
|
SmartReplyController smartReplyController,
|
HeadsUpManager headsUpManager,
|
SmartRepliesAndActions existingSmartRepliesAndActions) {
|
SmartRepliesAndActions newSmartRepliesAndActions =
|
chooseSmartRepliesAndActions(smartReplyConstants, entry);
|
if (!shouldShowSmartReplyView(entry, newSmartRepliesAndActions)) {
|
return new InflatedSmartReplies(null /* smartReplyView */,
|
null /* smartSuggestionButtons */, newSmartRepliesAndActions);
|
}
|
|
// 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.
|
boolean delayOnClickListener =
|
!areSuggestionsSimilar(existingSmartRepliesAndActions, newSmartRepliesAndActions);
|
|
SmartReplyView smartReplyView = SmartReplyView.inflate(context);
|
|
List<Button> suggestionButtons = new ArrayList<>();
|
if (newSmartRepliesAndActions.smartReplies != null) {
|
suggestionButtons.addAll(smartReplyView.inflateRepliesFromRemoteInput(
|
newSmartRepliesAndActions.smartReplies, smartReplyController, entry,
|
delayOnClickListener));
|
}
|
if (newSmartRepliesAndActions.smartActions != null) {
|
suggestionButtons.addAll(
|
smartReplyView.inflateSmartActions(newSmartRepliesAndActions.smartActions,
|
smartReplyController, entry, headsUpManager,
|
delayOnClickListener));
|
}
|
|
return new InflatedSmartReplies(smartReplyView, suggestionButtons,
|
newSmartRepliesAndActions);
|
}
|
|
@VisibleForTesting
|
static boolean areSuggestionsSimilar(
|
SmartRepliesAndActions left, SmartRepliesAndActions right) {
|
if (left == right) return true;
|
if (left == null || right == null) return false;
|
|
if (!Arrays.equals(left.getSmartReplies(), right.getSmartReplies())) {
|
return false;
|
}
|
|
return !NotificationUiAdjustment.areDifferent(
|
left.getSmartActions(), right.getSmartActions());
|
}
|
|
/**
|
* Returns whether we should show the smart reply view and its smart suggestions.
|
*/
|
public static boolean shouldShowSmartReplyView(
|
NotificationEntry entry,
|
SmartRepliesAndActions smartRepliesAndActions) {
|
if (smartRepliesAndActions.smartReplies == null
|
&& smartRepliesAndActions.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.
|
boolean showingSpinner = entry.notification.getNotification()
|
.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.
|
boolean hideSmartReplies = entry.notification.getNotification()
|
.extras.getBoolean(Notification.EXTRA_HIDE_SMART_REPLIES, false);
|
if (hideSmartReplies) {
|
return false;
|
}
|
return true;
|
}
|
|
/**
|
* 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.
|
*/
|
@NonNull
|
public static SmartRepliesAndActions chooseSmartRepliesAndActions(
|
SmartReplyConstants smartReplyConstants,
|
final NotificationEntry entry) {
|
Notification notification = entry.notification.getNotification();
|
Pair<RemoteInput, Notification.Action> remoteInputActionPair =
|
notification.findRemoteInputActionPair(false /* freeform */);
|
Pair<RemoteInput, Notification.Action> freeformRemoteInputActionPair =
|
notification.findRemoteInputActionPair(true /* freeform */);
|
|
if (!smartReplyConstants.isEnabled()) {
|
if (DEBUG) {
|
Log.d(TAG, "Smart suggestions not enabled, not adding suggestions for "
|
+ entry.notification.getKey());
|
}
|
return new SmartRepliesAndActions(null, null);
|
}
|
// 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.
|
boolean enableAppGeneratedSmartReplies = (!smartReplyConstants.requiresTargetingP()
|
|| entry.targetSdk >= Build.VERSION_CODES.P);
|
|
boolean appGeneratedSmartRepliesExist =
|
enableAppGeneratedSmartReplies
|
&& remoteInputActionPair != null
|
&& !ArrayUtils.isEmpty(remoteInputActionPair.first.getChoices())
|
&& remoteInputActionPair.second.actionIntent != null;
|
|
List<Notification.Action> appGeneratedSmartActions = notification.getContextualActions();
|
boolean appGeneratedSmartActionsExist = !appGeneratedSmartActions.isEmpty();
|
|
SmartReplyView.SmartReplies smartReplies = null;
|
SmartReplyView.SmartActions smartActions = null;
|
if (appGeneratedSmartRepliesExist) {
|
smartReplies = new SmartReplyView.SmartReplies(
|
remoteInputActionPair.first.getChoices(),
|
remoteInputActionPair.first,
|
remoteInputActionPair.second.actionIntent,
|
false /* fromAssistant */);
|
}
|
if (appGeneratedSmartActionsExist) {
|
smartActions = new SmartReplyView.SmartActions(appGeneratedSmartActions,
|
false /* fromAssistant */);
|
}
|
// Apps didn't provide any smart replies / actions, use those from NAS (if any).
|
if (!appGeneratedSmartRepliesExist && !appGeneratedSmartActionsExist) {
|
boolean useGeneratedReplies = !ArrayUtils.isEmpty(entry.systemGeneratedSmartReplies)
|
&& freeformRemoteInputActionPair != null
|
&& freeformRemoteInputActionPair.second.getAllowGeneratedReplies()
|
&& freeformRemoteInputActionPair.second.actionIntent != null;
|
if (useGeneratedReplies) {
|
smartReplies = new SmartReplyView.SmartReplies(
|
entry.systemGeneratedSmartReplies,
|
freeformRemoteInputActionPair.first,
|
freeformRemoteInputActionPair.second.actionIntent,
|
true /* fromAssistant */);
|
}
|
boolean useSmartActions = !ArrayUtils.isEmpty(entry.systemGeneratedSmartActions)
|
&& notification.getAllowSystemGeneratedContextualActions();
|
if (useSmartActions) {
|
List<Notification.Action> systemGeneratedActions =
|
entry.systemGeneratedSmartActions;
|
// Filter actions if we're in kiosk-mode - we don't care about screen pinning mode,
|
// since notifications aren't shown there anyway.
|
ActivityManagerWrapper activityManagerWrapper =
|
Dependency.get(ActivityManagerWrapper.class);
|
if (activityManagerWrapper.isLockTaskKioskModeActive()) {
|
systemGeneratedActions = filterWhiteListedLockTaskApps(systemGeneratedActions);
|
}
|
smartActions = new SmartReplyView.SmartActions(
|
systemGeneratedActions, true /* fromAssistant */);
|
}
|
}
|
return new SmartRepliesAndActions(smartReplies, smartActions);
|
}
|
|
/**
|
* Filter actions so that only actions pointing to whitelisted apps are allowed.
|
* This filtering is only meaningful when in lock-task mode.
|
*/
|
private static List<Notification.Action> filterWhiteListedLockTaskApps(
|
List<Notification.Action> actions) {
|
PackageManagerWrapper packageManagerWrapper = Dependency.get(PackageManagerWrapper.class);
|
DevicePolicyManagerWrapper devicePolicyManagerWrapper =
|
Dependency.get(DevicePolicyManagerWrapper.class);
|
List<Notification.Action> filteredActions = new ArrayList<>();
|
for (Notification.Action action : actions) {
|
if (action.actionIntent == null) continue;
|
Intent intent = action.actionIntent.getIntent();
|
// Only allow actions that are explicit (implicit intents are not handled in lock-task
|
// mode), and link to whitelisted apps.
|
ResolveInfo resolveInfo = packageManagerWrapper.resolveActivity(intent, 0 /* flags */);
|
if (resolveInfo != null && devicePolicyManagerWrapper.isLockTaskPermitted(
|
resolveInfo.activityInfo.packageName)) {
|
filteredActions.add(action);
|
}
|
}
|
return filteredActions;
|
}
|
|
/**
|
* Returns whether the {@link Notification} represented by entry has a free-form remote input.
|
* Such an input can be used e.g. to implement smart reply buttons - by passing the replies
|
* through the remote input.
|
*/
|
public static boolean hasFreeformRemoteInput(NotificationEntry entry) {
|
Notification notification = entry.notification.getNotification();
|
return null != notification.findRemoteInputActionPair(true /* freeform */);
|
}
|
|
/**
|
* A storage for smart replies and smart action.
|
*/
|
public static class SmartRepliesAndActions {
|
@Nullable public final SmartReplyView.SmartReplies smartReplies;
|
@Nullable public final SmartReplyView.SmartActions smartActions;
|
|
SmartRepliesAndActions(
|
@Nullable SmartReplyView.SmartReplies smartReplies,
|
@Nullable SmartReplyView.SmartActions smartActions) {
|
this.smartReplies = smartReplies;
|
this.smartActions = smartActions;
|
}
|
|
@NonNull public CharSequence[] getSmartReplies() {
|
return smartReplies == null ? new CharSequence[0] : smartReplies.choices;
|
}
|
|
@NonNull public List<Notification.Action> getSmartActions() {
|
return smartActions == null ? Collections.emptyList() : smartActions.actions;
|
}
|
}
|
}
|