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