Home | History | Annotate | Download | only in bubbles
      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 
    136     public BubbleExpandedView(Context context) {
    137         this(context, null);
    138     }
    139 
    140     public BubbleExpandedView(Context context, AttributeSet attrs) {
    141         this(context, attrs, 0);
    142     }
    143 
    144     public BubbleExpandedView(Context context, AttributeSet attrs, int defStyleAttr) {
    145         this(context, attrs, defStyleAttr, 0);
    146     }
    147 
    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
    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 
    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
    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      */
    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      */
    259     public void setOnBlockedListener(OnBubbleBlockedListener listener) {
    260         mOnBubbleBlockedListener = listener;
    261     }
    262 
    263     /**
    264      * Sets the notification entry used to populate this view.
    265      */
    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      */
    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      */
    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 
    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 
    345     boolean performBackPressIfNeeded() {
    346         if (!usingActivityView()) {
    347             return false;
    348         }
    349         mActivityView.performBackPress();
    350         return true;
    351     }
    352 
    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
    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 
    411     private void updateSettingsContentDescription() {
    412         mSettingsIcon.setContentDescription(getResources().getString(
    413                 R.string.bubbles_settings_button_description, mAppName));
    414     }
    415 
    416     void showSettingsIcon() {
    417         updateSettingsContentDescription();
    418         mSettingsIcon.setVisibility(VISIBLE);
    419     }
    420 
    421     /**
    422      * Update appearance of the expanded view being displayed.
    423      */
    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      */
    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      */
    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 
    462     private boolean usingActivityView() {
    463         return mBubbleIntent != null && mActivityView != null;
    464     }
    465 
    466     /**
    467      * @return the display id of the virtual display.
    468      */
    469     public int getVirtualDisplayId() {
    470         if (usingActivityView()) {
    471             return mActivityView.getVirtualDisplayId();
    472         }
    473         return INVALID_DISPLAY;
    474     }
    475 
    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 
    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
    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          */
    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      */
    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 
    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