blob: 96454e7e2a084b2875efeb705e3f0094b8ac0c43 [file] [log] [blame]
/*
* Copyright 2022 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.libraries.mobiledatadownload.testing;
import static com.google.common.truth.Truth.assertThat;
import android.app.Notification;
import android.app.NotificationManager;
import android.content.Context;
import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES;
import android.service.notification.StatusBarNotification;
import com.google.android.apps.common.testing.util.AndroidTestUtil;
import com.google.android.libraries.mobiledatadownload.foreground.NotificationUtil;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableList;
import com.google.common.truth.Correspondence;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.annotation.Nullable;
/** Testing Utility to capture notifications and make assertions. */
public interface MddNotificationCapture {
/**
* ADB shell command to clear notifications.
*
* <p>This command uses the notification service (i.e. NotificationManager) and calls the first
* method in that services idl definition. For our service, this refers to the method <a
* href="<internal>">cancelAllNotifications</a> in the INotificationManager.aidl.
*/
static final String CLEAR_NOTIFICATIONS_CMD = "service call notification 1";
/**
* ADB shell command to dump notification content to logcat.
*
* <p>The {@code dumpsys} command is used to dump system diagnostics. We are interested in
* notifications, so they are specified here. The {@code --noredact} flag is added to provided
* unredacted info (the actual values of variables) instead of just their type definitions. This
* flag has varying levels of support across the API levels, but should provide equivalent or
* greater information in the log dump that can be captured.
*/
static final String DUMP_NOTIFICATION_CMD = "dumpsys notification --noredact";
static final ImmutableList<Integer> MDD_ICON_IDS =
ImmutableList.of(
android.R.drawable.stat_sys_download,
android.R.drawable.stat_sys_download_done,
android.R.drawable.stat_sys_warning);
public static void clearNotifications() {
try {
AndroidTestUtil.executeShellCommand(CLEAR_NOTIFICATIONS_CMD);
} catch (IOException e) {
throw new IllegalStateException("Unable to execute shell command", e);
}
}
public static MddNotificationCapture snapshot(Context context) {
// Capturing active notifications is only available for API level 23 and above. Check to see if
// the current version supports it, otherwise fall back to using adb to capture notification
// content.
if (VERSION.SDK_INT >= VERSION_CODES.M) {
NotificationManager manager =
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
StatusBarNotification[] activeNotifications = manager.getActiveNotifications();
List<Notification> notifications = new ArrayList<>();
for (StatusBarNotification notification : activeNotifications) {
// TODO(b/148401016): Add some test to ensure only MDD notifications are included in this
// list.
notifications.add(notification.getNotification());
}
return new MPlusNotificationCapture(context, notifications);
}
try {
// NotificationManager.getActivitNotifications() is unavailable, fallback to using adb
String result = AndroidTestUtil.executeShellCommand(DUMP_NOTIFICATION_CMD);
return new PreMNotificationCapture(context, result);
} catch (IOException e) {
throw new IllegalStateException("Unable to execute shell command", e);
}
}
void assertStartNotificationCaptured(String title, String text);
void assertSuccessNotificationCaptured(String title);
void assertFailedNotificationCaptured(String title);
void assertPausedNotificationCaptured(String title);
void assertNoNotificationsCaptured();
/**
* Implementation of Notification Capture for API levels below 23 (Android M).
*
* <p>This implementation relies on content captured from adb about notifictions. The primary
* method of finding results is using Regexes to search for relevant pieces of information about
* captured notifications.
*/
public static class PreMNotificationCapture implements MddNotificationCapture {
private final Context context;
private final String notificationOutput;
private static final Pattern CONTENT_TITLE_PATTERN =
Pattern.compile("android\\.title=String\\s\\((.+)\\)");
private static final Pattern CONTENT_TEXT_PATTERN =
Pattern.compile("android\\.text=String\\s\\((.+)\\)");
private static final Pattern ICON_PATTERN = Pattern.compile("icon=0x([a-fA-F0-9]+)");
private PreMNotificationCapture(Context context, String notificationOutput) {
this.context = context;
this.notificationOutput = notificationOutput;
}
@Override
public void assertStartNotificationCaptured(String title, String text) {
assertNotificationCapturedMatches(title, text, android.R.drawable.stat_sys_download);
}
@Override
public void assertSuccessNotificationCaptured(String title) {
assertNotificationCapturedMatches(
title,
NotificationUtil.getDownloadSuccessMessage(context),
android.R.drawable.stat_sys_download_done);
}
@Override
public void assertFailedNotificationCaptured(String title) {
assertNotificationCapturedMatches(
title,
NotificationUtil.getDownloadFailedMessage(context),
android.R.drawable.stat_sys_warning);
}
@Override
public void assertPausedNotificationCaptured(String title) {
assertNotificationCapturedMatches(
title,
NotificationUtil.getDownloadPausedMessage(context),
android.R.drawable.stat_sys_download);
}
@Override
public void assertNoNotificationsCaptured() {
List<String> titleMatches = getMatching(CONTENT_TITLE_PATTERN);
List<String> textMatches = getMatching(CONTENT_TEXT_PATTERN);
List<String> iconMatches = getMatching(ICON_PATTERN);
assertThat(titleMatches).isEmpty();
assertThat(textMatches).isEmpty();
// Capturing through adb includes inactive notifications too, so just make sure none of the
// MDD notifications were capturesd.
assertThat(iconMatches)
.comparingElementsUsing(
Correspondence.<String, Integer>transforming(
match -> {
// Our regex should capture only valid hexadecimal values
int iconResId = Integer.parseInt(match, 16);
return iconResId;
},
"convert to resource id"))
.containsNoneIn(MDD_ICON_IDS);
}
// TODO(b/148401016): Remove "unused" when title/text can be matched.
private void assertNotificationCapturedMatches(
String unusedTitle, String unusedText, int icon) {
/* List<String> titleMatches = getMatching(CONTENT_TITLE_PATTERN);
* List<String> textMatches = getMatching(CONTENT_TEXT_PATTERN); */
List<String> iconMatches = getMatching(ICON_PATTERN);
// TODO(b/148401016): Figure out how to access unredacted title and text content to match.
/* assertThat(titleMatches)
* .comparingElementsUsing(
* Correspondence.<String, Boolean>transforming(
* match -> match.contains(title), "is a title match"))
* .contains(true);
* assertThat(textMatches)
* .comparingElementsUsing(
* Correspondence.<String, Boolean>transforming(
* match -> match.contains(text), "is a text match"))
* .contains(true); */
assertThat(iconMatches)
.comparingElementsUsing(
Correspondence.<String, Boolean>transforming(
match -> {
// Our regex should capture only valid hexadecimal values
int iconResId = Integer.parseInt(match, 16);
return iconResId == icon;
},
"is an icon match"))
.contains(true);
}
private List<String> getMatching(Pattern pattern) {
List<String> matches = new ArrayList<>();
Matcher matcher = pattern.matcher(notificationOutput);
while (matcher.find()) {
matches.add(matcher.group(1).trim());
}
return matches;
}
}
/**
* Implementation of Notification Capture for API level 23 (Android M) and above.
*
* <p>This implementation relies on capturing Notifications using {@link
* NotificationManager#getActiveNotifications}. Available parts of the {@link Notification} are
* used to check for matching notifications.
*/
public static class MPlusNotificationCapture implements MddNotificationCapture {
private final Context context;
private final List<Notification> notifications;
private MPlusNotificationCapture(Context context, List<Notification> notifications) {
Preconditions.checkState(
VERSION.SDK_INT >= VERSION_CODES.M, "This implementation only works for M+ devices");
this.context = context;
this.notifications = notifications;
}
@Override
public void assertStartNotificationCaptured(String title, String text) {
assertThat(notifications)
.comparingElementsUsing(
createMatcherForNotification(
title, text, android.R.drawable.stat_sys_download, "is a start notification"))
.contains(true);
}
@Override
public void assertSuccessNotificationCaptured(String title) {
assertThat(notifications)
.comparingElementsUsing(
createMatcherForNotification(
title,
NotificationUtil.getDownloadSuccessMessage(context),
android.R.drawable.stat_sys_download_done,
"is a success notification"))
.contains(true);
}
@Override
public void assertFailedNotificationCaptured(String title) {
assertThat(notifications)
.comparingElementsUsing(
createMatcherForNotification(
title,
NotificationUtil.getDownloadFailedMessage(context),
android.R.drawable.stat_sys_warning,
"is a failed notification"))
.contains(true);
}
@Override
public void assertPausedNotificationCaptured(String title) {
assertThat(notifications)
.comparingElementsUsing(
createMatcherForNotification(
title,
NotificationUtil.getDownloadPausedMessage(context),
android.R.drawable.stat_sys_download,
"is a paused notification"))
.contains(true);
}
@Override
public void assertNoNotificationsCaptured() {
assertThat(notifications).isEmpty();
}
private static Correspondence<Notification, Boolean> createMatcherForNotification(
String title, String text, int icon, String tag) {
return Correspondence.transforming(
(@Nullable Notification actual) -> {
if (actual == null) {
return false;
}
boolean matches =
String.valueOf(actual.extras.getCharSequence("android.title")).equals(title)
&& String.valueOf(actual.extras.getCharSequence("android.text")).equals(text)
&& (actual.getSmallIcon().getResId() == icon);
return matches;
},
tag);
}
}
}