diff --git a/res/values/strings.xml b/res/values/strings.xml
index 080f97077f6..6100771533c 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -10524,4 +10524,14 @@
See all usage
+
+ Manage %1$s Notifications
+
+ No suggested application
+
+
+ - %1$d notification channel. Tap to manage all.
+ - %1$d notification channels. Tap to manage all.
+
+
diff --git a/src/com/android/settings/homepage/contextualcards/ContextualCardLoader.java b/src/com/android/settings/homepage/contextualcards/ContextualCardLoader.java
index 49e2a7697f6..88c489b4d7f 100644
--- a/src/com/android/settings/homepage/contextualcards/ContextualCardLoader.java
+++ b/src/com/android/settings/homepage/contextualcards/ContextualCardLoader.java
@@ -22,6 +22,7 @@ import static androidx.slice.widget.SliceLiveData.SUPPORTED_SPECS;
import static com.android.settings.slices.CustomSliceRegistry.BLUETOOTH_DEVICES_SLICE_URI;
import static com.android.settings.slices.CustomSliceRegistry.CONTEXTUAL_WIFI_SLICE_URI;
+import static com.android.settings.slices.CustomSliceRegistry.NOTIFICATION_CHANNEL_SLICE_URI;
import android.content.ContentProviderClient;
import android.content.ContentResolver;
@@ -205,7 +206,8 @@ public class ContextualCardLoader extends AsyncLoaderCompat
private boolean isLargeCard(ContextualCard card) {
return card.getSliceUri().equals(CONTEXTUAL_WIFI_SLICE_URI)
- || card.getSliceUri().equals(BLUETOOTH_DEVICES_SLICE_URI);
+ || card.getSliceUri().equals(BLUETOOTH_DEVICES_SLICE_URI)
+ || card.getSliceUri().equals(NOTIFICATION_CHANNEL_SLICE_URI);
}
public interface CardContentLoaderListener {
diff --git a/src/com/android/settings/homepage/contextualcards/SettingsContextualCardProvider.java b/src/com/android/settings/homepage/contextualcards/SettingsContextualCardProvider.java
index 57970433150..7bbe430e4b7 100644
--- a/src/com/android/settings/homepage/contextualcards/SettingsContextualCardProvider.java
+++ b/src/com/android/settings/homepage/contextualcards/SettingsContextualCardProvider.java
@@ -56,11 +56,18 @@ public class SettingsContextualCardProvider extends ContextualCardProvider {
.setCardName(CustomSliceRegistry.BATTERY_FIX_SLICE_URI.toString())
.setCardCategory(ContextualCard.Category.IMPORTANT)
.build();
+ final ContextualCard notificationChannelCard =
+ ContextualCard.newBuilder()
+ .setSliceUri(CustomSliceRegistry.NOTIFICATION_CHANNEL_SLICE_URI.toString())
+ .setCardName(CustomSliceRegistry.NOTIFICATION_CHANNEL_SLICE_URI.toString())
+ .setCardCategory(ContextualCard.Category.POSSIBLE)
+ .build();
final ContextualCardList cards = ContextualCardList.newBuilder()
.addCard(wifiCard)
.addCard(connectedDeviceCard)
.addCard(lowStorageCard)
.addCard(batteryFixCard)
+ .addCard(notificationChannelCard)
.build();
return cards;
diff --git a/src/com/android/settings/homepage/contextualcards/slices/NotificationChannelSlice.java b/src/com/android/settings/homepage/contextualcards/slices/NotificationChannelSlice.java
new file mode 100644
index 00000000000..ca5bbec2aef
--- /dev/null
+++ b/src/com/android/settings/homepage/contextualcards/slices/NotificationChannelSlice.java
@@ -0,0 +1,474 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * 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.android.settings.homepage.contextualcards.slices;
+
+import static android.app.NotificationManager.IMPORTANCE_LOW;
+import static android.app.NotificationManager.IMPORTANCE_NONE;
+import static android.app.slice.Slice.EXTRA_TOGGLE_STATE;
+
+import static com.android.settings.notification.NotificationSettingsBase.ARG_FROM_SETTINGS;
+
+import android.app.Application;
+import android.app.NotificationChannel;
+import android.app.NotificationChannelGroup;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.UserHandle;
+import android.provider.Settings;
+import android.text.TextUtils;
+import android.util.Log;
+
+import androidx.core.graphics.drawable.IconCompat;
+import androidx.slice.Slice;
+import androidx.slice.builders.ListBuilder;
+import androidx.slice.builders.SliceAction;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.logging.nano.MetricsProto;
+import com.android.settings.R;
+import com.android.settings.SubSettings;
+import com.android.settings.Utils;
+import com.android.settings.applications.AppAndNotificationDashboardFragment;
+import com.android.settings.applications.AppInfoBase;
+import com.android.settings.core.SubSettingLauncher;
+import com.android.settings.notification.AppNotificationSettings;
+import com.android.settings.notification.ChannelNotificationSettings;
+import com.android.settings.notification.NotificationBackend;
+import com.android.settings.slices.CustomSliceRegistry;
+import com.android.settings.slices.CustomSliceable;
+import com.android.settings.slices.SliceBroadcastReceiver;
+import com.android.settings.slices.SliceBuilderUtils;
+import com.android.settingslib.RestrictedLockUtils;
+import com.android.settingslib.RestrictedLockUtilsInternal;
+import com.android.settingslib.applications.ApplicationsState;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+
+public class NotificationChannelSlice implements CustomSliceable {
+
+ /**
+ * Recently app condition:
+ * App was installed between 3 and 7 days ago.
+ */
+ @VisibleForTesting
+ static final long DURATION_START_DAYS = TimeUnit.DAYS.toMillis(7);
+ @VisibleForTesting
+ static final long DURATION_END_DAYS = TimeUnit.DAYS.toMillis(3);
+
+ /**
+ * Notification count condition:
+ * App has sent at least ~10 notifications.
+ */
+ @VisibleForTesting
+ static final int MIN_NOTIFICATION_SENT_COUNT = 10;
+
+ /**
+ * Limit rows when the number of notification channel is more than {@link
+ * #DEFAULT_EXPANDED_ROW_COUNT}.
+ */
+ @VisibleForTesting
+ static final int DEFAULT_EXPANDED_ROW_COUNT = 3;
+
+ private static final String TAG = "NotifChannelSlice";
+ private static final String PACKAGE_NAME = "package_name";
+ private static final String PACKAGE_UID = "package_uid";
+ private static final String CHANNEL_ID = "channel_id";
+
+ /**
+ * TODO(b/119831690): Change to notification count sorting.
+ * This is the default sorting from NotificationSettingsBase, will be replaced with notification
+ * count sorting mechanism.
+ */
+ private static final Comparator mChannelComparator =
+ (left, right) -> {
+ if (TextUtils.equals(left.getId(), NotificationChannel.DEFAULT_CHANNEL_ID)) {
+ // Uncategorized/miscellaneous legacy channel goes last
+ return 1;
+ } else if (TextUtils.equals(right.getId(),
+ NotificationChannel.DEFAULT_CHANNEL_ID)) {
+ return -1;
+ }
+
+ return left.getId().compareTo(right.getId());
+ };
+
+ private final Context mContext;
+ @VisibleForTesting
+ NotificationBackend mNotificationBackend;
+ private String mPackageName;
+ private int mUid;
+
+ public NotificationChannelSlice(Context context) {
+ mContext = context;
+ mNotificationBackend = new NotificationBackend();
+ }
+
+ private static Bitmap drawableToBitmap(Drawable drawable) {
+ if (drawable instanceof BitmapDrawable) {
+ return ((BitmapDrawable) drawable).getBitmap();
+ }
+
+ final Bitmap bitmap = Bitmap.createBitmap(drawable.getIntrinsicWidth(),
+ drawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888);
+ final Canvas canvas = new Canvas(bitmap);
+ drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
+ drawable.draw(canvas);
+
+ return bitmap;
+ }
+
+ @Override
+ public Slice getSlice() {
+ final ListBuilder listBuilder =
+ new ListBuilder(mContext, getUri(), ListBuilder.INFINITY).setAccentColor(-1);
+ /**
+ * Get package which is satisfied with:
+ * 1. Recently installed.
+ * 2. Multiple channels.
+ * 3. Sent at least ~10 notifications.
+ */
+ // TODO(b/123065955): Review latency of NotificationChannelSlice
+ final List multiChannelPackages = getMultiChannelPackages(
+ getRecentlyInstalledPackages());
+ final PackageInfo packageInfo = getMaxSentNotificationsPackage(multiChannelPackages);
+
+ // Return a header with IsError flag, if package is not found.
+ if (packageInfo == null) {
+ return listBuilder.setHeader(getNoSuggestedAppHeader())
+ .setIsError(true).build();
+ }
+
+ // Save eligible package name and its uid, they will be used in getIntent().
+ mPackageName = packageInfo.packageName;
+ mUid = getApplicationUid(mPackageName);
+
+ // Add notification channel header.
+ final IconCompat icon = getApplicationIcon(mPackageName);
+ final CharSequence title = mContext.getString(R.string.manage_app_notification,
+ Utils.getApplicationLabel(mContext, mPackageName));
+ listBuilder.addRow(new ListBuilder.RowBuilder()
+ .setTitleItem(icon, ListBuilder.ICON_IMAGE)
+ .setTitle(title)
+ .setSubtitle(getSubTitle(mPackageName, mUid))
+ .setPrimaryAction(getPrimarySliceAction(icon, title, getIntent())));
+
+ // Get rows by notification channel.
+ final List rows = getNotificationChannelRows(packageInfo, icon);
+
+ // Get displayable notification channel count.
+ final int channelCount = Math.min(rows.size(), DEFAULT_EXPANDED_ROW_COUNT);
+
+ // According to the displayable channel count to add rows.
+ for (int i = 0; i < channelCount; i++) {
+ listBuilder.addRow(rows.get(i));
+ }
+
+ return listBuilder.build();
+ }
+
+ @Override
+ public Uri getUri() {
+ return CustomSliceRegistry.NOTIFICATION_CHANNEL_SLICE_URI;
+ }
+
+ @Override
+ public void onNotifyChange(Intent intent) {
+ final boolean newState = intent.getBooleanExtra(EXTRA_TOGGLE_STATE, false);
+ final String packageName = intent.getStringExtra(PACKAGE_NAME);
+ final int uid = intent.getIntExtra(PACKAGE_UID, -1);
+ final String channelId = intent.getStringExtra(CHANNEL_ID);
+ final PackageInfo packageInfo = getPackageInfo(packageName);
+ final NotificationBackend.AppRow appRow = mNotificationBackend.loadAppRow(mContext,
+ mContext.getPackageManager(), packageInfo);
+
+ final List notificationChannels = getEnabledChannels(packageName, uid,
+ appRow);
+ for (NotificationChannel channel : notificationChannels) {
+ if (TextUtils.equals(channel.getId(), channelId)) {
+ final int importance = newState ? IMPORTANCE_LOW : IMPORTANCE_NONE;
+ channel.setImportance(importance);
+ channel.lockFields(NotificationChannel.USER_LOCKED_IMPORTANCE);
+ mNotificationBackend.updateChannel(packageName, uid, channel);
+ return;
+ }
+ }
+ }
+
+ @Override
+ public Intent getIntent() {
+ final Bundle args = new Bundle();
+ args.putString(AppInfoBase.ARG_PACKAGE_NAME, mPackageName);
+ args.putInt(AppInfoBase.ARG_PACKAGE_UID, mUid);
+
+ return new SubSettingLauncher(mContext)
+ .setDestination(AppNotificationSettings.class.getName())
+ .setTitleRes(R.string.notifications_title)
+ .setArguments(args)
+ .setSourceMetricsCategory(MetricsProto.MetricsEvent.SLICE)
+ .toIntent();
+ }
+
+ @VisibleForTesting
+ IconCompat getApplicationIcon(String packageName) {
+ final Drawable drawable;
+ try {
+ drawable = mContext.getPackageManager().getApplicationIcon(packageName);
+ } catch (PackageManager.NameNotFoundException e) {
+ Log.w(TAG, "No such package to get application icon.");
+ return null;
+ }
+
+ return IconCompat.createWithBitmap(drawableToBitmap(drawable));
+ }
+
+ @VisibleForTesting
+ int getApplicationUid(String packageName) {
+ final ApplicationsState.AppEntry appEntry =
+ ApplicationsState.getInstance((Application) mContext.getApplicationContext())
+ .getEntry(packageName, UserHandle.myUserId());
+
+ return appEntry.info.uid;
+ }
+
+ private SliceAction buildRowSliceAction(NotificationChannel channel, IconCompat icon) {
+ final Bundle channelArgs = new Bundle();
+ channelArgs.putInt(AppInfoBase.ARG_PACKAGE_UID, mUid);
+ channelArgs.putString(AppInfoBase.ARG_PACKAGE_NAME, mPackageName);
+ channelArgs.putString(Settings.EXTRA_CHANNEL_ID, channel.getId());
+ channelArgs.putBoolean(ARG_FROM_SETTINGS, true);
+
+ final Intent channelIntent = new SubSettingLauncher(mContext)
+ .setDestination(ChannelNotificationSettings.class.getName())
+ .setArguments(channelArgs)
+ .setTitleRes(R.string.notification_channel_title)
+ .setSourceMetricsCategory(MetricsProto.MetricsEvent.SLICE)
+ .toIntent();
+
+ return SliceAction.createDeeplink(
+ PendingIntent.getActivity(mContext, channel.hashCode(), channelIntent, 0), icon,
+ ListBuilder.ICON_IMAGE, channel.getName());
+ }
+
+ private ListBuilder.HeaderBuilder getNoSuggestedAppHeader() {
+ final IconCompat icon = IconCompat.createWithResource(mContext,
+ R.drawable.ic_homepage_apps);
+ final CharSequence titleNoSuggestedApp = mContext.getString(R.string.no_suggested_app);
+ final SliceAction primarySliceActionForNoSuggestedApp = getPrimarySliceAction(icon,
+ titleNoSuggestedApp, getAppAndNotificationPageIntent());
+
+ return new ListBuilder.HeaderBuilder()
+ .setTitle(titleNoSuggestedApp)
+ .setPrimaryAction(primarySliceActionForNoSuggestedApp);
+ }
+
+ private List getNotificationChannelRows(PackageInfo packageInfo,
+ IconCompat icon) {
+ final List notificationChannelRows = new ArrayList<>();
+ final NotificationBackend.AppRow appRow = mNotificationBackend.loadAppRow(mContext,
+ mContext.getPackageManager(), packageInfo);
+ final List enabledChannels = getEnabledChannels(mPackageName, mUid,
+ appRow);
+
+ for (NotificationChannel channel : enabledChannels) {
+ notificationChannelRows.add(new ListBuilder.RowBuilder()
+ .setTitle(channel.getName())
+ .setSubtitle(NotificationBackend.getSentSummary(
+ mContext, appRow.sentByChannel.get(channel.getId()), false))
+ .setPrimaryAction(buildRowSliceAction(channel, icon))
+ .addEndItem(SliceAction.createToggle(getToggleIntent(channel.getId()),
+ null /* actionTitle */, channel.getImportance() != IMPORTANCE_NONE)));
+ }
+
+ return notificationChannelRows;
+ }
+
+ private PendingIntent getToggleIntent(String channelId) {
+ // Send broadcast to enable/disable channel.
+ final Intent intent = new Intent(getUri().toString())
+ .setClass(mContext, SliceBroadcastReceiver.class)
+ .putExtra(PACKAGE_NAME, mPackageName)
+ .putExtra(PACKAGE_UID, mUid)
+ .putExtra(CHANNEL_ID, channelId);
+
+ return PendingIntent.getBroadcast(mContext, intent.hashCode(), intent, 0);
+ }
+
+ private List getMultiChannelPackages(List packageInfoList) {
+ final List multiChannelPackages = new ArrayList<>();
+
+ if (packageInfoList.isEmpty()) {
+ return multiChannelPackages;
+ }
+
+ for (PackageInfo packageInfo : packageInfoList) {
+ final int channelCount = mNotificationBackend.getChannelCount(packageInfo.packageName,
+ getApplicationUid(packageInfo.packageName));
+ if (channelCount > 1) {
+ multiChannelPackages.add(packageInfo);
+ }
+ }
+
+ // TODO(b/119831690): Filter the packages which doesn't have any configurable channel.
+ return multiChannelPackages;
+ }
+
+ private List getRecentlyInstalledPackages() {
+ final long startTime = System.currentTimeMillis() - DURATION_START_DAYS;
+ final long endTime = System.currentTimeMillis() - DURATION_END_DAYS;
+
+ // Get recently installed packages between 3 and 7 days ago.
+ final List recentlyInstalledPackages = new ArrayList<>();
+ final List installedPackages =
+ mContext.getPackageManager().getInstalledPackages(0);
+ for (PackageInfo packageInfo : installedPackages) {
+ // Not include system app.
+ if (packageInfo.applicationInfo.isSystemApp()) {
+ continue;
+ }
+
+ if (packageInfo.firstInstallTime >= startTime
+ && packageInfo.firstInstallTime <= endTime) {
+ recentlyInstalledPackages.add(packageInfo);
+ }
+ }
+
+ return recentlyInstalledPackages;
+ }
+
+ private SliceAction getPrimarySliceAction(IconCompat icon, CharSequence title, Intent intent) {
+ return SliceAction.createDeeplink(
+ PendingIntent.getActivity(mContext, intent.hashCode(), intent, 0),
+ icon,
+ ListBuilder.ICON_IMAGE,
+ title);
+ }
+
+ private List getEnabledChannels(String packageName, int uid,
+ NotificationBackend.AppRow appRow) {
+ final List channelGroupList =
+ mNotificationBackend.getGroups(packageName, uid).getList();
+ final List channels = channelGroupList.stream()
+ .flatMap(group -> group.getChannels().stream().filter(
+ channel -> isChannelEnabled(group, channel, appRow)))
+ .collect(Collectors.toList());
+
+ // TODO(b/119831690): Sort the channels by notification count.
+ Collections.sort(channels, mChannelComparator);
+ return channels;
+ }
+
+ private PackageInfo getMaxSentNotificationsPackage(List packageInfoList) {
+ if (packageInfoList.isEmpty()) {
+ return null;
+ }
+
+ // Get the package which has sent at least ~10 notifications and not turn off channels.
+ int maxSentCount = 0;
+ PackageInfo maxSentCountPackage = null;
+ for (PackageInfo packageInfo : packageInfoList) {
+ final NotificationBackend.AppRow appRow = mNotificationBackend.loadAppRow(mContext,
+ mContext.getPackageManager(), packageInfo);
+ // Get sent notification count from app.
+ final int sentCount = appRow.sentByApp.sentCount;
+ if (!appRow.banned && sentCount >= MIN_NOTIFICATION_SENT_COUNT
+ && sentCount > maxSentCount) {
+ maxSentCount = sentCount;
+ maxSentCountPackage = packageInfo;
+ }
+ }
+
+ return maxSentCountPackage;
+ }
+
+ private CharSequence getSubTitle(String packageName, int uid) {
+ final int channelCount = mNotificationBackend.getChannelCount(packageName, uid);
+
+ return mContext.getResources().getQuantityString(
+ R.plurals.notification_channel_count_summary,
+ channelCount, channelCount);
+ }
+
+ private Intent getAppAndNotificationPageIntent() {
+ final String screenTitle = mContext.getText(R.string.app_and_notification_dashboard_title)
+ .toString();
+
+ return SliceBuilderUtils.buildSearchResultPageIntent(mContext,
+ AppAndNotificationDashboardFragment.class.getName(), "" /* key */,
+ screenTitle,
+ MetricsProto.MetricsEvent.SLICE)
+ .setClassName(mContext.getPackageName(), SubSettings.class.getName())
+ .setData(getUri());
+ }
+
+ private PackageInfo getPackageInfo(String packageName) {
+ try {
+ return mContext.getPackageManager().getPackageInfo(packageName, 0);
+ } catch (PackageManager.NameNotFoundException e) {
+ Log.w(TAG, "No such package to get package info.");
+ return null;
+ }
+ }
+
+ private boolean isChannelEnabled(NotificationChannelGroup group, NotificationChannel channel,
+ NotificationBackend.AppRow appRow) {
+ final RestrictedLockUtils.EnforcedAdmin suspendedAppsAdmin =
+ RestrictedLockUtilsInternal.checkIfApplicationIsSuspended(mContext, mPackageName,
+ mUid);
+
+ return suspendedAppsAdmin == null
+ && isChannelBlockable(channel, appRow)
+ && isChannelConfigurable(channel, appRow)
+ && !group.isBlocked();
+ }
+
+ private boolean isChannelConfigurable(NotificationChannel channel,
+ NotificationBackend.AppRow appRow) {
+ if (channel != null && appRow != null) {
+ return !TextUtils.equals(channel.getId(), appRow.lockedChannelId);
+ }
+
+ return false;
+ }
+
+ private boolean isChannelBlockable(NotificationChannel channel,
+ NotificationBackend.AppRow appRow) {
+ if (channel != null && appRow != null) {
+ if (!appRow.systemApp) {
+ return true;
+ }
+
+ return channel.isBlockableSystem()
+ || channel.getImportance() == IMPORTANCE_NONE;
+ }
+
+ return false;
+ }
+}
\ No newline at end of file
diff --git a/src/com/android/settings/notification/NotificationSettingsBase.java b/src/com/android/settings/notification/NotificationSettingsBase.java
index 0b79e20fd78..19aeb51f340 100644
--- a/src/com/android/settings/notification/NotificationSettingsBase.java
+++ b/src/com/android/settings/notification/NotificationSettingsBase.java
@@ -59,7 +59,7 @@ import java.util.List;
abstract public class NotificationSettingsBase extends DashboardFragment {
private static final String TAG = "NotifiSettingsBase";
private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
- protected static final String ARG_FROM_SETTINGS = "fromSettings";
+ public static final String ARG_FROM_SETTINGS = "fromSettings";
protected PackageManager mPm;
protected NotificationBackend mBackend = new NotificationBackend();
diff --git a/src/com/android/settings/slices/CustomSliceManager.java b/src/com/android/settings/slices/CustomSliceManager.java
index 24ee680b7c7..8d07276fab7 100644
--- a/src/com/android/settings/slices/CustomSliceManager.java
+++ b/src/com/android/settings/slices/CustomSliceManager.java
@@ -31,6 +31,7 @@ import com.android.settings.homepage.contextualcards.deviceinfo.StorageSlice;
import com.android.settings.homepage.contextualcards.slices.BatteryFixSlice;
import com.android.settings.homepage.contextualcards.slices.BluetoothDevicesSlice;
import com.android.settings.homepage.contextualcards.slices.LowStorageSlice;
+import com.android.settings.homepage.contextualcards.slices.NotificationChannelSlice;
import com.android.settings.location.LocationSlice;
import com.android.settings.wifi.slice.ContextualWifiSlice;
import com.android.settings.wifi.slice.WifiSlice;
@@ -115,6 +116,8 @@ public class CustomSliceManager {
mUriMap.put(CustomSliceRegistry.FLASHLIGHT_SLICE_URI, FlashlightSlice.class);
mUriMap.put(CustomSliceRegistry.LOCATION_SLICE_URI, LocationSlice.class);
mUriMap.put(CustomSliceRegistry.LOW_STORAGE_SLICE_URI, LowStorageSlice.class);
+ mUriMap.put(CustomSliceRegistry.NOTIFICATION_CHANNEL_SLICE_URI,
+ NotificationChannelSlice.class);
mUriMap.put(CustomSliceRegistry.STORAGE_SLICE_URI, StorageSlice.class);
mUriMap.put(CustomSliceRegistry.WIFI_SLICE_URI, WifiSlice.class);
}
diff --git a/src/com/android/settings/slices/CustomSliceRegistry.java b/src/com/android/settings/slices/CustomSliceRegistry.java
index bdf8b35ebda..e842cb97857 100644
--- a/src/com/android/settings/slices/CustomSliceRegistry.java
+++ b/src/com/android/settings/slices/CustomSliceRegistry.java
@@ -154,6 +154,15 @@ public class CustomSliceRegistry {
.appendEncodedPath(SettingsSlicesContract.PATH_SETTING_INTENT)
.appendPath("low_storage")
.build();
+ /**
+ * Backing Uri for Notification channel Slice.
+ */
+ public static final Uri NOTIFICATION_CHANNEL_SLICE_URI = new Uri.Builder()
+ .scheme(ContentResolver.SCHEME_CONTENT)
+ .authority(SettingsSliceProvider.SLICE_AUTHORITY)
+ .appendPath(SettingsSlicesContract.PATH_SETTING_ACTION)
+ .appendPath("notification_channel")
+ .build();
/**
* Backing Uri for the storage slice.
*/
diff --git a/tests/robotests/src/com/android/settings/homepage/contextualcards/slices/NotificationChannelSliceTest.java b/tests/robotests/src/com/android/settings/homepage/contextualcards/slices/NotificationChannelSliceTest.java
new file mode 100644
index 00000000000..f46570212a5
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/homepage/contextualcards/slices/NotificationChannelSliceTest.java
@@ -0,0 +1,278 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * 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.android.settings.homepage.contextualcards.slices;
+
+import static android.app.NotificationManager.IMPORTANCE_NONE;
+import static android.app.slice.Slice.HINT_LIST_ITEM;
+import static android.app.slice.SliceItem.FORMAT_SLICE;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.when;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.NotificationChannel;
+import android.app.NotificationChannelGroup;
+import android.content.Context;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.ParceledListSlice;
+import android.util.ArrayMap;
+
+import androidx.core.graphics.drawable.IconCompat;
+import androidx.slice.Slice;
+import androidx.slice.SliceMetadata;
+import androidx.slice.SliceProvider;
+import androidx.slice.core.SliceQuery;
+import androidx.slice.widget.SliceLiveData;
+
+import com.android.settings.R;
+import com.android.settings.notification.NotificationBackend;
+import com.android.settings.notification.NotificationBackend.AppRow;
+import com.android.settings.notification.NotificationBackend.NotificationsSentState;
+import com.android.settings.testutils.shadow.ShadowRestrictedLockUtilsInternal;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadows.ShadowPackageManager;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+@RunWith(RobolectricTestRunner.class)
+public class NotificationChannelSliceTest {
+ private static final String APP_LABEL = "Example App";
+ private static final int CHANNEL_COUNT = 3;
+ private static final String CHANNEL_NAME_PREFIX = "channel";
+ private static final int NOTIFICATION_COUNT =
+ NotificationChannelSlice.MIN_NOTIFICATION_SENT_COUNT + 1;
+ private static final String PACKAGE_NAME = "com.test.notification.channel.slice";
+ private static final int UID = 2019;
+
+ @Mock
+ private NotificationBackend mNotificationBackend;
+ private Context mContext;
+ private IconCompat mIcon;
+ private NotificationChannelSlice mNotificationChannelSlice;
+ private ShadowPackageManager mPackageManager;
+
+ @Before
+ public void setUp() {
+ MockitoAnnotations.initMocks(this);
+ mContext = RuntimeEnvironment.application;
+
+ // Shadow PackageManager to add mock package.
+ mPackageManager = shadowOf(mContext.getPackageManager());
+
+ // Set-up specs for SliceMetadata.
+ SliceProvider.setSpecs(SliceLiveData.SUPPORTED_SPECS);
+
+ mNotificationChannelSlice = spy(new NotificationChannelSlice(mContext));
+
+ doReturn(UID).when(mNotificationChannelSlice).getApplicationUid(any(String.class));
+ mIcon = IconCompat.createWithResource(mContext, R.drawable.ic_settings_24dp);
+ doReturn(mIcon).when(mNotificationChannelSlice).getApplicationIcon(any(String.class));
+
+ // Assign mock NotificationBackend to build notification related data.
+ mNotificationChannelSlice.mNotificationBackend = mNotificationBackend;
+ }
+
+ @After
+ public void tearDown() {
+ mPackageManager.removePackage(PACKAGE_NAME);
+ ShadowRestrictedLockUtilsInternal.reset();
+ }
+
+ @Test
+ @Config(shadows = ShadowRestrictedLockUtilsInternal.class)
+ public void getSlice_hasSuggestedApp_shouldHaveNotificationChannelTitle() {
+ addMockPackageToPackageManager(true /* isRecentlyInstalled */,
+ ApplicationInfo.FLAG_INSTALLED);
+ mockNotificationBackend(CHANNEL_COUNT, NOTIFICATION_COUNT, false /* banned */);
+
+ final Slice slice = mNotificationChannelSlice.getSlice();
+
+ final SliceMetadata metadata = SliceMetadata.from(mContext, slice);
+ assertThat(metadata.getTitle()).isEqualTo(
+ mContext.getString(R.string.manage_app_notification, APP_LABEL));
+ }
+
+ @Test
+ public void getSlice_noRecentlyInstalledApp_shouldHaveNoSuggestedAppTitle() {
+ addMockPackageToPackageManager(false /* isRecentlyInstalled */,
+ ApplicationInfo.FLAG_INSTALLED);
+ mockNotificationBackend(CHANNEL_COUNT, NOTIFICATION_COUNT, false /* banned */);
+
+ final Slice slice = mNotificationChannelSlice.getSlice();
+
+ final SliceMetadata metadata = SliceMetadata.from(mContext, slice);
+ assertThat(metadata.getTitle()).isEqualTo(mContext.getString(R.string.no_suggested_app));
+ }
+
+ @Test
+ public void getSlice_noMultiChannelApp_shouldHaveNoSuggestedAppTitle() {
+ addMockPackageToPackageManager(true /* isRecentlyInstalled */,
+ ApplicationInfo.FLAG_INSTALLED);
+ mockNotificationBackend(1 /* channelCount */, NOTIFICATION_COUNT, false /* banned */);
+
+ final Slice slice = mNotificationChannelSlice.getSlice();
+
+ final SliceMetadata metadata = SliceMetadata.from(mContext, slice);
+ assertThat(metadata.getTitle()).isEqualTo(mContext.getString(R.string.no_suggested_app));
+ }
+
+ @Test
+ public void getSlice_insufficientNotificationSentCount_shouldHaveNoSuggestedAppTitle() {
+ addMockPackageToPackageManager(true /* isRecentlyInstalled */,
+ ApplicationInfo.FLAG_INSTALLED);
+ mockNotificationBackend(CHANNEL_COUNT, 1 /* notificationCount */, false /* banned */);
+
+ final Slice slice = mNotificationChannelSlice.getSlice();
+
+ final SliceMetadata metadata = SliceMetadata.from(mContext, slice);
+ assertThat(metadata.getTitle()).isEqualTo(mContext.getString(R.string.no_suggested_app));
+ }
+
+ @Test
+ public void getSlice_isSystemApp_shouldHaveNoSuggestedAppTitle() {
+ addMockPackageToPackageManager(true /* isRecentlyInstalled */, ApplicationInfo.FLAG_SYSTEM);
+ mockNotificationBackend(CHANNEL_COUNT, NOTIFICATION_COUNT, false /* banned */);
+
+ final Slice slice = mNotificationChannelSlice.getSlice();
+
+ final SliceMetadata metadata = SliceMetadata.from(mContext, slice);
+ assertThat(metadata.getTitle()).isEqualTo(mContext.getString(R.string.no_suggested_app));
+ }
+
+ @Test
+ public void getSlice_isNotificationBanned_shouldHaveNoSuggestedAppTitle() {
+ addMockPackageToPackageManager(true /* isRecentlyInstalled */,
+ ApplicationInfo.FLAG_INSTALLED);
+ mockNotificationBackend(CHANNEL_COUNT, NOTIFICATION_COUNT, true /* banned */);
+
+ final Slice slice = mNotificationChannelSlice.getSlice();
+
+ final SliceMetadata metadata = SliceMetadata.from(mContext, slice);
+ assertThat(metadata.getTitle()).isEqualTo(mContext.getString(R.string.no_suggested_app));
+ }
+
+ @Test
+ @Config(shadows = ShadowRestrictedLockUtilsInternal.class)
+ public void getSlice_exceedDefaultRowCount_shouldOnlyShowDefaultRows() {
+ addMockPackageToPackageManager(true /* isRecentlyInstalled */,
+ ApplicationInfo.FLAG_INSTALLED);
+ mockNotificationBackend(NotificationChannelSlice.DEFAULT_EXPANDED_ROW_COUNT * 2,
+ NOTIFICATION_COUNT, false /* banned */);
+
+ final Slice slice = mNotificationChannelSlice.getSlice();
+
+ // Get the number of RowBuilders from Slice.
+ final int rows = SliceQuery.findAll(slice, FORMAT_SLICE, HINT_LIST_ITEM,
+ null /* nonHints */).size();
+ // The header of this slice is built by RowBuilder. Hence, the row count will contain it.
+ assertThat(rows).isEqualTo(NotificationChannelSlice.DEFAULT_EXPANDED_ROW_COUNT + 1);
+ }
+
+ private void addMockPackageToPackageManager(boolean isRecentlyInstalled, int flags) {
+ final ApplicationInfo applicationInfo = new ApplicationInfo();
+ applicationInfo.name = APP_LABEL;
+ applicationInfo.uid = UID;
+ applicationInfo.flags = flags;
+
+ final PackageInfo packageInfo = new PackageInfo();
+ packageInfo.packageName = PACKAGE_NAME;
+ packageInfo.applicationInfo = applicationInfo;
+ packageInfo.firstInstallTime = createAppInstallTime(isRecentlyInstalled);
+ mPackageManager.addPackage(packageInfo);
+ }
+
+ private long createAppInstallTime(boolean isRecentlyInstalled) {
+ if (isRecentlyInstalled) {
+ return System.currentTimeMillis() - NotificationChannelSlice.DURATION_END_DAYS;
+ }
+
+ return System.currentTimeMillis();
+ }
+
+ private void mockNotificationBackend(int channelCount, int notificationCount, boolean banned) {
+ final List channels = buildNotificationChannel(channelCount);
+ final AppRow appRow = buildAppRow(channelCount, notificationCount, banned);
+
+ doReturn(buildNotificationChannelGroups(channels)).when(mNotificationBackend).getGroups(
+ any(String.class), any(int.class));
+ doReturn(appRow).when(mNotificationBackend).loadAppRow(any(Context.class),
+ any(PackageManager.class), any(PackageInfo.class));
+ doReturn(channelCount).when(mNotificationBackend).getChannelCount(
+ any(String.class), any(int.class));
+ }
+
+ private AppRow buildAppRow(int channelCount, int sentCount, boolean banned) {
+ final AppRow appRow = new AppRow();
+ appRow.banned = banned;
+ appRow.channelCount = channelCount;
+ appRow.sentByApp = new NotificationsSentState();
+ appRow.sentByApp.sentCount = sentCount;
+ appRow.sentByChannel = buildNotificationSentStates(channelCount, sentCount);
+
+ return appRow;
+ }
+
+ private List buildNotificationChannel(int channelCount) {
+ final List channels = new ArrayList<>();
+ for (int i = 0; i < channelCount; i++) {
+ channels.add(new NotificationChannel(CHANNEL_NAME_PREFIX + i, CHANNEL_NAME_PREFIX + i,
+ IMPORTANCE_NONE));
+ }
+
+ return channels;
+ }
+
+ private ParceledListSlice buildNotificationChannelGroups(
+ List channels) {
+ final NotificationChannelGroup notificationChannelGroup = new NotificationChannelGroup(
+ "group", "group");
+ notificationChannelGroup.setBlocked(false);
+ notificationChannelGroup.setChannels(channels);
+
+ return new ParceledListSlice(Arrays.asList(notificationChannelGroup));
+ }
+
+ private Map buildNotificationSentStates(int channelCount,
+ int sentCount) {
+ final Map states = new ArrayMap<>();
+ for (int i = 0; i < channelCount; i++) {
+ final NotificationsSentState state = new NotificationsSentState();
+ state.sentCount = sentCount;
+ states.put(CHANNEL_NAME_PREFIX + i, state);
+ }
+
+ return states;
+ }
+}
\ No newline at end of file
diff --git a/tests/robotests/src/com/android/settings/testutils/shadow/ShadowRestrictedLockUtilsInternal.java b/tests/robotests/src/com/android/settings/testutils/shadow/ShadowRestrictedLockUtilsInternal.java
index e39056cedf3..d98379cb2c1 100644
--- a/tests/robotests/src/com/android/settings/testutils/shadow/ShadowRestrictedLockUtilsInternal.java
+++ b/tests/robotests/src/com/android/settings/testutils/shadow/ShadowRestrictedLockUtilsInternal.java
@@ -37,6 +37,7 @@ public class ShadowRestrictedLockUtilsInternal {
private static DevicePolicyManager sDevicePolicyManager;
private static String[] sDisabledTypes;
private static int sKeyguardDisabledFeatures;
+ private static boolean sIsSuspended;
@Resetter
public static void reset() {
@@ -45,6 +46,7 @@ public class ShadowRestrictedLockUtilsInternal {
sKeyguardDisabledFeatures = 0;
sDisabledTypes = new String[0];
sMaximumTimeToLockIsSet = false;
+ sIsSuspended = false;
}
@Implementation
@@ -101,6 +103,12 @@ public class ShadowRestrictedLockUtilsInternal {
return sMaximumTimeToLockIsSet ? new EnforcedAdmin() : null;
}
+ @Implementation
+ protected static EnforcedAdmin checkIfApplicationIsSuspended(Context context,
+ String packageName, int userId) {
+ return sIsSuspended ? new EnforcedAdmin() : null;
+ }
+
public static void setRestricted(boolean restricted) {
sIsRestricted = restricted;
}
@@ -132,4 +140,8 @@ public class ShadowRestrictedLockUtilsInternal {
public static void setMaximumTimeToLockIsSet(boolean isSet) {
sMaximumTimeToLockIsSet = isSet;
}
+
+ public static void setSuspended(boolean suspended) {
+ sIsRestricted = suspended;
+ }
}