1 /*
2  * Copyright (C) 2015 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 
17 package com.android.systemui.statusbar;
18 
19 import android.app.Notification;
20 import android.app.RemoteInput;
21 import android.content.Context;
22 import android.net.Uri;
23 import android.os.SystemProperties;
24 import android.service.notification.StatusBarNotification;
25 import android.util.ArrayMap;
26 import android.util.Pair;
27 
28 import com.android.systemui.statusbar.notification.collection.NotificationEntry;
29 import com.android.systemui.statusbar.policy.RemoteInputUriController;
30 import com.android.systemui.statusbar.policy.RemoteInputView;
31 
32 import java.lang.ref.WeakReference;
33 import java.util.ArrayList;
34 import java.util.List;
35 import java.util.Objects;
36 
37 /**
38  * Keeps track of the currently active {@link RemoteInputView}s.
39  */
40 public class RemoteInputController {
41     private static final boolean ENABLE_REMOTE_INPUT =
42             SystemProperties.getBoolean("debug.enable_remote_input", true);
43 
44     private final ArrayList<Pair<WeakReference<NotificationEntry>, Object>> mOpen
45             = new ArrayList<>();
46     private final ArrayMap<String, Object> mSpinning = new ArrayMap<>();
47     private final ArrayList<Callback> mCallbacks = new ArrayList<>(3);
48     private final Delegate mDelegate;
49     private final RemoteInputUriController mRemoteInputUriController;
50 
RemoteInputController(Delegate delegate, RemoteInputUriController remoteInputUriController)51     public RemoteInputController(Delegate delegate,
52             RemoteInputUriController remoteInputUriController) {
53         mDelegate = delegate;
54         mRemoteInputUriController = remoteInputUriController;
55     }
56 
57     /**
58      * Adds RemoteInput actions from the WearableExtender; to be removed once more apps support this
59      * via first-class API.
60      *
61      * TODO: Remove once enough apps specify remote inputs on their own.
62      */
processForRemoteInput(Notification n, Context context)63     public static void processForRemoteInput(Notification n, Context context) {
64         if (!ENABLE_REMOTE_INPUT) {
65             return;
66         }
67 
68         if (n.extras != null && n.extras.containsKey("android.wearable.EXTENSIONS") &&
69                 (n.actions == null || n.actions.length == 0)) {
70             Notification.Action viableAction = null;
71             Notification.WearableExtender we = new Notification.WearableExtender(n);
72 
73             List<Notification.Action> actions = we.getActions();
74             final int numActions = actions.size();
75 
76             for (int i = 0; i < numActions; i++) {
77                 Notification.Action action = actions.get(i);
78                 if (action == null) {
79                     continue;
80                 }
81                 RemoteInput[] remoteInputs = action.getRemoteInputs();
82                 if (remoteInputs == null) {
83                     continue;
84                 }
85                 for (RemoteInput ri : remoteInputs) {
86                     if (ri.getAllowFreeFormInput()) {
87                         viableAction = action;
88                         break;
89                     }
90                 }
91                 if (viableAction != null) {
92                     break;
93                 }
94             }
95 
96             if (viableAction != null) {
97                 Notification.Builder rebuilder = Notification.Builder.recoverBuilder(context, n);
98                 rebuilder.setActions(viableAction);
99                 rebuilder.build(); // will rewrite n
100             }
101         }
102     }
103 
104     /**
105      * Adds a currently active remote input.
106      *
107      * @param entry the entry for which a remote input is now active.
108      * @param token a token identifying the view that is managing the remote input
109      */
addRemoteInput(NotificationEntry entry, Object token)110     public void addRemoteInput(NotificationEntry entry, Object token) {
111         Objects.requireNonNull(entry);
112         Objects.requireNonNull(token);
113 
114         boolean found = pruneWeakThenRemoveAndContains(
115                 entry /* contains */, null /* remove */, token /* removeToken */);
116         if (!found) {
117             mOpen.add(new Pair<>(new WeakReference<>(entry), token));
118         }
119 
120         apply(entry);
121     }
122 
123     /**
124      * Removes a currently active remote input.
125      *
126      * @param entry the entry for which a remote input should be removed.
127      * @param token a token identifying the view that is requesting the removal. If non-null,
128      *              the entry is only removed if the token matches the last added token for this
129      *              entry. If null, the entry is removed regardless.
130      */
removeRemoteInput(NotificationEntry entry, Object token)131     public void removeRemoteInput(NotificationEntry entry, Object token) {
132         Objects.requireNonNull(entry);
133         if (entry.mRemoteEditImeVisible && entry.mRemoteEditImeAnimatingAway) return;
134         // If the view is being removed, this may be called even though we're not active
135         if (!isRemoteInputActive(entry)) return;
136 
137         pruneWeakThenRemoveAndContains(null /* contains */, entry /* remove */, token);
138 
139         apply(entry);
140     }
141 
142     /**
143      * Adds a currently spinning (i.e. sending) remote input.
144      *
145      * @param key the key of the entry that's spinning.
146      * @param token the token of the view managing the remote input.
147      */
addSpinning(String key, Object token)148     public void addSpinning(String key, Object token) {
149         Objects.requireNonNull(key);
150         Objects.requireNonNull(token);
151 
152         mSpinning.put(key, token);
153     }
154 
155     /**
156      * Removes a currently spinning remote input.
157      *
158      * @param key the key of the entry for which a remote input should be removed.
159      * @param token a token identifying the view that is requesting the removal. If non-null,
160      *              the entry is only removed if the token matches the last added token for this
161      *              entry. If null, the entry is removed regardless.
162      */
removeSpinning(String key, Object token)163     public void removeSpinning(String key, Object token) {
164         Objects.requireNonNull(key);
165 
166         if (token == null || mSpinning.get(key) == token) {
167             mSpinning.remove(key);
168         }
169     }
170 
isSpinning(String key)171     public boolean isSpinning(String key) {
172         return mSpinning.containsKey(key);
173     }
174 
175     /**
176      * Same as {@link #isSpinning}, but also verifies that the token is the same
177      * @param key the key that is spinning
178      * @param token the token that needs to be the same
179      * @return if this key with a given token is spinning
180      */
isSpinning(String key, Object token)181     public boolean isSpinning(String key, Object token) {
182         return mSpinning.get(key) == token;
183     }
184 
apply(NotificationEntry entry)185     private void apply(NotificationEntry entry) {
186         mDelegate.setRemoteInputActive(entry, isRemoteInputActive(entry));
187         boolean remoteInputActive = isRemoteInputActive();
188         int N = mCallbacks.size();
189         for (int i = 0; i < N; i++) {
190             mCallbacks.get(i).onRemoteInputActive(remoteInputActive);
191         }
192     }
193 
194     /**
195      * @return true if {@param entry} has an active RemoteInput
196      */
isRemoteInputActive(NotificationEntry entry)197     public boolean isRemoteInputActive(NotificationEntry entry) {
198         return pruneWeakThenRemoveAndContains(entry /* contains */, null /* remove */,
199                 null /* removeToken */);
200     }
201 
202     /**
203      * @return true if any entry has an active RemoteInput
204      */
isRemoteInputActive()205     public boolean isRemoteInputActive() {
206         pruneWeakThenRemoveAndContains(null /* contains */, null /* remove */,
207                 null /* removeToken */);
208         return !mOpen.isEmpty();
209     }
210 
211     /**
212      * Prunes dangling weak references, removes entries referring to {@param remove} and returns
213      * whether {@param contains} is part of the array in a single loop.
214      * @param remove if non-null, removes this entry from the active remote inputs
215      * @param removeToken if non-null, only removes an entry if this matches the token when the
216      *                    entry was added.
217      * @return true if {@param contains} is in the set of active remote inputs
218      */
pruneWeakThenRemoveAndContains( NotificationEntry contains, NotificationEntry remove, Object removeToken)219     private boolean pruneWeakThenRemoveAndContains(
220             NotificationEntry contains, NotificationEntry remove, Object removeToken) {
221         boolean found = false;
222         for (int i = mOpen.size() - 1; i >= 0; i--) {
223             NotificationEntry item = mOpen.get(i).first.get();
224             Object itemToken = mOpen.get(i).second;
225             boolean removeTokenMatches = (removeToken == null || itemToken == removeToken);
226 
227             if (item == null || (item == remove && removeTokenMatches)) {
228                 mOpen.remove(i);
229             } else if (item == contains) {
230                 if (removeToken != null && removeToken != itemToken) {
231                     // We need to update the token. Remove here and let caller reinsert it.
232                     mOpen.remove(i);
233                 } else {
234                     found = true;
235                 }
236             }
237         }
238         return found;
239     }
240 
241 
addCallback(Callback callback)242     public void addCallback(Callback callback) {
243         Objects.requireNonNull(callback);
244         mCallbacks.add(callback);
245     }
246 
removeCallback(Callback callback)247     public void removeCallback(Callback callback) {
248         mCallbacks.remove(callback);
249     }
250 
remoteInputSent(NotificationEntry entry)251     public void remoteInputSent(NotificationEntry entry) {
252         int N = mCallbacks.size();
253         for (int i = 0; i < N; i++) {
254             mCallbacks.get(i).onRemoteInputSent(entry);
255         }
256     }
257 
closeRemoteInputs()258     public void closeRemoteInputs() {
259         if (mOpen.size() == 0) {
260             return;
261         }
262 
263         // Make a copy because closing the remote inputs will modify mOpen.
264         ArrayList<NotificationEntry> list = new ArrayList<>(mOpen.size());
265         for (int i = mOpen.size() - 1; i >= 0; i--) {
266             NotificationEntry entry = mOpen.get(i).first.get();
267             if (entry != null && entry.rowExists()) {
268                 list.add(entry);
269             }
270         }
271 
272         for (int i = list.size() - 1; i >= 0; i--) {
273             NotificationEntry entry = list.get(i);
274             if (entry.rowExists()) {
275                 entry.closeRemoteInput();
276             }
277         }
278     }
279 
requestDisallowLongPressAndDismiss()280     public void requestDisallowLongPressAndDismiss() {
281         mDelegate.requestDisallowLongPressAndDismiss();
282     }
283 
lockScrollTo(NotificationEntry entry)284     public void lockScrollTo(NotificationEntry entry) {
285         mDelegate.lockScrollTo(entry);
286     }
287 
288     /**
289      * Create a temporary grant which allows the app that submitted the notification access to the
290      * specified URI.
291      */
grantInlineReplyUriPermission(StatusBarNotification sbn, Uri data)292     public void grantInlineReplyUriPermission(StatusBarNotification sbn, Uri data) {
293         mRemoteInputUriController.grantInlineReplyUriPermission(sbn, data);
294     }
295 
296     public interface Callback {
onRemoteInputActive(boolean active)297         default void onRemoteInputActive(boolean active) {}
298 
onRemoteInputSent(NotificationEntry entry)299         default void onRemoteInputSent(NotificationEntry entry) {}
300     }
301 
302     /**
303      * This is a delegate which implements some view controller pieces of the remote input process
304      */
305     public interface Delegate {
306         /**
307          * Activate remote input if necessary.
308          */
setRemoteInputActive(NotificationEntry entry, boolean remoteInputActive)309         void setRemoteInputActive(NotificationEntry entry, boolean remoteInputActive);
310 
311         /**
312          * Request that the view does not dismiss nor perform long press for the current touch.
313          */
requestDisallowLongPressAndDismiss()314         void requestDisallowLongPressAndDismiss();
315 
316         /**
317          * Request that the view is made visible by scrolling to it, and keep the scroll locked until
318          * the user scrolls, or {@param entry} loses focus or is detached.
319          */
lockScrollTo(NotificationEntry entry)320         void lockScrollTo(NotificationEntry entry);
321     }
322 }
323