blob: 6de054313cb74a03923f01ba1e9eab13cb8434c0 [file] [log] [blame]
/*
* Copyright (C) 2017 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;
import static android.app.WindowConfiguration.WINDOWING_MODE_FULLSCREEN_OR_SPLIT_SCREEN_SECONDARY;
import android.app.ActivityManager;
import android.app.PendingIntent;
import android.app.RemoteInput;
import android.content.Context;
import android.content.Intent;
import android.os.RemoteException;
import android.os.ServiceManager;
import android.os.SystemClock;
import android.os.SystemProperties;
import android.os.UserManager;
import android.service.notification.StatusBarNotification;
import android.util.ArraySet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import android.widget.RemoteViews;
import android.widget.TextView;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.statusbar.IStatusBarService;
import com.android.internal.statusbar.NotificationVisibility;
import com.android.systemui.Dependency;
import com.android.systemui.Dumpable;
import com.android.systemui.statusbar.notification.NotificationData;
import com.android.systemui.statusbar.notification.NotificationEntryManager;
import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow;
import com.android.systemui.statusbar.policy.RemoteInputView;
import java.io.FileDescriptor;
import java.io.PrintWriter;
import java.util.Set;
/**
* Class for handling remote input state over a set of notifications. This class handles things
* like keeping notifications temporarily that were cancelled as a response to a remote input
* interaction, keeping track of notifications to remove when NotificationPresenter is collapsed,
* and handling clicks on remote views.
*/
public class NotificationRemoteInputManager implements Dumpable {
public static final boolean ENABLE_REMOTE_INPUT =
SystemProperties.getBoolean("debug.enable_remote_input", true);
public static final boolean FORCE_REMOTE_INPUT_HISTORY =
SystemProperties.getBoolean("debug.force_remoteinput_history", true);
private static final boolean DEBUG = false;
private static final String TAG = "NotificationRemoteInputManager";
/**
* How long to wait before auto-dismissing a notification that was kept for remote input, and
* has now sent a remote input. We auto-dismiss, because the app may not see a reason to cancel
* these given that they technically don't exist anymore. We wait a bit in case the app issues
* an update.
*/
private static final int REMOTE_INPUT_KEPT_ENTRY_AUTO_CANCEL_DELAY = 200;
protected final ArraySet<NotificationData.Entry> mRemoteInputEntriesToRemoveOnCollapse =
new ArraySet<>();
// Dependencies:
protected final NotificationLockscreenUserManager mLockscreenUserManager =
Dependency.get(NotificationLockscreenUserManager.class);
protected final Context mContext;
private final UserManager mUserManager;
protected RemoteInputController mRemoteInputController;
protected NotificationPresenter mPresenter;
protected NotificationEntryManager mEntryManager;
protected IStatusBarService mBarService;
protected Callback mCallback;
private final RemoteViews.OnClickHandler mOnClickHandler = new RemoteViews.OnClickHandler() {
@Override
public boolean onClickHandler(
final View view, final PendingIntent pendingIntent, final Intent fillInIntent) {
mPresenter.wakeUpIfDozing(SystemClock.uptimeMillis(), view);
if (handleRemoteInput(view, pendingIntent)) {
return true;
}
if (DEBUG) {
Log.v(TAG, "Notification click handler invoked for intent: " + pendingIntent);
}
logActionClick(view);
// The intent we are sending is for the application, which
// won't have permission to immediately start an activity after
// the user switches to home. We know it is safe to do at this
// point, so make sure new activity switches are now allowed.
try {
ActivityManager.getService().resumeAppSwitches();
} catch (RemoteException e) {
}
return mCallback.handleRemoteViewClick(view, pendingIntent, fillInIntent,
() -> superOnClickHandler(view, pendingIntent, fillInIntent));
}
private void logActionClick(View view) {
ViewParent parent = view.getParent();
String key = getNotificationKeyForParent(parent);
if (key == null) {
Log.w(TAG, "Couldn't determine notification for click.");
return;
}
int index = -1;
// If this is a default template, determine the index of the button.
if (view.getId() == com.android.internal.R.id.action0 &&
parent != null && parent instanceof ViewGroup) {
ViewGroup actionGroup = (ViewGroup) parent;
index = actionGroup.indexOfChild(view);
}
final int count = mEntryManager.getNotificationData().getActiveNotifications().size();
final int rank = mEntryManager.getNotificationData().getRank(key);
final NotificationVisibility nv = NotificationVisibility.obtain(key, rank, count, true);
try {
mBarService.onNotificationActionClick(key, index, nv);
} catch (RemoteException e) {
// Ignore
}
}
private String getNotificationKeyForParent(ViewParent parent) {
while (parent != null) {
if (parent instanceof ExpandableNotificationRow) {
return ((ExpandableNotificationRow) parent)
.getStatusBarNotification().getKey();
}
parent = parent.getParent();
}
return null;
}
private boolean superOnClickHandler(View view, PendingIntent pendingIntent,
Intent fillInIntent) {
return super.onClickHandler(view, pendingIntent, fillInIntent,
WINDOWING_MODE_FULLSCREEN_OR_SPLIT_SCREEN_SECONDARY);
}
private boolean handleRemoteInput(View view, PendingIntent pendingIntent) {
if (mCallback.shouldHandleRemoteInput(view, pendingIntent)) {
return true;
}
Object tag = view.getTag(com.android.internal.R.id.remote_input_tag);
RemoteInput[] inputs = null;
if (tag instanceof RemoteInput[]) {
inputs = (RemoteInput[]) tag;
}
if (inputs == null) {
return false;
}
RemoteInput input = null;
for (RemoteInput i : inputs) {
if (i.getAllowFreeFormInput()) {
input = i;
}
}
if (input == null) {
return false;
}
ViewParent p = view.getParent();
RemoteInputView riv = null;
while (p != null) {
if (p instanceof View) {
View pv = (View) p;
if (pv.isRootNamespace()) {
riv = findRemoteInputView(pv);
break;
}
}
p = p.getParent();
}
ExpandableNotificationRow row = null;
while (p != null) {
if (p instanceof ExpandableNotificationRow) {
row = (ExpandableNotificationRow) p;
break;
}
p = p.getParent();
}
if (row == null) {
return false;
}
row.setUserExpanded(true);
if (!mLockscreenUserManager.shouldAllowLockscreenRemoteInput()) {
final int userId = pendingIntent.getCreatorUserHandle().getIdentifier();
if (mLockscreenUserManager.isLockscreenPublicMode(userId)) {
mCallback.onLockedRemoteInput(row, view);
return true;
}
if (mUserManager.getUserInfo(userId).isManagedProfile()
&& mPresenter.isDeviceLocked(userId)) {
mCallback.onLockedWorkRemoteInput(userId, row, view);
return true;
}
}
if (riv == null) {
riv = findRemoteInputView(row.getPrivateLayout().getExpandedChild());
if (riv == null) {
return false;
}
if (!row.getPrivateLayout().getExpandedChild().isShown()) {
mCallback.onMakeExpandedVisibleForRemoteInput(row, view);
return true;
}
}
int width = view.getWidth();
if (view instanceof TextView) {
// Center the reveal on the text which might be off-center from the TextView
TextView tv = (TextView) view;
if (tv.getLayout() != null) {
int innerWidth = (int) tv.getLayout().getLineWidth(0);
innerWidth += tv.getCompoundPaddingLeft() + tv.getCompoundPaddingRight();
width = Math.min(width, innerWidth);
}
}
int cx = view.getLeft() + width / 2;
int cy = view.getTop() + view.getHeight() / 2;
int w = riv.getWidth();
int h = riv.getHeight();
int r = Math.max(
Math.max(cx + cy, cx + (h - cy)),
Math.max((w - cx) + cy, (w - cx) + (h - cy)));
riv.setRevealParameters(cx, cy, r);
riv.setPendingIntent(pendingIntent);
riv.setRemoteInput(inputs, input);
riv.focusAnimated();
return true;
}
private RemoteInputView findRemoteInputView(View v) {
if (v == null) {
return null;
}
return (RemoteInputView) v.findViewWithTag(RemoteInputView.VIEW_TAG);
}
};
public NotificationRemoteInputManager(Context context) {
mContext = context;
mBarService = IStatusBarService.Stub.asInterface(
ServiceManager.getService(Context.STATUS_BAR_SERVICE));
mUserManager = (UserManager) mContext.getSystemService(Context.USER_SERVICE);
}
public void setUpWithPresenter(NotificationPresenter presenter,
NotificationEntryManager entryManager,
Callback callback,
RemoteInputController.Delegate delegate) {
mPresenter = presenter;
mEntryManager = entryManager;
mCallback = callback;
mRemoteInputController = new RemoteInputController(delegate);
mRemoteInputController.addCallback(new RemoteInputController.Callback() {
@Override
public void onRemoteInputSent(NotificationData.Entry entry) {
if (FORCE_REMOTE_INPUT_HISTORY
&& mEntryManager.isNotificationKeptForRemoteInput(entry.key)) {
mEntryManager.removeNotification(entry.key, null);
} else if (mRemoteInputEntriesToRemoveOnCollapse.contains(entry)) {
// We're currently holding onto this notification, but from the apps point of
// view it is already canceled, so we'll need to cancel it on the apps behalf
// after sending - unless the app posts an update in the mean time, so wait a
// bit.
mPresenter.getHandler().postDelayed(() -> {
if (mRemoteInputEntriesToRemoveOnCollapse.remove(entry)) {
mEntryManager.removeNotification(entry.key, null);
}
}, REMOTE_INPUT_KEPT_ENTRY_AUTO_CANCEL_DELAY);
}
try {
mBarService.onNotificationDirectReplied(entry.notification.getKey());
} catch (RemoteException e) {
// Nothing to do, system going down
}
}
});
}
public RemoteInputController getController() {
return mRemoteInputController;
}
public void onUpdateNotification(NotificationData.Entry entry) {
mRemoteInputEntriesToRemoveOnCollapse.remove(entry);
}
/**
* Returns true if NotificationRemoteInputManager wants to keep this notification around.
*
* @param entry notification being removed
*/
public boolean onRemoveNotification(NotificationData.Entry entry) {
if (entry != null && mRemoteInputController.isRemoteInputActive(entry)
&& (entry.row != null && !entry.row.isDismissed())) {
mRemoteInputEntriesToRemoveOnCollapse.add(entry);
return true;
}
return false;
}
public void onPerformRemoveNotification(StatusBarNotification n,
NotificationData.Entry entry) {
if (mRemoteInputController.isRemoteInputActive(entry)) {
mRemoteInputController.removeRemoteInput(entry, null);
}
}
public void removeRemoteInputEntriesKeptUntilCollapsed() {
for (int i = 0; i < mRemoteInputEntriesToRemoveOnCollapse.size(); i++) {
NotificationData.Entry entry = mRemoteInputEntriesToRemoveOnCollapse.valueAt(i);
mRemoteInputController.removeRemoteInput(entry, null);
mEntryManager.removeNotification(entry.key, mEntryManager.getLatestRankingMap());
}
mRemoteInputEntriesToRemoveOnCollapse.clear();
}
public void checkRemoteInputOutside(MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_OUTSIDE // touch outside the source bar
&& event.getX() == 0 && event.getY() == 0 // a touch outside both bars
&& mRemoteInputController.isRemoteInputActive()) {
mRemoteInputController.closeRemoteInputs();
}
}
@Override
public void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
pw.println("NotificationRemoteInputManager state:");
pw.print(" mRemoteInputEntriesToRemoveOnCollapse: ");
pw.println(mRemoteInputEntriesToRemoveOnCollapse);
}
public void bindRow(ExpandableNotificationRow row) {
row.setRemoteInputController(mRemoteInputController);
row.setRemoteViewClickHandler(mOnClickHandler);
}
@VisibleForTesting
public Set<NotificationData.Entry> getRemoteInputEntriesToRemoveOnCollapse() {
return mRemoteInputEntriesToRemoveOnCollapse;
}
/**
* Callback for various remote input related events, or for providing information that
* NotificationRemoteInputManager needs to know to decide what to do.
*/
public interface Callback {
/**
* Called when remote input was activated but the device is locked.
*
* @param row
* @param clicked
*/
void onLockedRemoteInput(ExpandableNotificationRow row, View clicked);
/**
* Called when remote input was activated but the device is locked and in a managed profile.
*
* @param userId
* @param row
* @param clicked
*/
void onLockedWorkRemoteInput(int userId, ExpandableNotificationRow row, View clicked);
/**
* Called when a row should be made expanded for the purposes of remote input.
*
* @param row
* @param clickedView
*/
void onMakeExpandedVisibleForRemoteInput(ExpandableNotificationRow row, View clickedView);
/**
* Return whether or not remote input should be handled for this view.
*
* @param view
* @param pendingIntent
* @return true iff the remote input should be handled
*/
boolean shouldHandleRemoteInput(View view, PendingIntent pendingIntent);
/**
* Performs any special handling for a remote view click. The default behaviour can be
* called through the defaultHandler parameter.
*
* @param view
* @param pendingIntent
* @param fillInIntent
* @param defaultHandler
* @return true iff the click was handled
*/
boolean handleRemoteViewClick(View view, PendingIntent pendingIntent, Intent fillInIntent,
ClickHandler defaultHandler);
}
/**
* Helper interface meant for passing the default on click behaviour to NotificationPresenter,
* so it may do its own handling before invoking the default behaviour.
*/
public interface ClickHandler {
/**
* Tries to handle a click on a remote view.
*
* @return true iff the click was handled
*/
boolean handleClick();
}
}