• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright 2022 Google LLC
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.google.android.libraries.mobiledatadownload.testing;
17 
18 import static com.google.common.truth.Truth.assertThat;
19 
20 import android.app.Notification;
21 import android.app.NotificationManager;
22 import android.content.Context;
23 import android.os.Build.VERSION;
24 import android.os.Build.VERSION_CODES;
25 import android.service.notification.StatusBarNotification;
26 import com.google.android.apps.common.testing.util.AndroidTestUtil;
27 import com.google.android.libraries.mobiledatadownload.foreground.NotificationUtil;
28 import com.google.common.base.Preconditions;
29 import com.google.common.collect.ImmutableList;
30 import com.google.common.truth.Correspondence;
31 import java.io.IOException;
32 import java.util.ArrayList;
33 import java.util.List;
34 import java.util.regex.Matcher;
35 import java.util.regex.Pattern;
36 import javax.annotation.Nullable;
37 
38 /** Testing Utility to capture notifications and make assertions. */
39 public interface MddNotificationCapture {
40   /**
41    * ADB shell command to clear notifications.
42    *
43    * <p>This command uses the notification service (i.e. NotificationManager) and calls the first
44    * method in that services idl definition. For our service, this refers to the method <a
45    * href="<internal>">cancelAllNotifications</a> in the INotificationManager.aidl.
46    */
47   static final String CLEAR_NOTIFICATIONS_CMD = "service call notification 1";
48 
49   /**
50    * ADB shell command to dump notification content to logcat.
51    *
52    * <p>The {@code dumpsys} command is used to dump system diagnostics. We are interested in
53    * notifications, so they are specified here. The {@code --noredact} flag is added to provided
54    * unredacted info (the actual values of variables) instead of just their type definitions. This
55    * flag has varying levels of support across the API levels, but should provide equivalent or
56    * greater information in the log dump that can be captured.
57    */
58   static final String DUMP_NOTIFICATION_CMD = "dumpsys notification --noredact";
59 
60   static final ImmutableList<Integer> MDD_ICON_IDS =
61       ImmutableList.of(
62           android.R.drawable.stat_sys_download,
63           android.R.drawable.stat_sys_download_done,
64           android.R.drawable.stat_sys_warning);
65 
clearNotifications()66   public static void clearNotifications() {
67     try {
68       AndroidTestUtil.executeShellCommand(CLEAR_NOTIFICATIONS_CMD);
69     } catch (IOException e) {
70       throw new IllegalStateException("Unable to execute shell command", e);
71     }
72   }
73 
snapshot(Context context)74   public static MddNotificationCapture snapshot(Context context) {
75     // Capturing active notifications is only available for API level 23 and above. Check to see if
76     // the current version supports it, otherwise fall back to using adb to capture notification
77     // content.
78     if (VERSION.SDK_INT >= VERSION_CODES.M) {
79       NotificationManager manager =
80           (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
81       StatusBarNotification[] activeNotifications = manager.getActiveNotifications();
82       List<Notification> notifications = new ArrayList<>();
83       for (StatusBarNotification notification : activeNotifications) {
84         // TODO(b/148401016): Add some test to ensure only MDD notifications are included in this
85         // list.
86         notifications.add(notification.getNotification());
87       }
88       return new MPlusNotificationCapture(context, notifications);
89     }
90     try {
91       // NotificationManager.getActivitNotifications() is unavailable, fallback to using adb
92       String result = AndroidTestUtil.executeShellCommand(DUMP_NOTIFICATION_CMD);
93       return new PreMNotificationCapture(context, result);
94     } catch (IOException e) {
95       throw new IllegalStateException("Unable to execute shell command", e);
96     }
97   }
98 
assertStartNotificationCaptured(String title, String text)99   void assertStartNotificationCaptured(String title, String text);
100 
assertSuccessNotificationCaptured(String title)101   void assertSuccessNotificationCaptured(String title);
102 
assertFailedNotificationCaptured(String title)103   void assertFailedNotificationCaptured(String title);
104 
assertPausedNotificationCaptured(String title, boolean wifiOnly)105   void assertPausedNotificationCaptured(String title, boolean wifiOnly);
106 
assertNoNotificationsCaptured()107   void assertNoNotificationsCaptured();
108 
109   /**
110    * Implementation of Notification Capture for API levels below 23 (Android M).
111    *
112    * <p>This implementation relies on content captured from adb about notifictions. The primary
113    * method of finding results is using Regexes to search for relevant pieces of information about
114    * captured notifications.
115    */
116   public static class PreMNotificationCapture implements MddNotificationCapture {
117     private final Context context;
118     private final String notificationOutput;
119 
120     private static final Pattern CONTENT_TITLE_PATTERN =
121         Pattern.compile("android\\.title=String\\s\\((.+)\\)");
122     private static final Pattern CONTENT_TEXT_PATTERN =
123         Pattern.compile("android\\.text=String\\s\\((.+)\\)");
124     private static final Pattern ICON_PATTERN = Pattern.compile("icon=0x([a-fA-F0-9]+)");
125 
PreMNotificationCapture(Context context, String notificationOutput)126     private PreMNotificationCapture(Context context, String notificationOutput) {
127       this.context = context;
128       this.notificationOutput = notificationOutput;
129     }
130 
131     @Override
assertStartNotificationCaptured(String title, String text)132     public void assertStartNotificationCaptured(String title, String text) {
133       assertNotificationCapturedMatches(title, text, android.R.drawable.stat_sys_download);
134     }
135 
136     @Override
assertSuccessNotificationCaptured(String title)137     public void assertSuccessNotificationCaptured(String title) {
138       assertNotificationCapturedMatches(
139           title,
140           NotificationUtil.getDownloadSuccessMessage(context),
141           android.R.drawable.stat_sys_download_done);
142     }
143 
144     @Override
assertFailedNotificationCaptured(String title)145     public void assertFailedNotificationCaptured(String title) {
146       assertNotificationCapturedMatches(
147           title,
148           NotificationUtil.getDownloadFailedMessage(context),
149           android.R.drawable.stat_sys_warning);
150     }
151 
152     @Override
assertPausedNotificationCaptured(String title, boolean wifiOnly)153     public void assertPausedNotificationCaptured(String title, boolean wifiOnly) {
154       assertNotificationCapturedMatches(
155           title,
156           wifiOnly
157               ? NotificationUtil.getDownloadPausedWifiMessage(context)
158               : NotificationUtil.getDownloadPausedMessage(context),
159           android.R.drawable.stat_sys_download);
160     }
161 
162     @Override
assertNoNotificationsCaptured()163     public void assertNoNotificationsCaptured() {
164       List<String> titleMatches = getMatching(CONTENT_TITLE_PATTERN);
165       List<String> textMatches = getMatching(CONTENT_TEXT_PATTERN);
166       List<String> iconMatches = getMatching(ICON_PATTERN);
167 
168       assertThat(titleMatches).isEmpty();
169       assertThat(textMatches).isEmpty();
170 
171       // Capturing through adb includes inactive notifications too, so just make sure none of the
172       // MDD notifications were capturesd.
173       assertThat(iconMatches)
174           .comparingElementsUsing(
175               Correspondence.<String, Integer>transforming(
176                   match ->
177                       // Our regex should capture only valid hexadecimal values
178                       Integer.parseInt(match, 16),
179                   "convert to resource id"))
180           .containsNoneIn(MDD_ICON_IDS);
181     }
182 
183     // TODO(b/148401016): Remove "unused" when title/text can be matched.
assertNotificationCapturedMatches( String unusedTitle, String unusedText, int icon)184     private void assertNotificationCapturedMatches(
185         String unusedTitle, String unusedText, int icon) {
186       /* List<String> titleMatches = getMatching(CONTENT_TITLE_PATTERN);
187        * List<String> textMatches = getMatching(CONTENT_TEXT_PATTERN); */
188       List<String> iconMatches = getMatching(ICON_PATTERN);
189 
190       // TODO(b/148401016): Figure out how to access unredacted title and text content to match.
191       /* assertThat(titleMatches)
192        *     .comparingElementsUsing(
193        *         Correspondence.<String, Boolean>transforming(
194        *             match -> match.contains(title), "is a title match"))
195        *     .contains(true);
196        * assertThat(textMatches)
197        *     .comparingElementsUsing(
198        *         Correspondence.<String, Boolean>transforming(
199        *             match -> match.contains(text), "is a text match"))
200        *     .contains(true); */
201       assertThat(iconMatches)
202           .comparingElementsUsing(
203               Correspondence.<String, Boolean>transforming(
204                   match -> {
205                     // Our regex should capture only valid hexadecimal values
206                     int iconResId = Integer.parseInt(match, 16);
207                     return iconResId == icon;
208                   },
209                   "is an icon match"))
210           .contains(true);
211     }
212 
getMatching(Pattern pattern)213     private List<String> getMatching(Pattern pattern) {
214       List<String> matches = new ArrayList<>();
215       Matcher matcher = pattern.matcher(notificationOutput);
216       while (matcher.find()) {
217         matches.add(matcher.group(1).trim());
218       }
219       return matches;
220     }
221   }
222 
223   /**
224    * Implementation of Notification Capture for API level 23 (Android M) and above.
225    *
226    * <p>This implementation relies on capturing Notifications using {@link
227    * NotificationManager#getActiveNotifications}. Available parts of the {@link Notification} are
228    * used to check for matching notifications.
229    */
230   public static class MPlusNotificationCapture implements MddNotificationCapture {
231     private final Context context;
232     private final List<Notification> notifications;
233 
MPlusNotificationCapture(Context context, List<Notification> notifications)234     private MPlusNotificationCapture(Context context, List<Notification> notifications) {
235       Preconditions.checkState(
236           VERSION.SDK_INT >= VERSION_CODES.M, "This implementation only works for M+ devices");
237       this.context = context;
238       this.notifications = notifications;
239     }
240 
241     @Override
assertStartNotificationCaptured(String title, String text)242     public void assertStartNotificationCaptured(String title, String text) {
243       assertThat(notifications)
244           .comparingElementsUsing(
245               createMatcherForNotification(
246                   title, text, android.R.drawable.stat_sys_download, "is a start notification"))
247           .contains(true);
248     }
249 
250     @Override
assertSuccessNotificationCaptured(String title)251     public void assertSuccessNotificationCaptured(String title) {
252       assertThat(notifications)
253           .comparingElementsUsing(
254               createMatcherForNotification(
255                   title,
256                   NotificationUtil.getDownloadSuccessMessage(context),
257                   android.R.drawable.stat_sys_download_done,
258                   "is a success notification"))
259           .contains(true);
260     }
261 
262     @Override
assertFailedNotificationCaptured(String title)263     public void assertFailedNotificationCaptured(String title) {
264       assertThat(notifications)
265           .comparingElementsUsing(
266               createMatcherForNotification(
267                   title,
268                   NotificationUtil.getDownloadFailedMessage(context),
269                   android.R.drawable.stat_sys_warning,
270                   "is a failed notification"))
271           .contains(true);
272     }
273 
274     @Override
assertPausedNotificationCaptured(String title, boolean wifiOnly)275     public void assertPausedNotificationCaptured(String title, boolean wifiOnly) {
276       assertThat(notifications)
277           .comparingElementsUsing(
278               createMatcherForNotification(
279                   title,
280                   wifiOnly
281                       ? NotificationUtil.getDownloadPausedWifiMessage(context)
282                       : NotificationUtil.getDownloadPausedMessage(context),
283                   android.R.drawable.stat_sys_download,
284                   "is a paused notification"))
285           .contains(true);
286     }
287 
288     @Override
assertNoNotificationsCaptured()289     public void assertNoNotificationsCaptured() {
290       assertThat(notifications).isEmpty();
291     }
292 
createMatcherForNotification( String title, String text, int icon, String tag)293     private static Correspondence<Notification, Boolean> createMatcherForNotification(
294         String title, String text, int icon, String tag) {
295       return Correspondence.transforming(
296           (@Nullable Notification actual) -> {
297             if (actual == null) {
298               return false;
299             }
300 
301             boolean matches =
302                 String.valueOf(actual.extras.getCharSequence("android.title")).equals(title)
303                     && String.valueOf(actual.extras.getCharSequence("android.text")).equals(text)
304                     && (actual.getSmallIcon().getResId() == icon);
305             return matches;
306           },
307           tag);
308     }
309   }
310 }
311