1 /*
2  * Copyright (C) 2019 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.car.developeroptions.homepage.contextualcards.slices;
18 
19 import static android.app.NotificationManager.IMPORTANCE_LOW;
20 import static android.app.NotificationManager.IMPORTANCE_NONE;
21 import static android.app.slice.Slice.EXTRA_TOGGLE_STATE;
22 
23 import static com.android.car.developeroptions.notification.NotificationSettingsBase.ARG_FROM_SETTINGS;
24 
25 import android.app.Application;
26 import android.app.NotificationChannel;
27 import android.app.NotificationChannelGroup;
28 import android.app.PendingIntent;
29 import android.content.Context;
30 import android.content.Intent;
31 import android.content.pm.PackageInfo;
32 import android.content.pm.PackageManager;
33 import android.graphics.drawable.Drawable;
34 import android.net.Uri;
35 import android.os.Bundle;
36 import android.os.UserHandle;
37 import android.provider.Settings;
38 import android.text.TextUtils;
39 import android.util.Log;
40 
41 import androidx.core.graphics.drawable.IconCompat;
42 import androidx.slice.Slice;
43 import androidx.slice.builders.ListBuilder;
44 import androidx.slice.builders.SliceAction;
45 
46 import com.android.internal.annotations.VisibleForTesting;
47 import com.android.internal.logging.nano.MetricsProto;
48 import com.android.car.developeroptions.R;
49 import com.android.car.developeroptions.SubSettings;
50 import com.android.car.developeroptions.Utils;
51 import com.android.car.developeroptions.applications.AppAndNotificationDashboardFragment;
52 import com.android.car.developeroptions.applications.AppInfoBase;
53 import com.android.car.developeroptions.core.SubSettingLauncher;
54 import com.android.car.developeroptions.notification.AppNotificationSettings;
55 import com.android.car.developeroptions.notification.ChannelNotificationSettings;
56 import com.android.car.developeroptions.notification.NotificationBackend;
57 import com.android.car.developeroptions.notification.NotificationBackend.NotificationsSentState;
58 import com.android.car.developeroptions.slices.CustomSliceRegistry;
59 import com.android.car.developeroptions.slices.CustomSliceable;
60 import com.android.car.developeroptions.slices.SliceBroadcastReceiver;
61 import com.android.car.developeroptions.slices.SliceBuilderUtils;
62 import com.android.settingslib.RestrictedLockUtils;
63 import com.android.settingslib.RestrictedLockUtilsInternal;
64 import com.android.settingslib.applications.ApplicationsState;
65 
66 import java.util.ArrayList;
67 import java.util.Comparator;
68 import java.util.List;
69 import java.util.concurrent.TimeUnit;
70 import java.util.stream.Collectors;
71 
72 public class NotificationChannelSlice implements CustomSliceable {
73 
74     /**
75      * Recently app condition:
76      * App was installed between 3 and 7 days ago.
77      */
78     @VisibleForTesting
79     static final long DURATION_START_DAYS = TimeUnit.DAYS.toMillis(7);
80     @VisibleForTesting
81     static final long DURATION_END_DAYS = TimeUnit.DAYS.toMillis(3);
82 
83     /**
84      * Notification count condition:
85      * App has sent at least ~10 notifications.
86      */
87     @VisibleForTesting
88     static final int MIN_NOTIFICATION_SENT_COUNT = 10;
89 
90     /**
91      * Limit rows when the number of notification channel is more than {@link
92      * #DEFAULT_EXPANDED_ROW_COUNT}.
93      */
94     @VisibleForTesting
95     static final int DEFAULT_EXPANDED_ROW_COUNT = 3;
96 
97     private static final String TAG = "NotifChannelSlice";
98     private static final String PACKAGE_NAME = "package_name";
99     private static final String PACKAGE_UID = "package_uid";
100     private static final String CHANNEL_ID = "channel_id";
101 
102     /**
103      * Sort notification channel with weekly average sent count by descending.
104      *
105      * Note:
106      * When the sent count of notification channels is the same, follow the sorting mechanism from
107      * {@link com.android.car.developeroptions.notification.NotificationSettingsBase#mChannelComparator}.
108      * Since slice view only shows displayable notification channels, so those deleted ones are
109      * excluded from the comparison here.
110      */
111     private static final Comparator<NotificationChannelState> CHANNEL_STATE_COMPARATOR =
112             (left, right) -> {
113                 final NotificationsSentState leftState = left.getNotificationsSentState();
114                 final NotificationsSentState rightState = right.getNotificationsSentState();
115                 if (rightState.avgSentWeekly != leftState.avgSentWeekly) {
116                     return rightState.avgSentWeekly - leftState.avgSentWeekly;
117                 }
118 
119                 final NotificationChannel leftChannel = left.getNotificationChannel();
120                 final NotificationChannel rightChannel = right.getNotificationChannel();
121                 if (TextUtils.equals(leftChannel.getId(), NotificationChannel.DEFAULT_CHANNEL_ID)) {
122                     return 1;
123                 } else if (TextUtils.equals(rightChannel.getId(),
124                         NotificationChannel.DEFAULT_CHANNEL_ID)) {
125                     return -1;
126                 }
127 
128                 return leftChannel.getId().compareTo(rightChannel.getId());
129             };
130 
131     protected final Context mContext;
132     @VisibleForTesting
133     NotificationBackend mNotificationBackend;
134     private NotificationBackend.AppRow mAppRow;
135     private String mPackageName;
136     private int mUid;
137 
NotificationChannelSlice(Context context)138     public NotificationChannelSlice(Context context) {
139         mContext = context;
140         mNotificationBackend = new NotificationBackend();
141     }
142 
143     @Override
getSlice()144     public Slice getSlice() {
145         final ListBuilder listBuilder =
146                 new ListBuilder(mContext, getUri(), ListBuilder.INFINITY)
147                         .setAccentColor(COLOR_NOT_TINTED);
148         /**
149          * Get package which is satisfied with:
150          * 1. Recently installed.
151          * 2. Multiple channels.
152          * 3. Sent at least ~10 notifications.
153          */
154         // TODO(b/123065955): Review latency of NotificationChannelSlice
155         final List<PackageInfo> multiChannelPackages = getMultiChannelPackages(
156                 getRecentlyInstalledPackages());
157         final PackageInfo packageInfo = getMaxSentNotificationsPackage(multiChannelPackages);
158 
159         // Return a header with IsError flag, if package is not found.
160         if (packageInfo == null) {
161             return listBuilder.setHeader(getNoSuggestedAppHeader())
162                     .setIsError(true).build();
163         }
164 
165         // Save eligible package name and its uid, they will be used in getIntent().
166         mPackageName = packageInfo.packageName;
167         mUid = getApplicationUid(mPackageName);
168 
169         // Add notification channel header.
170         final IconCompat icon = getApplicationIcon(mPackageName);
171         final CharSequence title = mContext.getString(R.string.manage_app_notification,
172                 Utils.getApplicationLabel(mContext, mPackageName));
173         listBuilder.addRow(new ListBuilder.RowBuilder()
174                 .setTitleItem(icon, ListBuilder.ICON_IMAGE)
175                 .setTitle(title)
176                 .setSubtitle(getSubTitle(mPackageName, mUid))
177                 .setPrimaryAction(getPrimarySliceAction(icon, title, getIntent())));
178 
179         // Add notification channel rows.
180         final List<ListBuilder.RowBuilder> rows = getNotificationChannelRows(packageInfo, icon);
181         for (ListBuilder.RowBuilder rowBuilder : rows) {
182             listBuilder.addRow(rowBuilder);
183         }
184 
185         return listBuilder.build();
186     }
187 
188     @Override
getUri()189     public Uri getUri() {
190         return CustomSliceRegistry.NOTIFICATION_CHANNEL_SLICE_URI;
191     }
192 
193     @Override
onNotifyChange(Intent intent)194     public void onNotifyChange(Intent intent) {
195         final boolean newState = intent.getBooleanExtra(EXTRA_TOGGLE_STATE, false);
196         final String packageName = intent.getStringExtra(PACKAGE_NAME);
197         final int uid = intent.getIntExtra(PACKAGE_UID, -1);
198         final String channelId = intent.getStringExtra(CHANNEL_ID);
199         final NotificationChannel channel = mNotificationBackend.getChannel(packageName, uid,
200                 channelId);
201         final int importance = newState ? IMPORTANCE_LOW : IMPORTANCE_NONE;
202         channel.setImportance(importance);
203         channel.lockFields(NotificationChannel.USER_LOCKED_IMPORTANCE);
204         mNotificationBackend.updateChannel(packageName, uid, channel);
205     }
206 
207     @Override
getIntent()208     public Intent getIntent() {
209         final Bundle args = new Bundle();
210         args.putString(AppInfoBase.ARG_PACKAGE_NAME, mPackageName);
211         args.putInt(AppInfoBase.ARG_PACKAGE_UID, mUid);
212 
213         return new SubSettingLauncher(mContext)
214                 .setDestination(AppNotificationSettings.class.getName())
215                 .setTitleRes(R.string.notifications_title)
216                 .setArguments(args)
217                 .setSourceMetricsCategory(MetricsProto.MetricsEvent.SLICE)
218                 .toIntent();
219     }
220 
221     @VisibleForTesting
getApplicationIcon(String packageName)222     IconCompat getApplicationIcon(String packageName) {
223         final Drawable drawable;
224         try {
225             drawable = mContext.getPackageManager().getApplicationIcon(packageName);
226         } catch (PackageManager.NameNotFoundException e) {
227             Log.w(TAG, "No such package to get application icon.");
228             return null;
229         }
230 
231         return Utils.createIconWithDrawable(drawable);
232     }
233 
234     @VisibleForTesting
getApplicationUid(String packageName)235     int getApplicationUid(String packageName) {
236         final ApplicationsState.AppEntry appEntry =
237                 ApplicationsState.getInstance((Application) mContext.getApplicationContext())
238                         .getEntry(packageName, UserHandle.myUserId());
239 
240         return appEntry.info.uid;
241     }
242 
buildRowSliceAction(NotificationChannel channel, IconCompat icon)243     private SliceAction buildRowSliceAction(NotificationChannel channel, IconCompat icon) {
244         final Bundle channelArgs = new Bundle();
245         channelArgs.putInt(AppInfoBase.ARG_PACKAGE_UID, mUid);
246         channelArgs.putString(AppInfoBase.ARG_PACKAGE_NAME, mPackageName);
247         channelArgs.putString(Settings.EXTRA_CHANNEL_ID, channel.getId());
248         channelArgs.putBoolean(ARG_FROM_SETTINGS, true);
249 
250         final Intent channelIntent = new SubSettingLauncher(mContext)
251                 .setDestination(ChannelNotificationSettings.class.getName())
252                 .setArguments(channelArgs)
253                 .setTitleRes(R.string.notification_channel_title)
254                 .setSourceMetricsCategory(MetricsProto.MetricsEvent.SLICE)
255                 .toIntent();
256 
257         return SliceAction.createDeeplink(
258                 PendingIntent.getActivity(mContext, channel.hashCode(), channelIntent, 0), icon,
259                 ListBuilder.ICON_IMAGE, channel.getName());
260     }
261 
getNoSuggestedAppHeader()262     private ListBuilder.HeaderBuilder getNoSuggestedAppHeader() {
263         final IconCompat icon = IconCompat.createWithResource(mContext,
264                 R.drawable.ic_homepage_apps);
265         final CharSequence titleNoSuggestedApp = mContext.getString(R.string.no_suggested_app);
266         final SliceAction primarySliceActionForNoSuggestedApp = getPrimarySliceAction(icon,
267                 titleNoSuggestedApp, getAppAndNotificationPageIntent());
268 
269         return new ListBuilder.HeaderBuilder()
270                 .setTitle(titleNoSuggestedApp)
271                 .setPrimaryAction(primarySliceActionForNoSuggestedApp);
272     }
273 
getNotificationChannelRows(PackageInfo packageInfo, IconCompat icon)274     private List<ListBuilder.RowBuilder> getNotificationChannelRows(PackageInfo packageInfo,
275             IconCompat icon) {
276         final List<ListBuilder.RowBuilder> notificationChannelRows = new ArrayList<>();
277         final List<NotificationChannel> displayableChannels = getDisplayableChannels(mAppRow);
278 
279         for (NotificationChannel channel : displayableChannels) {
280             notificationChannelRows.add(new ListBuilder.RowBuilder()
281                     .setTitle(channel.getName())
282                     .setSubtitle(NotificationBackend.getSentSummary(
283                             mContext, mAppRow.sentByChannel.get(channel.getId()), false))
284                     .setPrimaryAction(buildRowSliceAction(channel, icon))
285                     .addEndItem(SliceAction.createToggle(getToggleIntent(channel.getId()),
286                             null /* actionTitle */, channel.getImportance() != IMPORTANCE_NONE)));
287         }
288 
289         return notificationChannelRows;
290     }
291 
getToggleIntent(String channelId)292     private PendingIntent getToggleIntent(String channelId) {
293         // Send broadcast to enable/disable channel.
294         final Intent intent = new Intent(getUri().toString())
295                 .setClass(mContext, SliceBroadcastReceiver.class)
296                 .putExtra(PACKAGE_NAME, mPackageName)
297                 .putExtra(PACKAGE_UID, mUid)
298                 .putExtra(CHANNEL_ID, channelId);
299 
300         return PendingIntent.getBroadcast(mContext, intent.hashCode(), intent, 0);
301     }
302 
getMultiChannelPackages(List<PackageInfo> packageInfoList)303     private List<PackageInfo> getMultiChannelPackages(List<PackageInfo> packageInfoList) {
304         final List<PackageInfo> multiChannelPackages = new ArrayList<>();
305 
306         if (packageInfoList.isEmpty()) {
307             return multiChannelPackages;
308         }
309 
310         for (PackageInfo packageInfo : packageInfoList) {
311             final int channelCount = mNotificationBackend.getChannelCount(packageInfo.packageName,
312                     getApplicationUid(packageInfo.packageName));
313             if (channelCount > 1) {
314                 multiChannelPackages.add(packageInfo);
315             }
316         }
317 
318         // TODO(b/119831690): Filter the packages which doesn't have any configurable channel.
319         return multiChannelPackages;
320     }
321 
getRecentlyInstalledPackages()322     private List<PackageInfo> getRecentlyInstalledPackages() {
323         final long startTime = System.currentTimeMillis() - DURATION_START_DAYS;
324         final long endTime = System.currentTimeMillis() - DURATION_END_DAYS;
325 
326         // Get recently installed packages between 3 and 7 days ago.
327         final List<PackageInfo> recentlyInstalledPackages = new ArrayList<>();
328         final List<PackageInfo> installedPackages =
329                 mContext.getPackageManager().getInstalledPackages(0);
330         for (PackageInfo packageInfo : installedPackages) {
331             // Not include system app.
332             if (packageInfo.applicationInfo.isSystemApp()) {
333                 continue;
334             }
335 
336             if (packageInfo.firstInstallTime >= startTime
337                     && packageInfo.firstInstallTime <= endTime) {
338                 recentlyInstalledPackages.add(packageInfo);
339             }
340         }
341 
342         return recentlyInstalledPackages;
343     }
344 
getPrimarySliceAction(IconCompat icon, CharSequence title, Intent intent)345     private SliceAction getPrimarySliceAction(IconCompat icon, CharSequence title, Intent intent) {
346         return SliceAction.createDeeplink(
347                 PendingIntent.getActivity(mContext, intent.hashCode(), intent, 0),
348                 icon,
349                 ListBuilder.ICON_IMAGE,
350                 title);
351     }
352 
getDisplayableChannels(NotificationBackend.AppRow appRow)353     private List<NotificationChannel> getDisplayableChannels(NotificationBackend.AppRow appRow) {
354         final List<NotificationChannelGroup> channelGroupList =
355                 mNotificationBackend.getGroups(appRow.pkg, appRow.uid).getList();
356         final List<NotificationChannel> channels = channelGroupList.stream()
357                 .flatMap(group -> group.getChannels().stream().filter(
358                         channel -> isChannelEnabled(group, channel, appRow)))
359                 .collect(Collectors.toList());
360 
361         // Pack the notification channel with notification sent state for sorting.
362         final List<NotificationChannelState> channelStates = new ArrayList<>();
363         for (NotificationChannel channel : channels) {
364             NotificationsSentState sentState = appRow.sentByChannel.get(channel.getId());
365             if (sentState == null) {
366                 sentState = new NotificationsSentState();
367             }
368             channelStates.add(new NotificationChannelState(sentState, channel));
369         }
370 
371         // Sort the notification channels with notification sent count by descending.
372         return channelStates.stream()
373                 .sorted(CHANNEL_STATE_COMPARATOR)
374                 .map(state -> state.getNotificationChannel())
375                 .limit(DEFAULT_EXPANDED_ROW_COUNT)
376                 .collect(Collectors.toList());
377     }
378 
getMaxSentNotificationsPackage(List<PackageInfo> packageInfoList)379     private PackageInfo getMaxSentNotificationsPackage(List<PackageInfo> packageInfoList) {
380         if (packageInfoList.isEmpty()) {
381             return null;
382         }
383 
384         // Get the package which has sent at least ~10 notifications and not turn off channels.
385         int maxSentCount = 0;
386         PackageInfo maxSentCountPackage = null;
387         for (PackageInfo packageInfo : packageInfoList) {
388             final NotificationBackend.AppRow appRow = mNotificationBackend.loadAppRow(mContext,
389                     mContext.getPackageManager(), packageInfo);
390             // Ignore packages which are banned notifications or block all displayable channels.
391             if (appRow.banned || isAllChannelsBlocked(getDisplayableChannels(appRow))) {
392                 continue;
393             }
394 
395             // Get sent notification count from app.
396             final int sentCount = appRow.sentByApp.sentCount;
397             if (sentCount >= MIN_NOTIFICATION_SENT_COUNT && sentCount > maxSentCount) {
398                 maxSentCount = sentCount;
399                 maxSentCountPackage = packageInfo;
400                 mAppRow = appRow;
401             }
402         }
403 
404         return maxSentCountPackage;
405     }
406 
isAllChannelsBlocked(List<NotificationChannel> channels)407     private boolean isAllChannelsBlocked(List<NotificationChannel> channels) {
408         for (NotificationChannel channel : channels) {
409             if (channel.getImportance() != IMPORTANCE_NONE) {
410                 return false;
411             }
412         }
413         return true;
414     }
415 
getSubTitle(String packageName, int uid)416     protected CharSequence getSubTitle(String packageName, int uid) {
417         final int channelCount = mNotificationBackend.getChannelCount(packageName, uid);
418 
419         if (channelCount > DEFAULT_EXPANDED_ROW_COUNT) {
420             return mContext.getString(
421                     R.string.notification_many_channel_count_summary, channelCount);
422         }
423 
424         return mContext.getResources().getQuantityString(
425                 R.plurals.notification_few_channel_count_summary, channelCount, channelCount);
426     }
427 
getAppAndNotificationPageIntent()428     private Intent getAppAndNotificationPageIntent() {
429         final String screenTitle = mContext.getText(R.string.app_and_notification_dashboard_title)
430                 .toString();
431 
432         return SliceBuilderUtils.buildSearchResultPageIntent(mContext,
433                 AppAndNotificationDashboardFragment.class.getName(), "" /* key */,
434                 screenTitle,
435                 MetricsProto.MetricsEvent.SLICE)
436                 .setClassName(mContext.getPackageName(), SubSettings.class.getName())
437                 .setData(getUri());
438     }
439 
isChannelEnabled(NotificationChannelGroup group, NotificationChannel channel, NotificationBackend.AppRow appRow)440     private boolean isChannelEnabled(NotificationChannelGroup group, NotificationChannel channel,
441             NotificationBackend.AppRow appRow) {
442         final RestrictedLockUtils.EnforcedAdmin suspendedAppsAdmin =
443                 RestrictedLockUtilsInternal.checkIfApplicationIsSuspended(mContext, mPackageName,
444                         mUid);
445 
446         return suspendedAppsAdmin == null
447                 && isChannelBlockable(channel, appRow)
448                 && isChannelConfigurable(channel, appRow)
449                 && !group.isBlocked();
450     }
451 
isChannelConfigurable(NotificationChannel channel, NotificationBackend.AppRow appRow)452     private boolean isChannelConfigurable(NotificationChannel channel,
453             NotificationBackend.AppRow appRow) {
454         if (channel != null && appRow != null) {
455             return !TextUtils.equals(channel.getId(), appRow.lockedChannelId);
456         }
457 
458         return false;
459     }
460 
isChannelBlockable(NotificationChannel channel, NotificationBackend.AppRow appRow)461     private boolean isChannelBlockable(NotificationChannel channel,
462             NotificationBackend.AppRow appRow) {
463         if (channel != null && appRow != null) {
464             if (!appRow.systemApp) {
465                 return true;
466             }
467 
468             return channel.isBlockable()
469                     || channel.getImportance() == IMPORTANCE_NONE;
470         }
471 
472         return false;
473     }
474 
475     /**
476      * This class is used to sort notification channels according to notification sent count and
477      * notification id in {@link NotificationChannelSlice#CHANNEL_STATE_COMPARATOR}.
478      *
479      * Include {@link NotificationsSentState#avgSentWeekly} and {@link NotificationChannel#getId()}
480      * to get the number of notifications being sent and notification id.
481      */
482     private static class NotificationChannelState {
483 
484         final private NotificationsSentState mNotificationsSentState;
485         final private NotificationChannel mNotificationChannel;
486 
NotificationChannelState(NotificationsSentState notificationsSentState, NotificationChannel notificationChannel)487         public NotificationChannelState(NotificationsSentState notificationsSentState,
488                 NotificationChannel notificationChannel) {
489             mNotificationsSentState = notificationsSentState;
490             mNotificationChannel = notificationChannel;
491         }
492 
getNotificationChannel()493         public NotificationChannel getNotificationChannel() {
494             return mNotificationChannel;
495         }
496 
getNotificationsSentState()497         public NotificationsSentState getNotificationsSentState() {
498             return mNotificationsSentState;
499         }
500     }
501 }
502