1 /* 2 * Copyright (C) 2017 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.systemui.statusbar; 18 19 import static android.app.NotificationManager.IMPORTANCE_MIN; 20 import static android.app.NotificationManager.IMPORTANCE_NONE; 21 22 import android.animation.Animator; 23 import android.animation.AnimatorListenerAdapter; 24 import android.animation.AnimatorSet; 25 import android.animation.ObjectAnimator; 26 import android.annotation.Nullable; 27 import android.app.INotificationManager; 28 import android.app.Notification; 29 import android.app.NotificationChannel; 30 import android.app.NotificationChannelGroup; 31 import android.content.Context; 32 import android.content.Intent; 33 import android.content.pm.ActivityInfo; 34 import android.content.pm.ApplicationInfo; 35 import android.content.pm.PackageManager; 36 import android.content.pm.ResolveInfo; 37 import android.graphics.drawable.Drawable; 38 import android.os.Handler; 39 import android.os.RemoteException; 40 import android.service.notification.StatusBarNotification; 41 import android.text.TextUtils; 42 import android.util.AttributeSet; 43 import android.util.Log; 44 import android.view.View; 45 import android.view.ViewGroup; 46 import android.view.accessibility.AccessibilityEvent; 47 import android.widget.ImageView; 48 import android.widget.LinearLayout; 49 import android.widget.TextView; 50 51 import com.android.internal.annotations.VisibleForTesting; 52 import com.android.internal.logging.MetricsLogger; 53 import com.android.internal.logging.nano.MetricsProto.MetricsEvent; 54 import com.android.systemui.Dependency; 55 import com.android.systemui.Interpolators; 56 import com.android.systemui.R; 57 import com.android.systemui.statusbar.notification.NotificationCounters; 58 59 import java.util.List; 60 61 /** 62 * The guts of a notification revealed when performing a long press. This also houses the blocking 63 * helper affordance that allows a user to keep/stop notifications after swiping one away. 64 */ 65 public class NotificationInfo extends LinearLayout implements NotificationGuts.GutsContent { 66 private static final String TAG = "InfoGuts"; 67 68 private INotificationManager mINotificationManager; 69 private PackageManager mPm; 70 private MetricsLogger mMetricsLogger; 71 72 private String mPackageName; 73 private String mAppName; 74 private int mAppUid; 75 private int mNumUniqueChannelsInRow; 76 private NotificationChannel mSingleNotificationChannel; 77 private int mStartingUserImportance; 78 private int mChosenImportance; 79 private boolean mIsSingleDefaultChannel; 80 private boolean mIsNonblockable; 81 private StatusBarNotification mSbn; 82 private AnimatorSet mExpandAnimation; 83 private boolean mIsForeground; 84 85 private CheckSaveListener mCheckSaveListener; 86 private OnSettingsClickListener mOnSettingsClickListener; 87 private OnAppSettingsClickListener mAppSettingsClickListener; 88 private NotificationGuts mGutsContainer; 89 90 /** Whether this view is being shown as part of the blocking helper. */ 91 private boolean mIsForBlockingHelper; 92 private boolean mNegativeUserSentiment; 93 94 /** 95 * String that describes how the user exit or quit out of this view, also used as a counter tag. 96 */ 97 private String mExitReason = NotificationCounters.BLOCKING_HELPER_DISMISSED; 98 99 private OnClickListener mOnKeepShowing = v -> { 100 mExitReason = NotificationCounters.BLOCKING_HELPER_KEEP_SHOWING; 101 closeControls(v); 102 }; 103 104 private OnClickListener mOnStopOrMinimizeNotifications = v -> { 105 mExitReason = NotificationCounters.BLOCKING_HELPER_STOP_NOTIFICATIONS; 106 swapContent(false); 107 }; 108 109 private OnClickListener mOnUndo = v -> { 110 // Reset exit counter that we'll log and record an undo event separately (not an exit event) 111 mExitReason = NotificationCounters.BLOCKING_HELPER_DISMISSED; 112 logBlockingHelperCounter(NotificationCounters.BLOCKING_HELPER_UNDO); 113 swapContent(true); 114 }; 115 116 public NotificationInfo(Context context, AttributeSet attrs) { 117 super(context, attrs); 118 } 119 120 // Specify a CheckSaveListener to override when/if the user's changes are committed. 121 public interface CheckSaveListener { 122 // Invoked when importance has changed and the NotificationInfo wants to try to save it. 123 // Listener should run saveImportance unless the change should be canceled. 124 void checkSave(Runnable saveImportance, StatusBarNotification sbn); 125 } 126 127 public interface OnSettingsClickListener { 128 void onClick(View v, NotificationChannel channel, int appUid); 129 } 130 131 public interface OnAppSettingsClickListener { 132 void onClick(View v, Intent intent); 133 } 134 135 @VisibleForTesting 136 void bindNotification( 137 final PackageManager pm, 138 final INotificationManager iNotificationManager, 139 final String pkg, 140 final NotificationChannel notificationChannel, 141 final int numUniqueChannelsInRow, 142 final StatusBarNotification sbn, 143 final CheckSaveListener checkSaveListener, 144 final OnSettingsClickListener onSettingsClick, 145 final OnAppSettingsClickListener onAppSettingsClick, 146 boolean isNonblockable) 147 throws RemoteException { 148 bindNotification(pm, iNotificationManager, pkg, notificationChannel, 149 numUniqueChannelsInRow, sbn, checkSaveListener, onSettingsClick, 150 onAppSettingsClick, isNonblockable, false /* isBlockingHelper */, 151 false /* isUserSentimentNegative */); 152 } 153 154 public void bindNotification( 155 PackageManager pm, 156 INotificationManager iNotificationManager, 157 String pkg, 158 NotificationChannel notificationChannel, 159 int numUniqueChannelsInRow, 160 StatusBarNotification sbn, 161 CheckSaveListener checkSaveListener, 162 OnSettingsClickListener onSettingsClick, 163 OnAppSettingsClickListener onAppSettingsClick, 164 boolean isNonblockable, 165 boolean isForBlockingHelper, 166 boolean isUserSentimentNegative) 167 throws RemoteException { 168 mINotificationManager = iNotificationManager; 169 mMetricsLogger = Dependency.get(MetricsLogger.class); 170 mPackageName = pkg; 171 mNumUniqueChannelsInRow = numUniqueChannelsInRow; 172 mSbn = sbn; 173 mPm = pm; 174 mAppSettingsClickListener = onAppSettingsClick; 175 mAppName = mPackageName; 176 mCheckSaveListener = checkSaveListener; 177 mOnSettingsClickListener = onSettingsClick; 178 mSingleNotificationChannel = notificationChannel; 179 mStartingUserImportance = mChosenImportance = mSingleNotificationChannel.getImportance(); 180 mNegativeUserSentiment = isUserSentimentNegative; 181 mIsNonblockable = isNonblockable; 182 mIsForeground = 183 (mSbn.getNotification().flags & Notification.FLAG_FOREGROUND_SERVICE) != 0; 184 mIsForBlockingHelper = isForBlockingHelper; 185 mAppUid = mSbn.getUid(); 186 187 int numTotalChannels = mINotificationManager.getNumNotificationChannelsForPackage( 188 pkg, mAppUid, false /* includeDeleted */); 189 if (mNumUniqueChannelsInRow == 0) { 190 throw new IllegalArgumentException("bindNotification requires at least one channel"); 191 } else { 192 // Special behavior for the Default channel if no other channels have been defined. 193 mIsSingleDefaultChannel = mNumUniqueChannelsInRow == 1 194 && mSingleNotificationChannel.getId().equals( 195 NotificationChannel.DEFAULT_CHANNEL_ID) 196 && numTotalChannels == 1; 197 } 198 199 bindHeader(); 200 bindPrompt(); 201 bindButtons(); 202 } 203 204 private void bindHeader() throws RemoteException { 205 // Package name 206 Drawable pkgicon = null; 207 ApplicationInfo info; 208 try { 209 info = mPm.getApplicationInfo( 210 mPackageName, 211 PackageManager.MATCH_UNINSTALLED_PACKAGES 212 | PackageManager.MATCH_DISABLED_COMPONENTS 213 | PackageManager.MATCH_DIRECT_BOOT_UNAWARE 214 | PackageManager.MATCH_DIRECT_BOOT_AWARE); 215 if (info != null) { 216 mAppName = String.valueOf(mPm.getApplicationLabel(info)); 217 pkgicon = mPm.getApplicationIcon(info); 218 } 219 } catch (PackageManager.NameNotFoundException e) { 220 // app is gone, just show package name and generic icon 221 pkgicon = mPm.getDefaultActivityIcon(); 222 } 223 ((ImageView) findViewById(R.id.pkgicon)).setImageDrawable(pkgicon); 224 ((TextView) findViewById(R.id.pkgname)).setText(mAppName); 225 226 // Set group information if this channel has an associated group. 227 CharSequence groupName = null; 228 if (mSingleNotificationChannel != null && mSingleNotificationChannel.getGroup() != null) { 229 final NotificationChannelGroup notificationChannelGroup = 230 mINotificationManager.getNotificationChannelGroupForPackage( 231 mSingleNotificationChannel.getGroup(), mPackageName, mAppUid); 232 if (notificationChannelGroup != null) { 233 groupName = notificationChannelGroup.getName(); 234 } 235 } 236 TextView groupNameView = findViewById(R.id.group_name); 237 TextView groupDividerView = findViewById(R.id.pkg_group_divider); 238 if (groupName != null) { 239 groupNameView.setText(groupName); 240 groupNameView.setVisibility(View.VISIBLE); 241 groupDividerView.setVisibility(View.VISIBLE); 242 } else { 243 groupNameView.setVisibility(View.GONE); 244 groupDividerView.setVisibility(View.GONE); 245 } 246 247 // Settings button. 248 final View settingsButton = findViewById(R.id.info); 249 if (mAppUid >= 0 && mOnSettingsClickListener != null) { 250 settingsButton.setVisibility(View.VISIBLE); 251 final int appUidF = mAppUid; 252 settingsButton.setOnClickListener( 253 (View view) -> { 254 logBlockingHelperCounter( 255 NotificationCounters.BLOCKING_HELPER_NOTIF_SETTINGS); 256 mOnSettingsClickListener.onClick(view, 257 mNumUniqueChannelsInRow > 1 ? null : mSingleNotificationChannel, 258 appUidF); 259 }); 260 } else { 261 settingsButton.setVisibility(View.GONE); 262 } 263 } 264 265 private void bindPrompt() { 266 final TextView blockPrompt = findViewById(R.id.block_prompt); 267 bindName(); 268 if (mIsNonblockable) { 269 blockPrompt.setText(R.string.notification_unblockable_desc); 270 } else { 271 if (mNegativeUserSentiment) { 272 blockPrompt.setText(R.string.inline_blocking_helper); 273 } else if (mIsSingleDefaultChannel || mNumUniqueChannelsInRow > 1) { 274 blockPrompt.setText(R.string.inline_keep_showing_app); 275 } else { 276 blockPrompt.setText(R.string.inline_keep_showing); 277 } 278 } 279 } 280 281 private void bindName() { 282 final TextView channelName = findViewById(R.id.channel_name); 283 if (mIsSingleDefaultChannel || mNumUniqueChannelsInRow > 1) { 284 channelName.setVisibility(View.GONE); 285 } else { 286 channelName.setText(mSingleNotificationChannel.getName()); 287 } 288 } 289 290 @VisibleForTesting 291 void logBlockingHelperCounter(String counterTag) { 292 if (mIsForBlockingHelper) { 293 mMetricsLogger.count(counterTag, 1); 294 } 295 } 296 297 private boolean hasImportanceChanged() { 298 return mSingleNotificationChannel != null && mStartingUserImportance != mChosenImportance; 299 } 300 301 private void saveImportance() { 302 if (!mIsNonblockable) { 303 // Only go through the lock screen/bouncer if the user hit 'Stop notifications'. 304 // Otherwise, update the importance immediately. 305 if (mCheckSaveListener != null 306 && NotificationCounters.BLOCKING_HELPER_STOP_NOTIFICATIONS.equals( 307 mExitReason)) { 308 mCheckSaveListener.checkSave(this::updateImportance, mSbn); 309 } else { 310 updateImportance(); 311 } 312 } 313 } 314 315 /** 316 * Commits the updated importance values on the background thread. 317 */ 318 private void updateImportance() { 319 MetricsLogger.action(mContext, MetricsEvent.ACTION_SAVE_IMPORTANCE, 320 mChosenImportance - mStartingUserImportance); 321 322 Handler bgHandler = new Handler(Dependency.get(Dependency.BG_LOOPER)); 323 bgHandler.post(new UpdateImportanceRunnable(mINotificationManager, mPackageName, mAppUid, 324 mNumUniqueChannelsInRow == 1 ? mSingleNotificationChannel : null, 325 mStartingUserImportance, mChosenImportance)); 326 } 327 328 private void bindButtons() { 329 // Set up stay-in-notification actions 330 View block = findViewById(R.id.block); 331 TextView keep = findViewById(R.id.keep); 332 View minimize = findViewById(R.id.minimize); 333 334 findViewById(R.id.undo).setOnClickListener(mOnUndo); 335 block.setOnClickListener(mOnStopOrMinimizeNotifications); 336 keep.setOnClickListener(mOnKeepShowing); 337 minimize.setOnClickListener(mOnStopOrMinimizeNotifications); 338 339 if (mIsNonblockable) { 340 keep.setText(android.R.string.ok); 341 block.setVisibility(GONE); 342 minimize.setVisibility(GONE); 343 } else if (mIsForeground) { 344 block.setVisibility(GONE); 345 minimize.setVisibility(VISIBLE); 346 } else if (!mIsForeground) { 347 block.setVisibility(VISIBLE); 348 minimize.setVisibility(GONE); 349 } 350 351 // Set up app settings link (i.e. Customize) 352 TextView settingsLinkView = findViewById(R.id.app_settings); 353 Intent settingsIntent = getAppSettingsIntent(mPm, mPackageName, mSingleNotificationChannel, 354 mSbn.getId(), mSbn.getTag()); 355 if (!mIsForBlockingHelper 356 && settingsIntent != null 357 && !TextUtils.isEmpty(mSbn.getNotification().getSettingsText())) { 358 settingsLinkView.setVisibility(VISIBLE); 359 settingsLinkView.setText(mContext.getString(R.string.notification_app_settings)); 360 settingsLinkView.setOnClickListener((View view) -> { 361 mAppSettingsClickListener.onClick(view, settingsIntent); 362 }); 363 } else { 364 settingsLinkView.setVisibility(View.GONE); 365 } 366 } 367 368 private void swapContent(boolean showPrompt) { 369 if (mExpandAnimation != null) { 370 mExpandAnimation.cancel(); 371 } 372 373 View prompt = findViewById(R.id.prompt); 374 ViewGroup confirmation = findViewById(R.id.confirmation); 375 TextView confirmationText = findViewById(R.id.confirmation_text); 376 View header = findViewById(R.id.header); 377 378 if (showPrompt) { 379 mChosenImportance = mStartingUserImportance; 380 } else if (mIsForeground) { 381 mChosenImportance = IMPORTANCE_MIN; 382 confirmationText.setText(R.string.notification_channel_minimized); 383 } else { 384 mChosenImportance = IMPORTANCE_NONE; 385 confirmationText.setText(R.string.notification_channel_disabled); 386 } 387 388 ObjectAnimator promptAnim = ObjectAnimator.ofFloat(prompt, View.ALPHA, 389 prompt.getAlpha(), showPrompt ? 1f : 0f); 390 promptAnim.setInterpolator(showPrompt ? Interpolators.ALPHA_IN : Interpolators.ALPHA_OUT); 391 ObjectAnimator confirmAnim = ObjectAnimator.ofFloat(confirmation, View.ALPHA, 392 confirmation.getAlpha(), showPrompt ? 0f : 1f); 393 confirmAnim.setInterpolator(showPrompt ? Interpolators.ALPHA_OUT : Interpolators.ALPHA_IN); 394 395 prompt.setVisibility(showPrompt ? VISIBLE : GONE); 396 confirmation.setVisibility(showPrompt ? GONE : VISIBLE); 397 header.setVisibility(showPrompt ? VISIBLE : GONE); 398 399 mExpandAnimation = new AnimatorSet(); 400 mExpandAnimation.playTogether(promptAnim, confirmAnim); 401 mExpandAnimation.setDuration(150); 402 mExpandAnimation.addListener(new AnimatorListenerAdapter() { 403 boolean cancelled = false; 404 405 @Override 406 public void onAnimationCancel(Animator animation) { 407 cancelled = true; 408 } 409 410 @Override 411 public void onAnimationEnd(Animator animation) { 412 if (!cancelled) { 413 prompt.setVisibility(showPrompt ? VISIBLE : GONE); 414 confirmation.setVisibility(showPrompt ? GONE : VISIBLE); 415 } 416 } 417 }); 418 mExpandAnimation.start(); 419 } 420 421 @Override 422 public void onInitializeAccessibilityEvent(AccessibilityEvent event) { 423 super.onInitializeAccessibilityEvent(event); 424 if (mGutsContainer != null && 425 event.getEventType() == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) { 426 if (mGutsContainer.isExposed()) { 427 event.getText().add(mContext.getString( 428 R.string.notification_channel_controls_opened_accessibility, mAppName)); 429 } else { 430 event.getText().add(mContext.getString( 431 R.string.notification_channel_controls_closed_accessibility, mAppName)); 432 } 433 } 434 } 435 436 private Intent getAppSettingsIntent(PackageManager pm, String packageName, 437 NotificationChannel channel, int id, String tag) { 438 Intent intent = new Intent(Intent.ACTION_MAIN) 439 .addCategory(Notification.INTENT_CATEGORY_NOTIFICATION_PREFERENCES) 440 .setPackage(packageName); 441 final List<ResolveInfo> resolveInfos = pm.queryIntentActivities( 442 intent, 443 PackageManager.MATCH_DEFAULT_ONLY 444 ); 445 if (resolveInfos == null || resolveInfos.size() == 0 || resolveInfos.get(0) == null) { 446 return null; 447 } 448 final ActivityInfo activityInfo = resolveInfos.get(0).activityInfo; 449 intent.setClassName(activityInfo.packageName, activityInfo.name); 450 if (channel != null) { 451 intent.putExtra(Notification.EXTRA_CHANNEL_ID, channel.getId()); 452 } 453 intent.putExtra(Notification.EXTRA_NOTIFICATION_ID, id); 454 intent.putExtra(Notification.EXTRA_NOTIFICATION_TAG, tag); 455 return intent; 456 } 457 458 /** 459 * Closes the controls and commits the updated importance values (indirectly). If this view is 460 * being used to show the blocking helper, this will immediately dismiss the blocking helper and 461 * commit the updated importance. 462 * 463 * <p><b>Note,</b> this will only get called once the view is dismissing. This means that the 464 * user does not have the ability to undo the action anymore. See {@link #swapContent(boolean)} 465 * for where undo is handled. 466 */ 467 @VisibleForTesting 468 void closeControls(View v) { 469 int[] parentLoc = new int[2]; 470 int[] targetLoc = new int[2]; 471 mGutsContainer.getLocationOnScreen(parentLoc); 472 v.getLocationOnScreen(targetLoc); 473 final int centerX = v.getWidth() / 2; 474 final int centerY = v.getHeight() / 2; 475 final int x = targetLoc[0] - parentLoc[0] + centerX; 476 final int y = targetLoc[1] - parentLoc[1] + centerY; 477 mGutsContainer.closeControls(x, y, true /* save */, false /* force */); 478 } 479 480 @Override 481 public void setGutsParent(NotificationGuts guts) { 482 mGutsContainer = guts; 483 } 484 485 @Override 486 public boolean willBeRemoved() { 487 return hasImportanceChanged(); 488 } 489 490 @Override 491 public boolean shouldBeSaved() { 492 return hasImportanceChanged(); 493 } 494 495 @Override 496 public View getContentView() { 497 return this; 498 } 499 500 @Override 501 public boolean handleCloseControls(boolean save, boolean force) { 502 // Save regardless of the importance so we can lock the importance field if the user wants 503 // to keep getting notifications 504 if (save) { 505 saveImportance(); 506 } 507 logBlockingHelperCounter(mExitReason); 508 return false; 509 } 510 511 @Override 512 public int getActualHeight() { 513 return getHeight(); 514 } 515 516 /** 517 * Runnable to either update the given channel (with a new importance value) or, if no channel 518 * is provided, update notifications enabled state for the package. 519 */ 520 private static class UpdateImportanceRunnable implements Runnable { 521 private final INotificationManager mINotificationManager; 522 private final String mPackageName; 523 private final int mAppUid; 524 private final @Nullable NotificationChannel mChannelToUpdate; 525 private final int mCurrentImportance; 526 private final int mNewImportance; 527 528 529 public UpdateImportanceRunnable(INotificationManager notificationManager, 530 String packageName, int appUid, @Nullable NotificationChannel channelToUpdate, 531 int currentImportance, int newImportance) { 532 mINotificationManager = notificationManager; 533 mPackageName = packageName; 534 mAppUid = appUid; 535 mChannelToUpdate = channelToUpdate; 536 mCurrentImportance = currentImportance; 537 mNewImportance = newImportance; 538 } 539 540 @Override 541 public void run() { 542 try { 543 if (mChannelToUpdate != null) { 544 mChannelToUpdate.setImportance(mNewImportance); 545 mChannelToUpdate.lockFields(NotificationChannel.USER_LOCKED_IMPORTANCE); 546 mINotificationManager.updateNotificationChannelForPackage( 547 mPackageName, mAppUid, mChannelToUpdate); 548 } else { 549 // For notifications with more than one channel, update notification enabled 550 // state. If the importance was lowered, we disable notifications. 551 mINotificationManager.setNotificationsEnabledWithImportanceLockForPackage( 552 mPackageName, mAppUid, mNewImportance >= mCurrentImportance); 553 } 554 } catch (RemoteException e) { 555 Log.e(TAG, "Unable to update notification importance", e); 556 } 557 } 558 } 559 } 560