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