1 /*
2  * Copyright (C) 2020 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 android.app.stubs.shared;
18 
19 import static org.junit.Assert.assertEquals;
20 import static org.junit.Assert.assertNotEquals;
21 import static org.junit.Assert.assertNotNull;
22 import static org.junit.Assert.assertTrue;
23 
24 import android.app.Instrumentation;
25 import android.app.NotificationManager;
26 import android.app.PendingIntent.CanceledException;
27 import android.app.UiAutomation;
28 import android.content.ComponentName;
29 import android.content.Context;
30 import android.os.ParcelFileDescriptor;
31 import android.service.notification.StatusBarNotification;
32 import android.util.Log;
33 
34 import androidx.test.platform.app.InstrumentationRegistry;
35 
36 import com.android.compatibility.common.util.SystemUtil;
37 
38 import com.google.common.base.Objects;
39 
40 import java.io.FileInputStream;
41 import java.io.IOException;
42 import java.io.InputStream;
43 import java.util.ArrayList;
44 import java.util.Arrays;
45 
46 public class NotificationHelper {
47 
48     private static final String TAG = NotificationHelper.class.getSimpleName();
49     public static final long SHORT_WAIT_TIME = 100;
50     public static final long MAX_WAIT_TIME = 2000;
51 
52     public enum SEARCH_TYPE {
53         /**
54          * Search for the notification only within the posted app. This returns enqueued
55          * as well as posted notifications, so use with caution.
56          */
57         APP,
58         /**
59          * Search for the notification across all apps. Makes a binder call from the NLS to
60          * check currently posted notifications for all apps, which means it can return
61          * notifications the NLS hasn't been informed about yet.
62          */
63         LISTENER,
64         /**
65          * Search for the notification across all apps. Looks only in the list of notifications
66          * that the listener has been informed about via onNotificationPosted.
67          */
68         POSTED,
69         /**
70          * Search for the notification across all apps. Looks only in the list of notifications
71          * that the listener has been informed about via onNotificationRemoved.
72          */
73         REMOVED,
74         /**
75          * Search for the notification across all apps. Looks only in the list of notifications
76          * that are snoozed by the system
77          */
78         SNOOZED,
79     }
80 
81     private final Context mContext;
82     private final NotificationManager mNotificationManager;
83     private TestNotificationListener mNotificationListener;
84     private TestNotificationAssistant mAssistant;
85 
NotificationHelper(Context context)86     public NotificationHelper(Context context) {
87         mContext = context;
88         mNotificationManager = mContext.getSystemService(NotificationManager.class);
89     }
90 
clickNotification(int notificationId, boolean searchAll)91     public void clickNotification(int notificationId, boolean searchAll) throws CanceledException {
92         findPostedNotification(null, notificationId,
93                 searchAll ? SEARCH_TYPE.LISTENER : SEARCH_TYPE.APP)
94                 .getNotification().contentIntent.send();
95     }
96 
findPostedNotification(String tag, int id, SEARCH_TYPE searchType)97     public StatusBarNotification findPostedNotification(String tag, int id,
98             SEARCH_TYPE searchType) {
99         // notification posting is asynchronous so it may take a few hundred ms to appear.
100         // we will check for it for up to MAX_WAIT_TIME ms before giving up.
101         for (long totalWait = 0; totalWait < MAX_WAIT_TIME; totalWait += SHORT_WAIT_TIME) {
102             StatusBarNotification n = findNotificationNoWait(tag, id, searchType);
103             if (n != null) {
104                 return n;
105             }
106             try {
107                 Thread.sleep(SHORT_WAIT_TIME);
108             } catch (InterruptedException ex) {
109                 // pass
110             }
111         }
112         return findNotificationNoWait(null, id, searchType);
113     }
114 
115     /**
116      * Returns true if the notification cannot be found. Polls for the notification to account for
117      * delays in posting
118      */
isNotificationGone(int id, SEARCH_TYPE searchType)119     public boolean isNotificationGone(int id, SEARCH_TYPE searchType) {
120         // notification is a bit asynchronous so it may take a few ms to appear in
121         // getActiveNotifications()
122         // we will check for it for up to MAX_WAIT_TIME ms before giving up.
123         boolean found = false;
124         for (long totalWait = 0; totalWait < MAX_WAIT_TIME; totalWait += SHORT_WAIT_TIME) {
125             // Need reset flag.
126             found = false;
127             for (StatusBarNotification sbn : getNotifications(searchType)) {
128                 Log.d(TAG, "Found " + sbn.getKey());
129                 if (sbn.getId() == id) {
130                     found = true;
131                     break;
132                 }
133             }
134             if (!found) break;
135             try {
136                 Thread.sleep(SHORT_WAIT_TIME);
137             } catch (InterruptedException ex) {
138                 // pass
139             }
140         }
141         return !found;
142     }
143 
144     /**
145      * Checks whether the NLS has received a removal event for this notification
146      */
isNotificationGone(String key)147     public boolean isNotificationGone(String key) {
148         for (long totalWait = 0; totalWait < MAX_WAIT_TIME; totalWait += SHORT_WAIT_TIME) {
149             if (mNotificationListener.mRemovedReasons.containsKey(key)) {
150                 return true;
151             }
152             try {
153                 Thread.sleep(SHORT_WAIT_TIME);
154             } catch (InterruptedException ex) {
155                 // pass
156             }
157         }
158         return false;
159     }
160 
findNotificationNoWait(String tag, int id, SEARCH_TYPE searchType)161     private StatusBarNotification findNotificationNoWait(String tag, int id,
162             SEARCH_TYPE searchType) {
163         for (StatusBarNotification sbn : getNotifications(searchType)) {
164             if (sbn.getId() == id && Objects.equal(sbn.getTag(), tag)) {
165                 return sbn;
166             }
167         }
168         return null;
169     }
170 
getNotifications(SEARCH_TYPE searchType)171     private ArrayList<StatusBarNotification> getNotifications(SEARCH_TYPE searchType) {
172         switch (searchType) {
173             case APP:
174                 return new ArrayList<>(
175                         Arrays.asList(mNotificationManager.getActiveNotifications()));
176             case POSTED:
177                 return new ArrayList(mNotificationListener.mPosted);
178             case REMOVED:
179                 return new ArrayList<>(mNotificationListener.mRemoved);
180             case SNOOZED:
181                 return new ArrayList<>(
182                         Arrays.asList(mNotificationListener.getSnoozedNotifications()));
183             case LISTENER:
184             default:
185                 return new ArrayList<>(
186                         Arrays.asList(mNotificationListener.getActiveNotifications()));
187         }
188     }
189 
enableListener(String pkg)190     public TestNotificationListener enableListener(String pkg) throws IOException {
191         String command = " cmd notification allow_listener "
192                 + pkg + "/" + TestNotificationListener.class.getName();
193         runCommand(command, InstrumentationRegistry.getInstrumentation());
194         mNotificationListener = TestNotificationListener.getInstance();
195         if (mNotificationListener != null) {
196             mNotificationListener.addTestPackage(pkg);
197         }
198         return mNotificationListener;
199     }
200 
disableListener(String pkg)201     public void disableListener(String pkg) throws IOException {
202         final ComponentName component =
203                 new ComponentName(pkg, TestNotificationListener.class.getName());
204         String command = " cmd notification disallow_listener " + component.flattenToString();
205 
206         runCommand(command, InstrumentationRegistry.getInstrumentation());
207 
208         final NotificationManager nm = mContext.getSystemService(NotificationManager.class);
209         assertEquals(component + " has incorrect listener access",
210                 false, nm.isNotificationListenerAccessGranted(component));
211     }
212 
enableAssistant(String pkg)213     public TestNotificationAssistant enableAssistant(String pkg) throws IOException {
214         final ComponentName component =
215                 new ComponentName(pkg, TestNotificationAssistant.class.getName());
216 
217         InstrumentationRegistry.getInstrumentation().getUiAutomation()
218                 .adoptShellPermissionIdentity("android.permission.STATUS_BAR_SERVICE",
219                         "android.permission.REQUEST_NOTIFICATION_ASSISTANT_SERVICE");
220         mNotificationManager.setNotificationAssistantAccessGranted(component, true);
221 
222         assertTrue(component + " has not been allowed",
223                 mNotificationManager.isNotificationAssistantAccessGranted(component));
224         assertEquals(component, mNotificationManager.getAllowedNotificationAssistant());
225 
226         mAssistant = TestNotificationAssistant.getInstance();
227 
228         InstrumentationRegistry.getInstrumentation()
229                 .getUiAutomation().dropShellPermissionIdentity();
230         return mAssistant;
231     }
232 
233     // For a NAS not owned by the test package, we need to check/enable the NAS with the shell
enableOtherPkgAssistantIfNeeded(String componentName)234     public void enableOtherPkgAssistantIfNeeded(String componentName) {
235         if (componentName == null || componentName.equals(getEnabledAssistant())) {
236             return;
237         }
238         SystemUtil.runShellCommand("cmd notification allow_assistant " + componentName);
239     }
240 
getEnabledAssistant()241     public String getEnabledAssistant() {
242         return SystemUtil.runShellCommand("cmd notification get_approved_assistant");
243     }
244 
disableAssistant(String pkg)245     public void disableAssistant(String pkg) throws IOException {
246         final ComponentName component =
247                 new ComponentName(pkg, TestNotificationAssistant.class.getName());
248 
249         InstrumentationRegistry.getInstrumentation().getUiAutomation()
250                 .adoptShellPermissionIdentity("android.permission.STATUS_BAR_SERVICE",
251                         "android.permission.REQUEST_NOTIFICATION_ASSISTANT_SERVICE");
252         mNotificationManager.setNotificationAssistantAccessGranted(component, false);
253 
254         assertTrue(component + " has not been disallowed",
255                 !mNotificationManager.isNotificationAssistantAccessGranted(component));
256         assertNotEquals(component, mNotificationManager.getAllowedNotificationAssistant());
257 
258         InstrumentationRegistry.getInstrumentation()
259                 .getUiAutomation().dropShellPermissionIdentity();
260     }
261 
262     @SuppressWarnings("StatementWithEmptyBody")
runCommand(String command, Instrumentation instrumentation)263     public void runCommand(String command, Instrumentation instrumentation)
264             throws IOException {
265         UiAutomation uiAutomation = instrumentation.getUiAutomation();
266         // Execute command
267         try (ParcelFileDescriptor fd = uiAutomation.executeShellCommand(command)) {
268             assertNotNull("Failed to execute shell command: " + command, fd);
269             // Wait for the command to finish by reading until EOF
270             try (InputStream in = new FileInputStream(fd.getFileDescriptor())) {
271                 byte[] buffer = new byte[4096];
272                 while (in.read(buffer) > 0) {
273                     // discard output
274                 }
275             } catch (IOException e) {
276                 throw new IOException("Could not read stdout of command: " + command, e);
277             }
278         }
279     }
280 }
281