/* * Copyright (C) 2018 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.systemui.bubbles; import static android.view.Display.INVALID_DISPLAY; import static com.android.systemui.bubbles.BubbleController.DEBUG_ENABLE_AUTO_BUBBLE; import android.annotation.Nullable; import android.app.ActivityOptions; import android.app.ActivityView; import android.app.INotificationManager; import android.app.Notification; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; import android.content.res.Resources; import android.content.res.TypedArray; import android.graphics.Color; import android.graphics.Insets; import android.graphics.Point; import android.graphics.drawable.Drawable; import android.graphics.drawable.ShapeDrawable; import android.os.ServiceManager; import android.os.UserHandle; import android.provider.Settings; import android.service.notification.StatusBarNotification; import android.util.AttributeSet; import android.util.Log; import android.util.StatsLog; import android.view.View; import android.view.ViewGroup; import android.view.WindowInsets; import android.widget.LinearLayout; import com.android.internal.policy.ScreenDecorationsUtils; import com.android.systemui.Dependency; import com.android.systemui.R; import com.android.systemui.recents.TriangleShape; import com.android.systemui.statusbar.AlphaOptimizedButton; import com.android.systemui.statusbar.notification.collection.NotificationEntry; import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow; import com.android.systemui.statusbar.notification.stack.ExpandableViewState; /** * Container for the expanded bubble view, handles rendering the caret and settings icon. */ public class BubbleExpandedView extends LinearLayout implements View.OnClickListener { private static final String TAG = "BubbleExpandedView"; // The triangle pointing to the expanded view private View mPointerView; private int mPointerMargin; private AlphaOptimizedButton mSettingsIcon; // Views for expanded state private ExpandableNotificationRow mNotifRow; private ActivityView mActivityView; private boolean mActivityViewReady = false; private PendingIntent mBubbleIntent; private boolean mKeyboardVisible; private boolean mNeedsNewHeight; private int mMinHeight; private int mSettingsIconHeight; private int mBubbleHeight; private int mPointerWidth; private int mPointerHeight; private ShapeDrawable mPointerDrawable; private NotificationEntry mEntry; private PackageManager mPm; private String mAppName; private Drawable mAppIcon; private INotificationManager mNotificationManagerService; private BubbleController mBubbleController = Dependency.get(BubbleController.class); private BubbleStackView mStackView; private BubbleExpandedView.OnBubbleBlockedListener mOnBubbleBlockedListener; private ActivityView.StateCallback mStateCallback = new ActivityView.StateCallback() { @Override public void onActivityViewReady(ActivityView view) { if (!mActivityViewReady) { mActivityViewReady = true; // Custom options so there is no activity transition animation ActivityOptions options = ActivityOptions.makeCustomAnimation(getContext(), 0 /* enterResId */, 0 /* exitResId */); // Post to keep the lifecycle normal post(() -> mActivityView.startActivity(mBubbleIntent, options)); } } @Override public void onActivityViewDestroyed(ActivityView view) { mActivityViewReady = false; } /** * This is only called for tasks on this ActivityView, which is also set to * single-task mode -- meaning never more than one task on this display. If a task * is being removed, it's the top Activity finishing and this bubble should * be removed or collapsed. */ @Override public void onTaskRemovalStarted(int taskId) { if (mEntry != null) { // Must post because this is called from a binder thread. post(() -> mBubbleController.removeBubble(mEntry.key, BubbleController.DISMISS_TASK_FINISHED)); } } }; public BubbleExpandedView(Context context) { this(context, null); } public BubbleExpandedView(Context context, AttributeSet attrs) { this(context, attrs, 0); } public BubbleExpandedView(Context context, AttributeSet attrs, int defStyleAttr) { this(context, attrs, defStyleAttr, 0); } public BubbleExpandedView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); mPm = context.getPackageManager(); mMinHeight = getResources().getDimensionPixelSize( R.dimen.bubble_expanded_default_height); mPointerMargin = getResources().getDimensionPixelSize(R.dimen.bubble_pointer_margin); try { mNotificationManagerService = INotificationManager.Stub.asInterface( ServiceManager.getServiceOrThrow(Context.NOTIFICATION_SERVICE)); } catch (ServiceManager.ServiceNotFoundException e) { Log.w(TAG, e); } } @Override protected void onFinishInflate() { super.onFinishInflate(); Resources res = getResources(); mPointerView = findViewById(R.id.pointer_view); mPointerWidth = res.getDimensionPixelSize(R.dimen.bubble_pointer_width); mPointerHeight = res.getDimensionPixelSize(R.dimen.bubble_pointer_height); mPointerDrawable = new ShapeDrawable(TriangleShape.create( mPointerWidth, mPointerHeight, true /* pointUp */)); mPointerView.setBackground(mPointerDrawable); mPointerView.setVisibility(GONE); mSettingsIconHeight = getContext().getResources().getDimensionPixelSize( R.dimen.bubble_expanded_header_height); mSettingsIcon = findViewById(R.id.settings_button); mSettingsIcon.setOnClickListener(this); mActivityView = new ActivityView(mContext, null /* attrs */, 0 /* defStyle */, true /* singleTaskInstance */); addView(mActivityView); // Expanded stack layout, top to bottom: // Expanded view container // ==> bubble row // ==> expanded view // ==> activity view // ==> manage button bringChildToFront(mActivityView); bringChildToFront(mSettingsIcon); applyThemeAttrs(); setOnApplyWindowInsetsListener((View view, WindowInsets insets) -> { // Keep track of IME displaying because we should not make any adjustments that might // cause a config change while the IME is displayed otherwise it'll loose focus. final int keyboardHeight = insets.getSystemWindowInsetBottom() - insets.getStableInsetBottom(); mKeyboardVisible = keyboardHeight != 0; if (!mKeyboardVisible && mNeedsNewHeight) { updateHeight(); } return view.onApplyWindowInsets(insets); }); } void applyThemeAttrs() { TypedArray ta = getContext().obtainStyledAttributes(R.styleable.BubbleExpandedView); int bgColor = ta.getColor( R.styleable.BubbleExpandedView_android_colorBackgroundFloating, Color.WHITE); float cornerRadius = ta.getDimension( R.styleable.BubbleExpandedView_android_dialogCornerRadius, 0); ta.recycle(); // Update triangle color. mPointerDrawable.setTint(bgColor); // Update ActivityView cornerRadius if (ScreenDecorationsUtils.supportsRoundedCornersOnWindows(mContext.getResources())) { mActivityView.setCornerRadius(cornerRadius); } } @Override protected void onDetachedFromWindow() { super.onDetachedFromWindow(); mKeyboardVisible = false; mNeedsNewHeight = false; if (mActivityView != null) { mActivityView.setForwardedInsets(Insets.of(0, 0, 0, 0)); } } /** * Called by {@link BubbleStackView} when the insets for the expanded state should be updated. * This should be done post-move and post-animation. */ void updateInsets(WindowInsets insets) { if (usingActivityView()) { Point displaySize = new Point(); mActivityView.getContext().getDisplay().getSize(displaySize); int[] windowLocation = mActivityView.getLocationOnScreen(); final int windowBottom = windowLocation[1] + mActivityView.getHeight(); final int keyboardHeight = insets.getSystemWindowInsetBottom() - insets.getStableInsetBottom(); final int insetsBottom = Math.max(0, windowBottom + keyboardHeight - displaySize.y); mActivityView.setForwardedInsets(Insets.of(0, 0, 0, insetsBottom)); } } /** * Sets the listener to notify when a bubble has been blocked. */ public void setOnBlockedListener(OnBubbleBlockedListener listener) { mOnBubbleBlockedListener = listener; } /** * Sets the notification entry used to populate this view. */ public void setEntry(NotificationEntry entry, BubbleStackView stackView, String appName) { mStackView = stackView; mEntry = entry; mAppName = appName; ApplicationInfo info; try { info = mPm.getApplicationInfo( entry.notification.getPackageName(), PackageManager.MATCH_UNINSTALLED_PACKAGES | PackageManager.MATCH_DISABLED_COMPONENTS | PackageManager.MATCH_DIRECT_BOOT_UNAWARE | PackageManager.MATCH_DIRECT_BOOT_AWARE); if (info != null) { mAppIcon = mPm.getApplicationIcon(info); } } catch (PackageManager.NameNotFoundException e) { // Do nothing. } if (mAppIcon == null) { mAppIcon = mPm.getDefaultActivityIcon(); } applyThemeAttrs(); showSettingsIcon(); updateExpandedView(); } /** * Lets activity view know it should be shown / populated. */ public void populateExpandedView() { if (usingActivityView()) { mActivityView.setCallback(mStateCallback); } else { // We're using notification template ViewGroup parent = (ViewGroup) mNotifRow.getParent(); if (parent == this) { // Already added return; } else if (parent != null) { // Still in the shade... remove it parent.removeView(mNotifRow); } addView(mNotifRow, 1 /* index */); } } /** * Updates the entry backing this view. This will not re-populate ActivityView, it will * only update the deep-links in the title, and the height of the view. */ public void update(NotificationEntry entry) { if (entry.key.equals(mEntry.key)) { mEntry = entry; updateSettingsContentDescription(); updateHeight(); } else { Log.w(TAG, "Trying to update entry with different key, new entry: " + entry.key + " old entry: " + mEntry.key); } } private void updateExpandedView() { mBubbleIntent = getBubbleIntent(mEntry); if (mBubbleIntent != null) { if (mNotifRow != null) { // Clear out the row if we had it previously removeView(mNotifRow); mNotifRow = null; } mActivityView.setVisibility(VISIBLE); } else if (DEBUG_ENABLE_AUTO_BUBBLE) { // Hide activity view if we had it previously mActivityView.setVisibility(GONE); mNotifRow = mEntry.getRow(); } updateView(); } boolean performBackPressIfNeeded() { if (!usingActivityView()) { return false; } mActivityView.performBackPress(); return true; } void updateHeight() { if (usingActivityView()) { Notification.BubbleMetadata data = mEntry.getBubbleMetadata(); float desiredHeight; if (data == null) { // This is a contentIntent based bubble, lets allow it to be the max height // as it was forced into this mode and not prepared to be small desiredHeight = mStackView.getMaxExpandedHeight(); } else { boolean useRes = data.getDesiredHeightResId() != 0; float desiredPx; if (useRes) { desiredPx = getDimenForPackageUser(data.getDesiredHeightResId(), mEntry.notification.getPackageName(), mEntry.notification.getUser().getIdentifier()); } else { desiredPx = data.getDesiredHeight() * getContext().getResources().getDisplayMetrics().density; } desiredHeight = desiredPx > 0 ? desiredPx : mMinHeight; } int max = mStackView.getMaxExpandedHeight() - mSettingsIconHeight - mPointerHeight - mPointerMargin; float height = Math.min(desiredHeight, max); height = Math.max(height, mMinHeight); LayoutParams lp = (LayoutParams) mActivityView.getLayoutParams(); mNeedsNewHeight = lp.height != height; if (!mKeyboardVisible) { // If the keyboard is visible... don't adjust the height because that will cause // a configuration change and the keyboard will be lost. lp.height = (int) height; mBubbleHeight = (int) height; mActivityView.setLayoutParams(lp); mNeedsNewHeight = false; } } else { mBubbleHeight = mNotifRow != null ? mNotifRow.getIntrinsicHeight() : mMinHeight; } } @Override public void onClick(View view) { if (mEntry == null) { return; } Notification n = mEntry.notification.getNotification(); int id = view.getId(); if (id == R.id.settings_button) { Intent intent = getSettingsIntent(mEntry.notification.getPackageName(), mEntry.notification.getUid()); mStackView.collapseStack(() -> { mContext.startActivityAsUser(intent, mEntry.notification.getUser()); logBubbleClickEvent(mEntry, StatsLog.BUBBLE_UICHANGED__ACTION__HEADER_GO_TO_SETTINGS); }); } } private void updateSettingsContentDescription() { mSettingsIcon.setContentDescription(getResources().getString( R.string.bubbles_settings_button_description, mAppName)); } void showSettingsIcon() { updateSettingsContentDescription(); mSettingsIcon.setVisibility(VISIBLE); } /** * Update appearance of the expanded view being displayed. */ public void updateView() { if (usingActivityView() && mActivityView.getVisibility() == VISIBLE && mActivityView.isAttachedToWindow()) { mActivityView.onLocationChanged(); } else if (mNotifRow != null) { applyRowState(mNotifRow); } updateHeight(); } /** * Set the x position that the tip of the triangle should point to. */ public void setPointerPosition(float x) { float halfPointerWidth = mPointerWidth / 2f; float pointerLeft = x - halfPointerWidth; mPointerView.setTranslationX(pointerLeft); mPointerView.setVisibility(VISIBLE); } /** * Removes and releases an ActivityView if one was previously created for this bubble. */ public void cleanUpExpandedState() { removeView(mNotifRow); if (mActivityView == null) { return; } if (mActivityViewReady) { mActivityView.release(); } removeView(mActivityView); mActivityView = null; mActivityViewReady = false; } private boolean usingActivityView() { return mBubbleIntent != null && mActivityView != null; } /** * @return the display id of the virtual display. */ public int getVirtualDisplayId() { if (usingActivityView()) { return mActivityView.getVirtualDisplayId(); } return INVALID_DISPLAY; } private void applyRowState(ExpandableNotificationRow view) { view.reset(); view.setHeadsUp(false); view.resetTranslation(); view.setOnKeyguard(false); view.setOnAmbient(false); view.setClipBottomAmount(0); view.setClipTopAmount(0); view.setContentTransformationAmount(0, false); view.setIconsVisible(true); // TODO - Need to reset this (and others) when view goes back in shade, leave for now // view.setTopRoundness(1, false); // view.setBottomRoundness(1, false); ExpandableViewState viewState = view.getViewState(); viewState = viewState == null ? new ExpandableViewState() : viewState; viewState.height = view.getIntrinsicHeight(); viewState.gone = false; viewState.hidden = false; viewState.dimmed = false; viewState.dark = false; viewState.alpha = 1f; viewState.notGoneIndex = -1; viewState.xTranslation = 0; viewState.yTranslation = 0; viewState.zTranslation = 0; viewState.scaleX = 1; viewState.scaleY = 1; viewState.inShelf = true; viewState.headsUpIsVisible = false; viewState.applyToView(view); } private Intent getSettingsIntent(String packageName, final int appUid) { final Intent intent = new Intent(Settings.ACTION_APP_NOTIFICATION_BUBBLE_SETTINGS); intent.putExtra(Settings.EXTRA_APP_PACKAGE, packageName); intent.putExtra(Settings.EXTRA_APP_UID, appUid); intent.addFlags(Intent.FLAG_ACTIVITY_MULTIPLE_TASK); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); return intent; } @Nullable private PendingIntent getBubbleIntent(NotificationEntry entry) { Notification notif = entry.notification.getNotification(); Notification.BubbleMetadata data = notif.getBubbleMetadata(); if (BubbleController.canLaunchInActivityView(mContext, entry) && data != null) { return data.getIntent(); } return null; } /** * Listener that is notified when a bubble is blocked. */ public interface OnBubbleBlockedListener { /** * Called when a bubble is blocked for the provided entry. */ void onBubbleBlocked(NotificationEntry entry); } /** * Logs bubble UI click event. * * @param entry the bubble notification entry that user is interacting with. * @param action the user interaction enum. */ private void logBubbleClickEvent(NotificationEntry entry, int action) { StatusBarNotification notification = entry.notification; StatsLog.write(StatsLog.BUBBLE_UI_CHANGED, notification.getPackageName(), notification.getNotification().getChannelId(), notification.getId(), mStackView.getBubbleIndex(mStackView.getExpandedBubble()), mStackView.getBubbleCount(), action, mStackView.getNormalizedXPosition(), mStackView.getNormalizedYPosition(), entry.showInShadeWhenBubble(), entry.isForegroundService(), BubbleController.isForegroundApp(mContext, notification.getPackageName())); } private int getDimenForPackageUser(int resId, String pkg, int userId) { Resources r; if (pkg != null) { try { if (userId == UserHandle.USER_ALL) { userId = UserHandle.USER_SYSTEM; } r = mPm.getResourcesForApplicationAsUser(pkg, userId); return r.getDimensionPixelSize(resId); } catch (PackageManager.NameNotFoundException ex) { // Uninstalled, don't care } catch (Resources.NotFoundException e) { // Invalid res id, return 0 and user our default Log.e(TAG, "Couldn't find desired height res id", e); } } return 0; } }