Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 9e6a7a76 authored by Yanting Yang's avatar Yanting Yang Committed by Android (Google) Code Review
Browse files

Merge "Add Notification Channel slice to Contextual Settings Homepage"

parents c30323a7 aa29da44
Loading
Loading
Loading
Loading
+10 −0
Original line number Diff line number Diff line
@@ -10524,4 +10524,14 @@
    <!-- Text for permission bar chart details in Privacy page.  [CHAR LIMIT=NONE] -->
    <string name="permission_bar_chart_details">See all usage</string>
    <!-- Title for notification channel slice. [CHAR LIMIT=NONE] -->
    <string name="manage_app_notification">Manage <xliff:g id="app_name" example="Settings">%1$s</xliff:g> Notifications</string>
    <!-- Title for no suggested app in notification channel slice. [CHAR LIMIT=NONE] -->
    <string name="no_suggested_app">No suggested application</string>
    <!-- Summary for notification channel slice. [CHAR LIMIT=NONE] -->
    <plurals name="notification_channel_count_summary">
        <item quantity="one"><xliff:g id="notification_channel_count">%1$d</xliff:g> notification channel. Tap to manage all.</item>
        <item quantity="other"><xliff:g id="notification_channel_count">%1$d</xliff:g> notification channels. Tap to manage all.</item>
    </plurals>
</resources>
+3 −1
Original line number Diff line number Diff line
@@ -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<List<ContextualCard>

    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 {
+7 −0
Original line number Diff line number Diff line
@@ -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;
+474 −0
Original line number Diff line number Diff line
/*
 * 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<NotificationChannel> 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<PackageInfo> 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<ListBuilder.RowBuilder> 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<NotificationChannel> 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<ListBuilder.RowBuilder> getNotificationChannelRows(PackageInfo packageInfo,
            IconCompat icon) {
        final List<ListBuilder.RowBuilder> notificationChannelRows = new ArrayList<>();
        final NotificationBackend.AppRow appRow = mNotificationBackend.loadAppRow(mContext,
                mContext.getPackageManager(), packageInfo);
        final List<NotificationChannel> 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<PackageInfo> getMultiChannelPackages(List<PackageInfo> packageInfoList) {
        final List<PackageInfo> 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<PackageInfo> 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<PackageInfo> recentlyInstalledPackages = new ArrayList<>();
        final List<PackageInfo> 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<NotificationChannel> getEnabledChannels(String packageName, int uid,
            NotificationBackend.AppRow appRow) {
        final List<NotificationChannelGroup> channelGroupList =
                mNotificationBackend.getGroups(packageName, uid).getList();
        final List<NotificationChannel> 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<PackageInfo> 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
+1 −1
Original line number Diff line number Diff line
@@ -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();
Loading