blob: 10adaec1c561d1a0a8e22b3f3e05b2031d7d69dc [file] [log] [blame]
/*
* Copyright (C) 2018 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.car.notification;
import android.annotation.Nullable;
import android.app.ActivityManager;
import android.app.Notification;
import android.app.PendingIntent;
import android.app.RemoteInput;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.os.RemoteException;
import android.service.notification.NotificationStats;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import android.widget.Toast;
import androidx.core.app.NotificationCompat;
import androidx.annotation.VisibleForTesting;
import com.android.car.assist.CarVoiceInteractionSession;
import com.android.car.assist.client.CarAssistUtils;
import com.android.internal.statusbar.IStatusBarService;
import com.android.internal.statusbar.NotificationVisibility;
import java.util.ArrayList;
import java.util.List;
/**
* Factory that builds a {@link View.OnClickListener} to handle the logic of what to do when a
* notification is clicked. It also handles the interaction with the StatusBarService.
*/
public class NotificationClickHandlerFactory {
/**
* Callback that will be issued after a notification is clicked.
*/
public interface OnNotificationClickListener {
/**
* A notification was clicked and handleNotificationClicked was invoked.
*
* @param launchResult For non-Assistant actions, returned from
* {@link PendingIntent#sendAndReturnResult}; for Assistant actions,
* returns {@link ActivityManager#START_SUCCESS} on success;
* {@link ActivityManager#START_ABORTED} otherwise.
*
* @param alertEntry {@link AlertEntry} whose Notification was clicked.
*/
void onNotificationClicked(int launchResult, AlertEntry alertEntry);
}
private static final String TAG = "NotificationClickHandlerFactory";
private final IStatusBarService mBarService;
private final List<OnNotificationClickListener> mClickListeners = new ArrayList<>();
private CarAssistUtils mCarAssistUtils;
@Nullable
private NotificationDataManager mNotificationDataManager;
private Handler mMainHandler;
public NotificationClickHandlerFactory(IStatusBarService barService) {
mBarService = barService;
mCarAssistUtils = null;
mMainHandler = new Handler(Looper.getMainLooper());
}
@VisibleForTesting
void setCarAssistUtils(CarAssistUtils carAssistUtils) {
mCarAssistUtils = carAssistUtils;
}
/**
* Sets the {@link NotificationDataManager} which contains additional state information of the
* {@link AlertEntry}s.
*/
public void setNotificationDataManager(NotificationDataManager manager) {
mNotificationDataManager = manager;
}
/**
* Returns the {@link NotificationDataManager} which contains additional state information of
* the {@link AlertEntry}s.
*/
@Nullable
public NotificationDataManager getNotificationDataManager() {
return mNotificationDataManager;
}
/**
* Returns a {@link View.OnClickListener} that should be used for the given
* {@link AlertEntry}
*
* @param alertEntry that will be considered clicked when onClick is called.
*/
public View.OnClickListener getClickHandler(AlertEntry alertEntry) {
return v -> {
Notification notification = alertEntry.getNotification();
final PendingIntent intent = notification.contentIntent != null
? notification.contentIntent
: notification.fullScreenIntent;
if (intent == null) {
return;
}
int result = ActivityManager.START_ABORTED;
try {
result = intent.sendAndReturnResult(/* context= */ null, /* code= */ 0,
/* intent= */ null, /* onFinished= */ null,
/* handler= */ null, /* requiredPermissions= */ null,
/* options= */ null);
} catch (PendingIntent.CanceledException e) {
// Do not take down the app over this
Log.w(TAG, "Sending contentIntent failed: " + e);
}
NotificationVisibility notificationVisibility = NotificationVisibility.obtain(
alertEntry.getKey(),
/* rank= */ -1, /* count= */ -1, /* visible= */ true);
try {
mBarService.onNotificationClick(alertEntry.getKey(),
notificationVisibility);
if (shouldAutoCancel(alertEntry)) {
clearNotification(alertEntry);
}
} catch (RemoteException ex) {
Log.e(TAG, "Remote exception in getClickHandler", ex);
}
handleNotificationClicked(result, alertEntry);
};
}
/**
* Returns a {@link View.OnClickListener} that should be used for the
* {@link android.app.Notification.Action} contained in the {@link AlertEntry}
*
* @param alertEntry that contains the clicked action.
* @param index the index of the action clicked.
*/
public View.OnClickListener getActionClickHandler(AlertEntry alertEntry, int index) {
return v -> {
Notification notification = alertEntry.getNotification();
Notification.Action action = notification.actions[index];
NotificationVisibility notificationVisibility = NotificationVisibility.obtain(
alertEntry.getKey(),
/* rank= */ -1, /* count= */ -1, /* visible= */ true);
boolean canceledExceptionThrown = false;
int semanticAction = action.getSemanticAction();
if (CarAssistUtils.isCarCompatibleMessagingNotification(
alertEntry.getStatusBarNotification())) {
if (semanticAction == Notification.Action.SEMANTIC_ACTION_REPLY) {
Context context = v.getContext().getApplicationContext();
Intent resultIntent = addCannedReplyMessage(action, context);
int result = sendPendingIntent(action.actionIntent, context, resultIntent);
if (result == ActivityManager.START_SUCCESS) {
showToast(context, R.string.toast_message_sent_success);
} else if (result == ActivityManager.START_ABORTED) {
canceledExceptionThrown = true;
}
}
} else {
int result = sendPendingIntent(action.actionIntent, /* context= */ null,
/* resultIntent= */ null);
if (result == ActivityManager.START_ABORTED) {
canceledExceptionThrown = true;
}
handleNotificationClicked(result, alertEntry);
}
if (!canceledExceptionThrown) {
try {
mBarService.onNotificationActionClick(
alertEntry.getKey(),
index,
action,
notificationVisibility,
/* generatedByAssistant= */ false);
} catch (RemoteException e) {
Log.e(TAG, "Remote exception in getActionClickHandler", e);
}
}
};
}
/**
* Returns a {@link View.OnClickListener} that should be used for the
* {@param messageNotification}'s {@param playButton}. Once the message is read aloud, the
* pending intent should be returned to the messaging app, so it can mark it as read.
*/
public View.OnClickListener getPlayClickHandler(AlertEntry messageNotification) {
return view -> {
if (!CarAssistUtils.isCarCompatibleMessagingNotification(
messageNotification.getStatusBarNotification())) {
return;
}
Context context = view.getContext().getApplicationContext();
if (mCarAssistUtils == null) {
mCarAssistUtils = new CarAssistUtils(context);
}
CarAssistUtils.ActionRequestCallback requestCallback = resultState -> {
if (CarAssistUtils.ActionRequestCallback.RESULT_FAILED.equals(resultState)) {
showToast(context, R.string.assist_action_failed_toast);
Log.e(TAG, "Assistant failed to read aloud the message");
}
// Don't trigger mCallback so the shade remains open.
};
mCarAssistUtils.requestAssistantVoiceAction(
messageNotification.getStatusBarNotification(),
CarVoiceInteractionSession.VOICE_ACTION_READ_NOTIFICATION,
requestCallback);
};
}
/**
* Returns a {@link View.OnClickListener} that should be used for the
* {@param messageNotification}'s {@param muteButton}.
*/
public View.OnClickListener getMuteClickHandler(
Button muteButton, AlertEntry messageNotification) {
return v -> {
NotificationCompat.Action action =
CarAssistUtils.getMuteAction(messageNotification.getNotification());
Log.d(TAG, action == null ? "Mute action is null, using built-in logic." :
"Mute action is not null, deferring muting behavior to app");
if (action != null && action.getActionIntent() != null) {
try {
action.getActionIntent().send();
// clear all notifications when mute button is clicked.
// once a mute pending intent is provided,
// the mute functionality is fully delegated to the app who will handle
// the mute state and ability to toggle on and off a notification.
// This is necessary to ensure that mute state has one single source of truth.
clearNotification(messageNotification);
} catch (PendingIntent.CanceledException e) {
Log.d(TAG, "Could not send pending intent to mute notification "
+ e.getLocalizedMessage());
}
} else if (mNotificationDataManager != null) {
mNotificationDataManager.toggleMute(messageNotification);
Context context = v.getContext().getApplicationContext();
muteButton.setText(
(mNotificationDataManager.isMessageNotificationMuted(messageNotification))
? context.getString(R.string.action_unmute_long)
: context.getString(R.string.action_mute_long));
// Don't trigger mCallback so the shade remains open.
} else {
Log.d(TAG, "Could not set mute click handler as NotificationDataManager is null");
}
};
}
/**
* Returns a {@link View.OnClickListener} that should be used for the {@code alertEntry}'s
* dismiss button.
*/
public View.OnClickListener getDismissHandler(AlertEntry alertEntry) {
return v -> clearNotification(alertEntry);
}
/**
* Registers a new {@link OnNotificationClickListener} to the list of click event listeners.
*/
public void registerClickListener(OnNotificationClickListener clickListener) {
if (clickListener != null && !mClickListeners.contains(clickListener)) {
mClickListeners.add(clickListener);
}
}
/**
* Unregisters a {@link OnNotificationClickListener} from the list of click event listeners.
*/
public void unregisterClickListener(OnNotificationClickListener clickListener) {
mClickListeners.remove(clickListener);
}
/**
* Clears all notifications.
*/
public void clearAllNotifications() {
try {
mBarService.onClearAllNotifications(ActivityManager.getCurrentUser());
} catch (RemoteException e) {
Log.e(TAG, "clearAllNotifications: ", e);
}
}
/**
* Clears the notifications provided.
*/
public void clearNotifications(List<NotificationGroup> notificationsToClear) {
notificationsToClear.forEach(notificationGroup -> {
if (notificationGroup.isGroup()) {
AlertEntry summaryNotification = notificationGroup.getGroupSummaryNotification();
clearNotification(summaryNotification);
}
notificationGroup.getChildNotifications()
.forEach(alertEntry -> clearNotification(alertEntry));
});
}
/**
* Collapses the notification shade panel.
*/
public void collapsePanel() {
try {
mBarService.collapsePanels();
} catch (RemoteException e) {
Log.e(TAG, "collapsePanel: ", e);
}
}
/**
* Invokes all onNotificationClicked handlers registered in {@link OnNotificationClickListener}s
* array.
*/
private void handleNotificationClicked(int launceResult, AlertEntry alertEntry) {
mClickListeners.forEach(
listener -> listener.onNotificationClicked(launceResult, alertEntry));
}
private void clearNotification(AlertEntry alertEntry) {
try {
// rank and count is used for logging and is not need at this time thus -1
NotificationVisibility notificationVisibility = NotificationVisibility.obtain(
alertEntry.getKey(),
/* rank= */ -1,
/* count= */ -1,
/* visible= */ true);
mBarService.onNotificationClear(
alertEntry.getStatusBarNotification().getPackageName(),
alertEntry.getStatusBarNotification().getUser().getIdentifier(),
alertEntry.getStatusBarNotification().getKey(),
NotificationStats.DISMISSAL_SHADE,
NotificationStats.DISMISS_SENTIMENT_NEUTRAL,
notificationVisibility);
} catch (RemoteException e) {
Log.e(TAG, "clearNotifications: ", e);
}
}
private int sendPendingIntent(PendingIntent pendingIntent, Context context,
Intent resultIntent) {
try {
return pendingIntent.sendAndReturnResult(/* context= */ context, /* code= */ 0,
/* intent= */ resultIntent, /* onFinished= */null,
/* handler= */ null, /* requiredPermissions= */ null,
/* options= */ null);
} catch (PendingIntent.CanceledException e) {
// Do not take down the app over this
Log.w(TAG, "Sending contentIntent failed: " + e);
return ActivityManager.START_ABORTED;
}
}
/** Adds the canned reply sms message to the {@link Notification.Action}'s RemoteInput. **/
@Nullable
private Intent addCannedReplyMessage(Notification.Action action, Context context) {
RemoteInput remoteInput = action.getRemoteInputs()[0];
if (remoteInput == null) {
Log.w("TAG", "Cannot add canned reply message to action with no RemoteInput.");
return null;
}
Bundle messageDataBundle = new Bundle();
messageDataBundle.putCharSequence(remoteInput.getResultKey(),
context.getString(R.string.canned_reply_message));
Intent resultIntent = new Intent();
RemoteInput.addResultsToIntent(
new RemoteInput[]{remoteInput}, resultIntent, messageDataBundle);
return resultIntent;
}
private void showToast(Context context, int resourceId) {
mMainHandler.post(
Toast.makeText(context, context.getString(resourceId), Toast.LENGTH_LONG)::show);
}
private boolean shouldAutoCancel(AlertEntry alertEntry) {
int flags = alertEntry.getNotification().flags;
if ((flags & Notification.FLAG_AUTO_CANCEL) != Notification.FLAG_AUTO_CANCEL) {
return false;
}
if ((flags & Notification.FLAG_FOREGROUND_SERVICE) != 0) {
return false;
}
return true;
}
}