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; + } }