1 /* 2 * Copyright (C) 2018 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.bubbles; 18 19 import static android.view.Display.INVALID_DISPLAY; 20 21 import static com.android.systemui.bubbles.BubbleController.DEBUG_ENABLE_AUTO_BUBBLE; 22 23 import android.annotation.Nullable; 24 import android.app.ActivityOptions; 25 import android.app.ActivityView; 26 import android.app.INotificationManager; 27 import android.app.Notification; 28 import android.app.PendingIntent; 29 import android.content.Context; 30 import android.content.Intent; 31 import android.content.pm.ApplicationInfo; 32 import android.content.pm.PackageManager; 33 import android.content.res.Resources; 34 import android.content.res.TypedArray; 35 import android.graphics.Color; 36 import android.graphics.Insets; 37 import android.graphics.Point; 38 import android.graphics.drawable.Drawable; 39 import android.graphics.drawable.ShapeDrawable; 40 import android.os.ServiceManager; 41 import android.os.UserHandle; 42 import android.provider.Settings; 43 import android.service.notification.StatusBarNotification; 44 import android.util.AttributeSet; 45 import android.util.Log; 46 import android.util.StatsLog; 47 import android.view.View; 48 import android.view.ViewGroup; 49 import android.view.WindowInsets; 50 import android.widget.LinearLayout; 51 52 import com.android.internal.policy.ScreenDecorationsUtils; 53 import com.android.systemui.Dependency; 54 import com.android.systemui.R; 55 import com.android.systemui.recents.TriangleShape; 56 import com.android.systemui.statusbar.AlphaOptimizedButton; 57 import com.android.systemui.statusbar.notification.collection.NotificationEntry; 58 import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow; 59 import com.android.systemui.statusbar.notification.stack.ExpandableViewState; 60 61 /** 62 * Container for the expanded bubble view, handles rendering the caret and settings icon. 63 */ 64 public class BubbleExpandedView extends LinearLayout implements View.OnClickListener { 65 private static final String TAG = "BubbleExpandedView"; 66 67 // The triangle pointing to the expanded view 68 private View mPointerView; 69 private int mPointerMargin; 70 71 private AlphaOptimizedButton mSettingsIcon; 72 73 // Views for expanded state 74 private ExpandableNotificationRow mNotifRow; 75 private ActivityView mActivityView; 76 77 private boolean mActivityViewReady = false; 78 private PendingIntent mBubbleIntent; 79 80 private boolean mKeyboardVisible; 81 private boolean mNeedsNewHeight; 82 83 private int mMinHeight; 84 private int mSettingsIconHeight; 85 private int mBubbleHeight; 86 private int mPointerWidth; 87 private int mPointerHeight; 88 private ShapeDrawable mPointerDrawable; 89 90 private NotificationEntry mEntry; 91 private PackageManager mPm; 92 private String mAppName; 93 private Drawable mAppIcon; 94 95 private INotificationManager mNotificationManagerService; 96 private BubbleController mBubbleController = Dependency.get(BubbleController.class); 97 98 private BubbleStackView mStackView; 99 100 private BubbleExpandedView.OnBubbleBlockedListener mOnBubbleBlockedListener; 101 102 private ActivityView.StateCallback mStateCallback = new ActivityView.StateCallback() { 103 @Override 104 public void onActivityViewReady(ActivityView view) { 105 if (!mActivityViewReady) { 106 mActivityViewReady = true; 107 // Custom options so there is no activity transition animation 108 ActivityOptions options = ActivityOptions.makeCustomAnimation(getContext(), 109 0 /* enterResId */, 0 /* exitResId */); 110 // Post to keep the lifecycle normal 111 post(() -> mActivityView.startActivity(mBubbleIntent, options)); 112 } 113 } 114 115 @Override 116 public void onActivityViewDestroyed(ActivityView view) { 117 mActivityViewReady = false; 118 } 119 120 /** 121 * This is only called for tasks on this ActivityView, which is also set to 122 * single-task mode -- meaning never more than one task on this display. If a task 123 * is being removed, it's the top Activity finishing and this bubble should 124 * be removed or collapsed. 125 */ 126 @Override 127 public void onTaskRemovalStarted(int taskId) { 128 if (mEntry != null) { 129 // Must post because this is called from a binder thread. 130 post(() -> mBubbleController.removeBubble(mEntry.key, 131 BubbleController.DISMISS_TASK_FINISHED)); 132 } 133 } 134 }; 135 BubbleExpandedView(Context context)136 public BubbleExpandedView(Context context) { 137 this(context, null); 138 } 139 BubbleExpandedView(Context context, AttributeSet attrs)140 public BubbleExpandedView(Context context, AttributeSet attrs) { 141 this(context, attrs, 0); 142 } 143 BubbleExpandedView(Context context, AttributeSet attrs, int defStyleAttr)144 public BubbleExpandedView(Context context, AttributeSet attrs, int defStyleAttr) { 145 this(context, attrs, defStyleAttr, 0); 146 } 147 BubbleExpandedView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)148 public BubbleExpandedView(Context context, AttributeSet attrs, int defStyleAttr, 149 int defStyleRes) { 150 super(context, attrs, defStyleAttr, defStyleRes); 151 mPm = context.getPackageManager(); 152 mMinHeight = getResources().getDimensionPixelSize( 153 R.dimen.bubble_expanded_default_height); 154 mPointerMargin = getResources().getDimensionPixelSize(R.dimen.bubble_pointer_margin); 155 try { 156 mNotificationManagerService = INotificationManager.Stub.asInterface( 157 ServiceManager.getServiceOrThrow(Context.NOTIFICATION_SERVICE)); 158 } catch (ServiceManager.ServiceNotFoundException e) { 159 Log.w(TAG, e); 160 } 161 } 162 163 @Override onFinishInflate()164 protected void onFinishInflate() { 165 super.onFinishInflate(); 166 167 Resources res = getResources(); 168 mPointerView = findViewById(R.id.pointer_view); 169 mPointerWidth = res.getDimensionPixelSize(R.dimen.bubble_pointer_width); 170 mPointerHeight = res.getDimensionPixelSize(R.dimen.bubble_pointer_height); 171 172 173 mPointerDrawable = new ShapeDrawable(TriangleShape.create( 174 mPointerWidth, mPointerHeight, true /* pointUp */)); 175 mPointerView.setBackground(mPointerDrawable); 176 mPointerView.setVisibility(GONE); 177 178 mSettingsIconHeight = getContext().getResources().getDimensionPixelSize( 179 R.dimen.bubble_expanded_header_height); 180 mSettingsIcon = findViewById(R.id.settings_button); 181 mSettingsIcon.setOnClickListener(this); 182 183 mActivityView = new ActivityView(mContext, null /* attrs */, 0 /* defStyle */, 184 true /* singleTaskInstance */); 185 addView(mActivityView); 186 187 // Expanded stack layout, top to bottom: 188 // Expanded view container 189 // ==> bubble row 190 // ==> expanded view 191 // ==> activity view 192 // ==> manage button 193 bringChildToFront(mActivityView); 194 bringChildToFront(mSettingsIcon); 195 196 applyThemeAttrs(); 197 198 setOnApplyWindowInsetsListener((View view, WindowInsets insets) -> { 199 // Keep track of IME displaying because we should not make any adjustments that might 200 // cause a config change while the IME is displayed otherwise it'll loose focus. 201 final int keyboardHeight = insets.getSystemWindowInsetBottom() 202 - insets.getStableInsetBottom(); 203 mKeyboardVisible = keyboardHeight != 0; 204 if (!mKeyboardVisible && mNeedsNewHeight) { 205 updateHeight(); 206 } 207 return view.onApplyWindowInsets(insets); 208 }); 209 } 210 applyThemeAttrs()211 void applyThemeAttrs() { 212 TypedArray ta = getContext().obtainStyledAttributes(R.styleable.BubbleExpandedView); 213 int bgColor = ta.getColor( 214 R.styleable.BubbleExpandedView_android_colorBackgroundFloating, Color.WHITE); 215 float cornerRadius = ta.getDimension( 216 R.styleable.BubbleExpandedView_android_dialogCornerRadius, 0); 217 ta.recycle(); 218 219 // Update triangle color. 220 mPointerDrawable.setTint(bgColor); 221 222 // Update ActivityView cornerRadius 223 if (ScreenDecorationsUtils.supportsRoundedCornersOnWindows(mContext.getResources())) { 224 mActivityView.setCornerRadius(cornerRadius); 225 } 226 } 227 228 @Override onDetachedFromWindow()229 protected void onDetachedFromWindow() { 230 super.onDetachedFromWindow(); 231 mKeyboardVisible = false; 232 mNeedsNewHeight = false; 233 if (mActivityView != null) { 234 mActivityView.setForwardedInsets(Insets.of(0, 0, 0, 0)); 235 } 236 } 237 238 /** 239 * Called by {@link BubbleStackView} when the insets for the expanded state should be updated. 240 * This should be done post-move and post-animation. 241 */ updateInsets(WindowInsets insets)242 void updateInsets(WindowInsets insets) { 243 if (usingActivityView()) { 244 Point displaySize = new Point(); 245 mActivityView.getContext().getDisplay().getSize(displaySize); 246 int[] windowLocation = mActivityView.getLocationOnScreen(); 247 final int windowBottom = windowLocation[1] + mActivityView.getHeight(); 248 final int keyboardHeight = insets.getSystemWindowInsetBottom() 249 - insets.getStableInsetBottom(); 250 final int insetsBottom = Math.max(0, 251 windowBottom + keyboardHeight - displaySize.y); 252 mActivityView.setForwardedInsets(Insets.of(0, 0, 0, insetsBottom)); 253 } 254 } 255 256 /** 257 * Sets the listener to notify when a bubble has been blocked. 258 */ setOnBlockedListener(OnBubbleBlockedListener listener)259 public void setOnBlockedListener(OnBubbleBlockedListener listener) { 260 mOnBubbleBlockedListener = listener; 261 } 262 263 /** 264 * Sets the notification entry used to populate this view. 265 */ setEntry(NotificationEntry entry, BubbleStackView stackView, String appName)266 public void setEntry(NotificationEntry entry, BubbleStackView stackView, String appName) { 267 mStackView = stackView; 268 mEntry = entry; 269 mAppName = appName; 270 271 ApplicationInfo info; 272 try { 273 info = mPm.getApplicationInfo( 274 entry.notification.getPackageName(), 275 PackageManager.MATCH_UNINSTALLED_PACKAGES 276 | PackageManager.MATCH_DISABLED_COMPONENTS 277 | PackageManager.MATCH_DIRECT_BOOT_UNAWARE 278 | PackageManager.MATCH_DIRECT_BOOT_AWARE); 279 if (info != null) { 280 mAppIcon = mPm.getApplicationIcon(info); 281 } 282 } catch (PackageManager.NameNotFoundException e) { 283 // Do nothing. 284 } 285 if (mAppIcon == null) { 286 mAppIcon = mPm.getDefaultActivityIcon(); 287 } 288 applyThemeAttrs(); 289 showSettingsIcon(); 290 updateExpandedView(); 291 } 292 293 /** 294 * Lets activity view know it should be shown / populated. 295 */ populateExpandedView()296 public void populateExpandedView() { 297 if (usingActivityView()) { 298 mActivityView.setCallback(mStateCallback); 299 } else { 300 // We're using notification template 301 ViewGroup parent = (ViewGroup) mNotifRow.getParent(); 302 if (parent == this) { 303 // Already added 304 return; 305 } else if (parent != null) { 306 // Still in the shade... remove it 307 parent.removeView(mNotifRow); 308 } 309 addView(mNotifRow, 1 /* index */); 310 } 311 } 312 313 /** 314 * Updates the entry backing this view. This will not re-populate ActivityView, it will 315 * only update the deep-links in the title, and the height of the view. 316 */ update(NotificationEntry entry)317 public void update(NotificationEntry entry) { 318 if (entry.key.equals(mEntry.key)) { 319 mEntry = entry; 320 updateSettingsContentDescription(); 321 updateHeight(); 322 } else { 323 Log.w(TAG, "Trying to update entry with different key, new entry: " 324 + entry.key + " old entry: " + mEntry.key); 325 } 326 } 327 updateExpandedView()328 private void updateExpandedView() { 329 mBubbleIntent = getBubbleIntent(mEntry); 330 if (mBubbleIntent != null) { 331 if (mNotifRow != null) { 332 // Clear out the row if we had it previously 333 removeView(mNotifRow); 334 mNotifRow = null; 335 } 336 mActivityView.setVisibility(VISIBLE); 337 } else if (DEBUG_ENABLE_AUTO_BUBBLE) { 338 // Hide activity view if we had it previously 339 mActivityView.setVisibility(GONE); 340 mNotifRow = mEntry.getRow(); 341 } 342 updateView(); 343 } 344 performBackPressIfNeeded()345 boolean performBackPressIfNeeded() { 346 if (!usingActivityView()) { 347 return false; 348 } 349 mActivityView.performBackPress(); 350 return true; 351 } 352 updateHeight()353 void updateHeight() { 354 if (usingActivityView()) { 355 Notification.BubbleMetadata data = mEntry.getBubbleMetadata(); 356 float desiredHeight; 357 if (data == null) { 358 // This is a contentIntent based bubble, lets allow it to be the max height 359 // as it was forced into this mode and not prepared to be small 360 desiredHeight = mStackView.getMaxExpandedHeight(); 361 } else { 362 boolean useRes = data.getDesiredHeightResId() != 0; 363 float desiredPx; 364 if (useRes) { 365 desiredPx = getDimenForPackageUser(data.getDesiredHeightResId(), 366 mEntry.notification.getPackageName(), 367 mEntry.notification.getUser().getIdentifier()); 368 } else { 369 desiredPx = data.getDesiredHeight() 370 * getContext().getResources().getDisplayMetrics().density; 371 } 372 desiredHeight = desiredPx > 0 ? desiredPx : mMinHeight; 373 } 374 int max = mStackView.getMaxExpandedHeight() - mSettingsIconHeight - mPointerHeight 375 - mPointerMargin; 376 float height = Math.min(desiredHeight, max); 377 height = Math.max(height, mMinHeight); 378 LayoutParams lp = (LayoutParams) mActivityView.getLayoutParams(); 379 mNeedsNewHeight = lp.height != height; 380 if (!mKeyboardVisible) { 381 // If the keyboard is visible... don't adjust the height because that will cause 382 // a configuration change and the keyboard will be lost. 383 lp.height = (int) height; 384 mBubbleHeight = (int) height; 385 mActivityView.setLayoutParams(lp); 386 mNeedsNewHeight = false; 387 } 388 } else { 389 mBubbleHeight = mNotifRow != null ? mNotifRow.getIntrinsicHeight() : mMinHeight; 390 } 391 } 392 393 @Override onClick(View view)394 public void onClick(View view) { 395 if (mEntry == null) { 396 return; 397 } 398 Notification n = mEntry.notification.getNotification(); 399 int id = view.getId(); 400 if (id == R.id.settings_button) { 401 Intent intent = getSettingsIntent(mEntry.notification.getPackageName(), 402 mEntry.notification.getUid()); 403 mStackView.collapseStack(() -> { 404 mContext.startActivityAsUser(intent, mEntry.notification.getUser()); 405 logBubbleClickEvent(mEntry, 406 StatsLog.BUBBLE_UICHANGED__ACTION__HEADER_GO_TO_SETTINGS); 407 }); 408 } 409 } 410 updateSettingsContentDescription()411 private void updateSettingsContentDescription() { 412 mSettingsIcon.setContentDescription(getResources().getString( 413 R.string.bubbles_settings_button_description, mAppName)); 414 } 415 showSettingsIcon()416 void showSettingsIcon() { 417 updateSettingsContentDescription(); 418 mSettingsIcon.setVisibility(VISIBLE); 419 } 420 421 /** 422 * Update appearance of the expanded view being displayed. 423 */ updateView()424 public void updateView() { 425 if (usingActivityView() 426 && mActivityView.getVisibility() == VISIBLE 427 && mActivityView.isAttachedToWindow()) { 428 mActivityView.onLocationChanged(); 429 } else if (mNotifRow != null) { 430 applyRowState(mNotifRow); 431 } 432 updateHeight(); 433 } 434 435 /** 436 * Set the x position that the tip of the triangle should point to. 437 */ setPointerPosition(float x)438 public void setPointerPosition(float x) { 439 float halfPointerWidth = mPointerWidth / 2f; 440 float pointerLeft = x - halfPointerWidth; 441 mPointerView.setTranslationX(pointerLeft); 442 mPointerView.setVisibility(VISIBLE); 443 } 444 445 /** 446 * Removes and releases an ActivityView if one was previously created for this bubble. 447 */ cleanUpExpandedState()448 public void cleanUpExpandedState() { 449 removeView(mNotifRow); 450 451 if (mActivityView == null) { 452 return; 453 } 454 if (mActivityViewReady) { 455 mActivityView.release(); 456 } 457 removeView(mActivityView); 458 mActivityView = null; 459 mActivityViewReady = false; 460 } 461 usingActivityView()462 private boolean usingActivityView() { 463 return mBubbleIntent != null && mActivityView != null; 464 } 465 466 /** 467 * @return the display id of the virtual display. 468 */ getVirtualDisplayId()469 public int getVirtualDisplayId() { 470 if (usingActivityView()) { 471 return mActivityView.getVirtualDisplayId(); 472 } 473 return INVALID_DISPLAY; 474 } 475 applyRowState(ExpandableNotificationRow view)476 private void applyRowState(ExpandableNotificationRow view) { 477 view.reset(); 478 view.setHeadsUp(false); 479 view.resetTranslation(); 480 view.setOnKeyguard(false); 481 view.setOnAmbient(false); 482 view.setClipBottomAmount(0); 483 view.setClipTopAmount(0); 484 view.setContentTransformationAmount(0, false); 485 view.setIconsVisible(true); 486 487 // TODO - Need to reset this (and others) when view goes back in shade, leave for now 488 // view.setTopRoundness(1, false); 489 // view.setBottomRoundness(1, false); 490 491 ExpandableViewState viewState = view.getViewState(); 492 viewState = viewState == null ? new ExpandableViewState() : viewState; 493 viewState.height = view.getIntrinsicHeight(); 494 viewState.gone = false; 495 viewState.hidden = false; 496 viewState.dimmed = false; 497 viewState.dark = false; 498 viewState.alpha = 1f; 499 viewState.notGoneIndex = -1; 500 viewState.xTranslation = 0; 501 viewState.yTranslation = 0; 502 viewState.zTranslation = 0; 503 viewState.scaleX = 1; 504 viewState.scaleY = 1; 505 viewState.inShelf = true; 506 viewState.headsUpIsVisible = false; 507 viewState.applyToView(view); 508 } 509 getSettingsIntent(String packageName, final int appUid)510 private Intent getSettingsIntent(String packageName, final int appUid) { 511 final Intent intent = new Intent(Settings.ACTION_APP_NOTIFICATION_BUBBLE_SETTINGS); 512 intent.putExtra(Settings.EXTRA_APP_PACKAGE, packageName); 513 intent.putExtra(Settings.EXTRA_APP_UID, appUid); 514 intent.addFlags(Intent.FLAG_ACTIVITY_MULTIPLE_TASK); 515 intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); 516 intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); 517 return intent; 518 } 519 520 @Nullable getBubbleIntent(NotificationEntry entry)521 private PendingIntent getBubbleIntent(NotificationEntry entry) { 522 Notification notif = entry.notification.getNotification(); 523 Notification.BubbleMetadata data = notif.getBubbleMetadata(); 524 if (BubbleController.canLaunchInActivityView(mContext, entry) && data != null) { 525 return data.getIntent(); 526 } 527 return null; 528 } 529 530 /** 531 * Listener that is notified when a bubble is blocked. 532 */ 533 public interface OnBubbleBlockedListener { 534 /** 535 * Called when a bubble is blocked for the provided entry. 536 */ onBubbleBlocked(NotificationEntry entry)537 void onBubbleBlocked(NotificationEntry entry); 538 } 539 540 /** 541 * Logs bubble UI click event. 542 * 543 * @param entry the bubble notification entry that user is interacting with. 544 * @param action the user interaction enum. 545 */ logBubbleClickEvent(NotificationEntry entry, int action)546 private void logBubbleClickEvent(NotificationEntry entry, int action) { 547 StatusBarNotification notification = entry.notification; 548 StatsLog.write(StatsLog.BUBBLE_UI_CHANGED, 549 notification.getPackageName(), 550 notification.getNotification().getChannelId(), 551 notification.getId(), 552 mStackView.getBubbleIndex(mStackView.getExpandedBubble()), 553 mStackView.getBubbleCount(), 554 action, 555 mStackView.getNormalizedXPosition(), 556 mStackView.getNormalizedYPosition(), 557 entry.showInShadeWhenBubble(), 558 entry.isForegroundService(), 559 BubbleController.isForegroundApp(mContext, notification.getPackageName())); 560 } 561 getDimenForPackageUser(int resId, String pkg, int userId)562 private int getDimenForPackageUser(int resId, String pkg, int userId) { 563 Resources r; 564 if (pkg != null) { 565 try { 566 if (userId == UserHandle.USER_ALL) { 567 userId = UserHandle.USER_SYSTEM; 568 } 569 r = mPm.getResourcesForApplicationAsUser(pkg, userId); 570 return r.getDimensionPixelSize(resId); 571 } catch (PackageManager.NameNotFoundException ex) { 572 // Uninstalled, don't care 573 } catch (Resources.NotFoundException e) { 574 // Invalid res id, return 0 and user our default 575 Log.e(TAG, "Couldn't find desired height res id", e); 576 } 577 } 578 return 0; 579 } 580 } 581