1 /*
2  * Copyright (C) 2017 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 package com.android.systemui.statusbar;
17 
18 import static android.app.WindowConfiguration.WINDOWING_MODE_FULLSCREEN_OR_SPLIT_SCREEN_SECONDARY;
19 
20 import android.app.ActivityManager;
21 import android.app.PendingIntent;
22 import android.app.RemoteInput;
23 import android.content.Context;
24 import android.content.Intent;
25 import android.os.RemoteException;
26 import android.os.ServiceManager;
27 import android.os.SystemClock;
28 import android.os.SystemProperties;
29 import android.os.UserManager;
30 import android.service.notification.StatusBarNotification;
31 import android.util.ArraySet;
32 import android.util.Log;
33 import android.view.MotionEvent;
34 import android.view.View;
35 import android.view.ViewGroup;
36 import android.view.ViewParent;
37 import android.widget.RemoteViews;
38 import android.widget.TextView;
39 
40 import com.android.internal.annotations.VisibleForTesting;
41 import com.android.internal.statusbar.IStatusBarService;
42 import com.android.internal.statusbar.NotificationVisibility;
43 import com.android.systemui.Dependency;
44 import com.android.systemui.Dumpable;
45 import com.android.systemui.statusbar.policy.RemoteInputView;
46 
47 import java.io.FileDescriptor;
48 import java.io.PrintWriter;
49 import java.util.Set;
50 
51 /**
52  * Class for handling remote input state over a set of notifications. This class handles things
53  * like keeping notifications temporarily that were cancelled as a response to a remote input
54  * interaction, keeping track of notifications to remove when NotificationPresenter is collapsed,
55  * and handling clicks on remote views.
56  */
57 public class NotificationRemoteInputManager implements Dumpable {
58     public static final boolean ENABLE_REMOTE_INPUT =
59             SystemProperties.getBoolean("debug.enable_remote_input", true);
60     public static final boolean FORCE_REMOTE_INPUT_HISTORY =
61             SystemProperties.getBoolean("debug.force_remoteinput_history", true);
62     private static final boolean DEBUG = false;
63     private static final String TAG = "NotificationRemoteInputManager";
64 
65     /**
66      * How long to wait before auto-dismissing a notification that was kept for remote input, and
67      * has now sent a remote input. We auto-dismiss, because the app may not see a reason to cancel
68      * these given that they technically don't exist anymore. We wait a bit in case the app issues
69      * an update.
70      */
71     private static final int REMOTE_INPUT_KEPT_ENTRY_AUTO_CANCEL_DELAY = 200;
72 
73     protected final ArraySet<NotificationData.Entry> mRemoteInputEntriesToRemoveOnCollapse =
74             new ArraySet<>();
75 
76     // Dependencies:
77     protected final NotificationLockscreenUserManager mLockscreenUserManager =
78             Dependency.get(NotificationLockscreenUserManager.class);
79 
80     protected final Context mContext;
81     private final UserManager mUserManager;
82 
83     protected RemoteInputController mRemoteInputController;
84     protected NotificationPresenter mPresenter;
85     protected NotificationEntryManager mEntryManager;
86     protected IStatusBarService mBarService;
87     protected Callback mCallback;
88 
89     private final RemoteViews.OnClickHandler mOnClickHandler = new RemoteViews.OnClickHandler() {
90 
91         @Override
92         public boolean onClickHandler(
93                 final View view, final PendingIntent pendingIntent, final Intent fillInIntent) {
94             mPresenter.wakeUpIfDozing(SystemClock.uptimeMillis(), view);
95 
96             if (handleRemoteInput(view, pendingIntent)) {
97                 return true;
98             }
99 
100             if (DEBUG) {
101                 Log.v(TAG, "Notification click handler invoked for intent: " + pendingIntent);
102             }
103             logActionClick(view);
104             // The intent we are sending is for the application, which
105             // won't have permission to immediately start an activity after
106             // the user switches to home.  We know it is safe to do at this
107             // point, so make sure new activity switches are now allowed.
108             try {
109                 ActivityManager.getService().resumeAppSwitches();
110             } catch (RemoteException e) {
111             }
112             return mCallback.handleRemoteViewClick(view, pendingIntent, fillInIntent,
113                     () -> superOnClickHandler(view, pendingIntent, fillInIntent));
114         }
115 
116         private void logActionClick(View view) {
117             ViewParent parent = view.getParent();
118             String key = getNotificationKeyForParent(parent);
119             if (key == null) {
120                 Log.w(TAG, "Couldn't determine notification for click.");
121                 return;
122             }
123             int index = -1;
124             // If this is a default template, determine the index of the button.
125             if (view.getId() == com.android.internal.R.id.action0 &&
126                     parent != null && parent instanceof ViewGroup) {
127                 ViewGroup actionGroup = (ViewGroup) parent;
128                 index = actionGroup.indexOfChild(view);
129             }
130             final int count = mEntryManager.getNotificationData().getActiveNotifications().size();
131             final int rank = mEntryManager.getNotificationData().getRank(key);
132             final NotificationVisibility nv = NotificationVisibility.obtain(key, rank, count, true);
133             try {
134                 mBarService.onNotificationActionClick(key, index, nv);
135             } catch (RemoteException e) {
136                 // Ignore
137             }
138         }
139 
140         private String getNotificationKeyForParent(ViewParent parent) {
141             while (parent != null) {
142                 if (parent instanceof ExpandableNotificationRow) {
143                     return ((ExpandableNotificationRow) parent)
144                             .getStatusBarNotification().getKey();
145                 }
146                 parent = parent.getParent();
147             }
148             return null;
149         }
150 
151         private boolean superOnClickHandler(View view, PendingIntent pendingIntent,
152                 Intent fillInIntent) {
153             return super.onClickHandler(view, pendingIntent, fillInIntent,
154                     WINDOWING_MODE_FULLSCREEN_OR_SPLIT_SCREEN_SECONDARY);
155         }
156 
157         private boolean handleRemoteInput(View view, PendingIntent pendingIntent) {
158             if (mCallback.shouldHandleRemoteInput(view, pendingIntent)) {
159                 return true;
160             }
161 
162             Object tag = view.getTag(com.android.internal.R.id.remote_input_tag);
163             RemoteInput[] inputs = null;
164             if (tag instanceof RemoteInput[]) {
165                 inputs = (RemoteInput[]) tag;
166             }
167 
168             if (inputs == null) {
169                 return false;
170             }
171 
172             RemoteInput input = null;
173 
174             for (RemoteInput i : inputs) {
175                 if (i.getAllowFreeFormInput()) {
176                     input = i;
177                 }
178             }
179 
180             if (input == null) {
181                 return false;
182             }
183 
184             ViewParent p = view.getParent();
185             RemoteInputView riv = null;
186             while (p != null) {
187                 if (p instanceof View) {
188                     View pv = (View) p;
189                     if (pv.isRootNamespace()) {
190                         riv = findRemoteInputView(pv);
191                         break;
192                     }
193                 }
194                 p = p.getParent();
195             }
196             ExpandableNotificationRow row = null;
197             while (p != null) {
198                 if (p instanceof ExpandableNotificationRow) {
199                     row = (ExpandableNotificationRow) p;
200                     break;
201                 }
202                 p = p.getParent();
203             }
204 
205             if (row == null) {
206                 return false;
207             }
208 
209             row.setUserExpanded(true);
210 
211             if (!mLockscreenUserManager.shouldAllowLockscreenRemoteInput()) {
212                 final int userId = pendingIntent.getCreatorUserHandle().getIdentifier();
213                 if (mLockscreenUserManager.isLockscreenPublicMode(userId)) {
214                     mCallback.onLockedRemoteInput(row, view);
215                     return true;
216                 }
217                 if (mUserManager.getUserInfo(userId).isManagedProfile()
218                         && mPresenter.isDeviceLocked(userId)) {
219                     mCallback.onLockedWorkRemoteInput(userId, row, view);
220                     return true;
221                 }
222             }
223 
224             if (riv == null) {
225                 riv = findRemoteInputView(row.getPrivateLayout().getExpandedChild());
226                 if (riv == null) {
227                     return false;
228                 }
229                 if (!row.getPrivateLayout().getExpandedChild().isShown()) {
230                     mCallback.onMakeExpandedVisibleForRemoteInput(row, view);
231                     return true;
232                 }
233             }
234 
235             int width = view.getWidth();
236             if (view instanceof TextView) {
237                 // Center the reveal on the text which might be off-center from the TextView
238                 TextView tv = (TextView) view;
239                 if (tv.getLayout() != null) {
240                     int innerWidth = (int) tv.getLayout().getLineWidth(0);
241                     innerWidth += tv.getCompoundPaddingLeft() + tv.getCompoundPaddingRight();
242                     width = Math.min(width, innerWidth);
243                 }
244             }
245             int cx = view.getLeft() + width / 2;
246             int cy = view.getTop() + view.getHeight() / 2;
247             int w = riv.getWidth();
248             int h = riv.getHeight();
249             int r = Math.max(
250                     Math.max(cx + cy, cx + (h - cy)),
251                     Math.max((w - cx) + cy, (w - cx) + (h - cy)));
252 
253             riv.setRevealParameters(cx, cy, r);
254             riv.setPendingIntent(pendingIntent);
255             riv.setRemoteInput(inputs, input);
256             riv.focusAnimated();
257 
258             return true;
259         }
260 
261         private RemoteInputView findRemoteInputView(View v) {
262             if (v == null) {
263                 return null;
264             }
265             return (RemoteInputView) v.findViewWithTag(RemoteInputView.VIEW_TAG);
266         }
267     };
268 
NotificationRemoteInputManager(Context context)269     public NotificationRemoteInputManager(Context context) {
270         mContext = context;
271         mBarService = IStatusBarService.Stub.asInterface(
272                 ServiceManager.getService(Context.STATUS_BAR_SERVICE));
273         mUserManager = (UserManager) mContext.getSystemService(Context.USER_SERVICE);
274     }
275 
setUpWithPresenter(NotificationPresenter presenter, NotificationEntryManager entryManager, Callback callback, RemoteInputController.Delegate delegate)276     public void setUpWithPresenter(NotificationPresenter presenter,
277             NotificationEntryManager entryManager,
278             Callback callback,
279             RemoteInputController.Delegate delegate) {
280         mPresenter = presenter;
281         mEntryManager = entryManager;
282         mCallback = callback;
283         mRemoteInputController = new RemoteInputController(delegate);
284         mRemoteInputController.addCallback(new RemoteInputController.Callback() {
285             @Override
286             public void onRemoteInputSent(NotificationData.Entry entry) {
287                 if (FORCE_REMOTE_INPUT_HISTORY
288                         && mEntryManager.isNotificationKeptForRemoteInput(entry.key)) {
289                     mEntryManager.removeNotification(entry.key, null);
290                 } else if (mRemoteInputEntriesToRemoveOnCollapse.contains(entry)) {
291                     // We're currently holding onto this notification, but from the apps point of
292                     // view it is already canceled, so we'll need to cancel it on the apps behalf
293                     // after sending - unless the app posts an update in the mean time, so wait a
294                     // bit.
295                     mPresenter.getHandler().postDelayed(() -> {
296                         if (mRemoteInputEntriesToRemoveOnCollapse.remove(entry)) {
297                             mEntryManager.removeNotification(entry.key, null);
298                         }
299                     }, REMOTE_INPUT_KEPT_ENTRY_AUTO_CANCEL_DELAY);
300                 }
301                 try {
302                     mBarService.onNotificationDirectReplied(entry.notification.getKey());
303                 } catch (RemoteException e) {
304                     // Nothing to do, system going down
305                 }
306             }
307         });
308 
309     }
310 
getController()311     public RemoteInputController getController() {
312         return mRemoteInputController;
313     }
314 
onUpdateNotification(NotificationData.Entry entry)315     public void onUpdateNotification(NotificationData.Entry entry) {
316         mRemoteInputEntriesToRemoveOnCollapse.remove(entry);
317     }
318 
319     /**
320      * Returns true if NotificationRemoteInputManager wants to keep this notification around.
321      *
322      * @param entry notification being removed
323      */
onRemoveNotification(NotificationData.Entry entry)324     public boolean onRemoveNotification(NotificationData.Entry entry) {
325         if (entry != null && mRemoteInputController.isRemoteInputActive(entry)
326                 && (entry.row != null && !entry.row.isDismissed())) {
327             mRemoteInputEntriesToRemoveOnCollapse.add(entry);
328             return true;
329         }
330         return false;
331     }
332 
onPerformRemoveNotification(StatusBarNotification n, NotificationData.Entry entry)333     public void onPerformRemoveNotification(StatusBarNotification n,
334             NotificationData.Entry entry) {
335         if (mRemoteInputController.isRemoteInputActive(entry)) {
336             mRemoteInputController.removeRemoteInput(entry, null);
337         }
338     }
339 
removeRemoteInputEntriesKeptUntilCollapsed()340     public void removeRemoteInputEntriesKeptUntilCollapsed() {
341         for (int i = 0; i < mRemoteInputEntriesToRemoveOnCollapse.size(); i++) {
342             NotificationData.Entry entry = mRemoteInputEntriesToRemoveOnCollapse.valueAt(i);
343             mRemoteInputController.removeRemoteInput(entry, null);
344             mEntryManager.removeNotification(entry.key, mEntryManager.getLatestRankingMap());
345         }
346         mRemoteInputEntriesToRemoveOnCollapse.clear();
347     }
348 
checkRemoteInputOutside(MotionEvent event)349     public void checkRemoteInputOutside(MotionEvent event) {
350         if (event.getAction() == MotionEvent.ACTION_OUTSIDE // touch outside the source bar
351                 && event.getX() == 0 && event.getY() == 0  // a touch outside both bars
352                 && mRemoteInputController.isRemoteInputActive()) {
353             mRemoteInputController.closeRemoteInputs();
354         }
355     }
356 
357     @Override
dump(FileDescriptor fd, PrintWriter pw, String[] args)358     public void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
359         pw.println("NotificationRemoteInputManager state:");
360         pw.print("  mRemoteInputEntriesToRemoveOnCollapse: ");
361         pw.println(mRemoteInputEntriesToRemoveOnCollapse);
362     }
363 
bindRow(ExpandableNotificationRow row)364     public void bindRow(ExpandableNotificationRow row) {
365         row.setRemoteInputController(mRemoteInputController);
366         row.setRemoteViewClickHandler(mOnClickHandler);
367     }
368 
369     @VisibleForTesting
getRemoteInputEntriesToRemoveOnCollapse()370     public Set<NotificationData.Entry> getRemoteInputEntriesToRemoveOnCollapse() {
371         return mRemoteInputEntriesToRemoveOnCollapse;
372     }
373 
374     /**
375      * Callback for various remote input related events, or for providing information that
376      * NotificationRemoteInputManager needs to know to decide what to do.
377      */
378     public interface Callback {
379 
380         /**
381          * Called when remote input was activated but the device is locked.
382          *
383          * @param row
384          * @param clicked
385          */
onLockedRemoteInput(ExpandableNotificationRow row, View clicked)386         void onLockedRemoteInput(ExpandableNotificationRow row, View clicked);
387 
388         /**
389          * Called when remote input was activated but the device is locked and in a managed profile.
390          *
391          * @param userId
392          * @param row
393          * @param clicked
394          */
onLockedWorkRemoteInput(int userId, ExpandableNotificationRow row, View clicked)395         void onLockedWorkRemoteInput(int userId, ExpandableNotificationRow row, View clicked);
396 
397         /**
398          * Called when a row should be made expanded for the purposes of remote input.
399          *
400          * @param row
401          * @param clickedView
402          */
onMakeExpandedVisibleForRemoteInput(ExpandableNotificationRow row, View clickedView)403         void onMakeExpandedVisibleForRemoteInput(ExpandableNotificationRow row, View clickedView);
404 
405         /**
406          * Return whether or not remote input should be handled for this view.
407          *
408          * @param view
409          * @param pendingIntent
410          * @return true iff the remote input should be handled
411          */
shouldHandleRemoteInput(View view, PendingIntent pendingIntent)412         boolean shouldHandleRemoteInput(View view, PendingIntent pendingIntent);
413 
414         /**
415          * Performs any special handling for a remote view click. The default behaviour can be
416          * called through the defaultHandler parameter.
417          *
418          * @param view
419          * @param pendingIntent
420          * @param fillInIntent
421          * @param defaultHandler
422          * @return  true iff the click was handled
423          */
handleRemoteViewClick(View view, PendingIntent pendingIntent, Intent fillInIntent, ClickHandler defaultHandler)424         boolean handleRemoteViewClick(View view, PendingIntent pendingIntent, Intent fillInIntent,
425                 ClickHandler defaultHandler);
426     }
427 
428     /**
429      * Helper interface meant for passing the default on click behaviour to NotificationPresenter,
430      * so it may do its own handling before invoking the default behaviour.
431      */
432     public interface ClickHandler {
433         /**
434          * Tries to handle a click on a remote view.
435          *
436          * @return true iff the click was handled
437          */
handleClick()438         boolean handleClick();
439     }
440 }
441