/* * 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.car.notification; import static android.view.ViewTreeObserver.InternalInsetsInfo; import static android.view.ViewTreeObserver.OnComputeInternalInsetsListener; import static android.view.ViewTreeObserver.OnGlobalFocusChangeListener; import static android.view.ViewTreeObserver.OnGlobalLayoutListener; import static com.android.car.assist.client.CarAssistUtils.isCarCompatibleMessagingNotification; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.AnimatorSet; import android.app.KeyguardManager; import android.app.Notification; import android.app.NotificationChannel; import android.app.NotificationManager; import android.car.drivingstate.CarUxRestrictions; import android.car.drivingstate.CarUxRestrictionsManager; import android.content.Context; import android.os.Build; import android.service.notification.NotificationListenerService; import android.util.Log; import android.util.Pair; import android.view.LayoutInflater; import android.view.View; import android.view.ViewTreeObserver; import androidx.annotation.VisibleForTesting; import com.android.car.notification.headsup.CarHeadsUpNotificationContainer; import com.android.car.notification.headsup.animationhelper.HeadsUpNotificationAnimationHelper; import com.android.car.notification.template.MessageNotificationViewHolder; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; /** * Notification Manager for heads-up notifications in car. */ public class CarHeadsUpNotificationManager implements CarUxRestrictionsManager.OnUxRestrictionsChangedListener { /** * Callback that will be issued after a Heads up notification state is changed. */ public interface OnHeadsUpNotificationStateChange { /** * Will be called if a new notification added/updated changes the heads up state for that * notification. */ void onStateChange(AlertEntry alertEntry, boolean isHeadsUp); } private static final boolean DEBUG = Build.IS_ENG || Build.IS_USERDEBUG; private static final String TAG = CarHeadsUpNotificationManager.class.getSimpleName(); private final Beeper mBeeper; private final Context mContext; private final boolean mEnableNavigationHeadsup; private final long mDuration; private final long mMinDisplayDuration; private HeadsUpNotificationAnimationHelper mAnimationHelper; private final int mNotificationHeadsUpCardMarginTop; private final KeyguardManager mKeyguardManager; private final PreprocessingManager mPreprocessingManager; private final LayoutInflater mInflater; private final CarHeadsUpNotificationContainer mHunContainer; // key for the map is the statusbarnotification key private final Map mActiveHeadsUpNotifications = new HashMap<>(); private final List mNotificationStateChangeListeners = new ArrayList<>(); private final Map> mRegisteredViewTreeListeners = new HashMap<>(); private boolean mShouldRestrictMessagePreview; private NotificationClickHandlerFactory mClickHandlerFactory; private NotificationDataManager mNotificationDataManager; private Set mAlertEntryKeyToRemove = new HashSet<>(); public CarHeadsUpNotificationManager(Context context, NotificationClickHandlerFactory clickHandlerFactory, CarHeadsUpNotificationContainer hunContainer) { mContext = context.getApplicationContext(); mEnableNavigationHeadsup = context.getResources().getBoolean(R.bool.config_showNavigationHeadsup); mClickHandlerFactory = clickHandlerFactory; mNotificationDataManager = NotificationDataManager.getInstance(); mBeeper = new Beeper(mContext); mDuration = mContext.getResources().getInteger(R.integer.headsup_notification_duration_ms); mNotificationHeadsUpCardMarginTop = (int) mContext.getResources().getDimension( R.dimen.headsup_notification_top_margin); mMinDisplayDuration = mContext.getResources().getInteger( R.integer.heads_up_notification_minimum_time); mAnimationHelper = getAnimationHelper(); mKeyguardManager = (KeyguardManager) context.getSystemService(Context.KEYGUARD_SERVICE); mPreprocessingManager = PreprocessingManager.getInstance(context); mInflater = LayoutInflater.from(mContext); mClickHandlerFactory.registerClickListener( (launchResult, alertEntry) -> dismissHun(alertEntry)); mHunContainer = hunContainer; } @VisibleForTesting void setNotificationDataManager(NotificationDataManager notificationDataManager) { mNotificationDataManager = notificationDataManager; } private HeadsUpNotificationAnimationHelper getAnimationHelper() { String helperName = mContext.getResources().getString( R.string.config_headsUpNotificationAnimationHelper); try { Class clazz = Class.forName(helperName); return (HeadsUpNotificationAnimationHelper) clazz.getConstructor().newInstance(); } catch (Exception e) { throw new IllegalArgumentException( String.format("Invalid animation helper: %s", helperName), e); } } /** * Show the notification as a heads-up if it meets the criteria. * *

Return's true if the notification will be shown as a heads up, false otherwise. */ public boolean maybeShowHeadsUp( AlertEntry alertEntry, NotificationListenerService.RankingMap rankingMap, Map activeNotifications) { if (!shouldShowHeadsUp(alertEntry, rankingMap)) { // check if this is an update to the existing notification and if it should still show // as a heads up or not. HeadsUpEntry currentActiveHeadsUpNotification = mActiveHeadsUpNotifications.get( alertEntry.getKey()); if (currentActiveHeadsUpNotification == null) { if (DEBUG) { Log.d(TAG, alertEntry + " is not an active heads up notification"); } return false; } if (CarNotificationDiff.sameNotificationKey(currentActiveHeadsUpNotification, alertEntry) && currentActiveHeadsUpNotification.getHandler().hasMessagesOrCallbacks()) { dismissHun(alertEntry); } return false; } boolean containsKeyFlag = !activeNotifications.containsKey(alertEntry.getKey()); boolean canUpdateFlag = canUpdate(alertEntry); boolean alertAgainFlag = alertAgain(alertEntry.getNotification()); if (DEBUG) { Log.d(TAG, alertEntry + " is an active notification: " + containsKeyFlag); Log.d(TAG, alertEntry + " is an updatable notification: " + canUpdateFlag); Log.d(TAG, alertEntry + " is not an alert once notification: " + alertAgainFlag); } if (containsKeyFlag || canUpdateFlag || alertAgainFlag) { showHeadsUp(mPreprocessingManager.optimizeForDriving(alertEntry), rankingMap); return true; } return false; } /** * This method gets called when an app wants to cancel or withdraw its notification. */ public void maybeRemoveHeadsUp(AlertEntry alertEntry) { HeadsUpEntry currentActiveHeadsUpNotification = mActiveHeadsUpNotifications.get( alertEntry.getKey()); mAlertEntryKeyToRemove.add(alertEntry.getKey()); // if the heads up notification is already removed do nothing. if (currentActiveHeadsUpNotification == null) { return; } long totalDisplayDuration = System.currentTimeMillis() - currentActiveHeadsUpNotification.getPostTime(); // ongoing notification that has passed the minimum threshold display time. if (totalDisplayDuration >= mMinDisplayDuration) { removeHun(alertEntry); return; } long earliestRemovalTime = mMinDisplayDuration - totalDisplayDuration; currentActiveHeadsUpNotification.getHandler().postDelayed(() -> removeHun(alertEntry), earliestRemovalTime); } /** * Registers a new {@link OnHeadsUpNotificationStateChange} to the list of listeners. */ public void registerHeadsUpNotificationStateChangeListener( OnHeadsUpNotificationStateChange listener) { if (!mNotificationStateChangeListeners.contains(listener)) { mNotificationStateChangeListeners.add(listener); } } /** * Unregisters a {@link OnHeadsUpNotificationStateChange} from the list of listeners. */ public void unregisterHeadsUpNotificationStateChangeListener( OnHeadsUpNotificationStateChange listener) { mNotificationStateChangeListeners.remove(listener); } /** * Invokes all OnHeadsUpNotificationStateChange handlers registered in {@link * OnHeadsUpNotificationStateChange}s array. */ private void handleHeadsUpNotificationStateChanged(AlertEntry alertEntry, boolean isHeadsUp) { String alertEntryKey = alertEntry.getKey(); // TODO(b/203784760): Implement a proper why to remove notification by user clicks. boolean scheduledToBeRemoved = mAlertEntryKeyToRemove.contains(alertEntryKey); if (scheduledToBeRemoved) { mAlertEntryKeyToRemove.remove(alertEntryKey); if (!isHeadsUp) { // Skip creation of notification center notification. return; } } mNotificationStateChangeListeners.forEach( listener -> listener.onStateChange(alertEntry, isHeadsUp)); } /** * Returns true if the notification's flag is not set to * {@link Notification#FLAG_ONLY_ALERT_ONCE} */ private boolean alertAgain(Notification newNotification) { return (newNotification.flags & Notification.FLAG_ONLY_ALERT_ONCE) == 0; } /** * Return true if the currently displaying notification have the same key as the new added * notification. In that case it will be considered as an update to the currently displayed * notification. */ private boolean isUpdate(AlertEntry alertEntry) { HeadsUpEntry currentActiveHeadsUpNotification = mActiveHeadsUpNotifications.get( alertEntry.getKey()); if (currentActiveHeadsUpNotification == null) { return false; } return CarNotificationDiff.sameNotificationKey(currentActiveHeadsUpNotification, alertEntry); } /** * Updates only when the notification is being displayed. */ private boolean canUpdate(AlertEntry alertEntry) { HeadsUpEntry currentActiveHeadsUpNotification = mActiveHeadsUpNotifications.get( alertEntry.getKey()); return currentActiveHeadsUpNotification != null && System.currentTimeMillis() - currentActiveHeadsUpNotification.getPostTime() < mDuration; } /** * Returns the active headsUpEntry or creates a new one while adding it to the list of * mActiveHeadsUpNotifications. */ private HeadsUpEntry addNewHeadsUpEntry(AlertEntry alertEntry) { HeadsUpEntry currentActiveHeadsUpNotification = mActiveHeadsUpNotifications.get( alertEntry.getKey()); if (currentActiveHeadsUpNotification == null) { currentActiveHeadsUpNotification = new HeadsUpEntry( alertEntry.getStatusBarNotification()); handleHeadsUpNotificationStateChanged(alertEntry, /* isHeadsUp= */ true); mActiveHeadsUpNotifications.put(alertEntry.getKey(), currentActiveHeadsUpNotification); currentActiveHeadsUpNotification.mIsAlertAgain = alertAgain( alertEntry.getNotification()); currentActiveHeadsUpNotification.mIsNewHeadsUp = true; return currentActiveHeadsUpNotification; } currentActiveHeadsUpNotification.mIsNewHeadsUp = false; currentActiveHeadsUpNotification.mIsAlertAgain = alertAgain( alertEntry.getNotification()); if (currentActiveHeadsUpNotification.mIsAlertAgain) { // This is a ongoing notification which needs to be alerted again to the user. This // requires for the post time to be updated. currentActiveHeadsUpNotification.updatePostTime(); } return currentActiveHeadsUpNotification; } /** * Controls three major conditions while showing heads up notification. *

*

    *
  1. When a new HUN comes in it will be displayed with animations *
  2. If an update to existing HUN comes in which enforces to alert the HUN again to user, * then the post time will be updated to current time. This will only be done if {@link * Notification#FLAG_ONLY_ALERT_ONCE} flag is not set. *
  3. If an update to existing HUN comes in which just updates the data and does not want to * alert itself again, then the animations will not be shown and the data will get updated. This * will only be done if {@link Notification#FLAG_ONLY_ALERT_ONCE} flag is not set. *
*/ private void showHeadsUp(AlertEntry alertEntry, NotificationListenerService.RankingMap rankingMap) { // Show animations only when there is no active HUN and notification is new. This check // needs to be done here because after this the new notification will be added to the map // holding ongoing notifications. boolean shouldShowAnimation = !isUpdate(alertEntry); HeadsUpEntry currentNotification = addNewHeadsUpEntry(alertEntry); if (currentNotification.mIsNewHeadsUp) { playSound(alertEntry, rankingMap); setAutoDismissViews(currentNotification, alertEntry); } else if (currentNotification.mIsAlertAgain) { setAutoDismissViews(currentNotification, alertEntry); } CarNotificationTypeItem notificationTypeItem = NotificationUtils.getNotificationViewType( alertEntry); currentNotification.setClickHandlerFactory(mClickHandlerFactory); if (currentNotification.getNotificationView() == null) { currentNotification.setNotificationView(mInflater.inflate( notificationTypeItem.getHeadsUpTemplate(), null)); mHunContainer.displayNotification(currentNotification.getNotificationView(), notificationTypeItem); currentNotification.setViewHolder( notificationTypeItem.getViewHolder(currentNotification.getNotificationView(), mClickHandlerFactory)); } currentNotification.getViewHolder().setHideDismissButton(!shouldDismissOnSwipe(alertEntry)); if (mShouldRestrictMessagePreview && notificationTypeItem.getNotificationType() == NotificationViewType.MESSAGE) { ((MessageNotificationViewHolder) currentNotification.getViewHolder()) .bindRestricted(alertEntry, /* isInGroup= */ false, /* isHeadsUp= */ true); } else { currentNotification.getViewHolder().bind(alertEntry, /* isInGroup= */false, /* isHeadsUp= */ true); } resetViewTreeListenersEntry(currentNotification); ViewTreeObserver viewTreeObserver = currentNotification.getNotificationView().getViewTreeObserver(); // measure the size of the card and make that area of the screen touchable OnComputeInternalInsetsListener onComputeInternalInsetsListener = info -> setInternalInsetsInfo(info, currentNotification, /* panelExpanded= */ false); viewTreeObserver.addOnComputeInternalInsetsListener(onComputeInternalInsetsListener); // Get the height of the notification view after onLayout() in order to animate the // notification into the screen. viewTreeObserver.addOnGlobalLayoutListener( new OnGlobalLayoutListener() { @Override public void onGlobalLayout() { View view = currentNotification.getNotificationView(); if (shouldShowAnimation) { mAnimationHelper.resetHUNPosition(view); AnimatorSet animatorSet = mAnimationHelper.getAnimateInAnimator( mContext, view); animatorSet.setTarget(view); animatorSet.start(); } view.getViewTreeObserver().removeOnGlobalLayoutListener(this); } }); // Reset the auto dismiss timeout for each rotary event. OnGlobalFocusChangeListener onGlobalFocusChangeListener = (oldFocus, newFocus) -> setAutoDismissViews(currentNotification, alertEntry); viewTreeObserver.addOnGlobalFocusChangeListener(onGlobalFocusChangeListener); mRegisteredViewTreeListeners.put(currentNotification, new Pair<>(onComputeInternalInsetsListener, onGlobalFocusChangeListener)); if (currentNotification.mIsNewHeadsUp) { // Add swipe gesture View cardView = currentNotification.getNotificationView().findViewById(R.id.card_view); cardView.setOnTouchListener(new HeadsUpNotificationOnTouchListener(cardView, shouldDismissOnSwipe(alertEntry), () -> resetView(alertEntry))); // Add dismiss button listener View dismissButton = currentNotification.getNotificationView().findViewById( R.id.dismiss_button); if (dismissButton != null) { dismissButton.setOnClickListener(v -> dismissHun(alertEntry)); } } } private void resetViewTreeListenersEntry(HeadsUpEntry headsUpEntry) { Pair listeners = mRegisteredViewTreeListeners.get(headsUpEntry); if (listeners == null) { return; } ViewTreeObserver observer = headsUpEntry.getNotificationView().getViewTreeObserver(); observer.removeOnComputeInternalInsetsListener(listeners.first); observer.removeOnGlobalFocusChangeListener(listeners.second); mRegisteredViewTreeListeners.remove(headsUpEntry); } protected void setInternalInsetsInfo(InternalInsetsInfo info, HeadsUpEntry currentNotification, boolean panelExpanded) { // If the panel is not on screen don't modify the touch region if (!mHunContainer.isVisible()) return; int[] mTmpTwoArray = new int[2]; View cardView = currentNotification.getNotificationView().findViewById( R.id.card_view); if (cardView == null) return; if (panelExpanded) { info.setTouchableInsets(InternalInsetsInfo.TOUCHABLE_INSETS_FRAME); return; } cardView.getLocationInWindow(mTmpTwoArray); int minX = mTmpTwoArray[0]; int maxX = mTmpTwoArray[0] + cardView.getWidth(); int minY = mTmpTwoArray[1] + mNotificationHeadsUpCardMarginTop; int maxY = mTmpTwoArray[1] + mNotificationHeadsUpCardMarginTop + cardView.getHeight(); info.setTouchableInsets(InternalInsetsInfo.TOUCHABLE_INSETS_REGION); info.touchableRegion.set(minX, minY, maxX, maxY); } private void playSound(AlertEntry alertEntry, NotificationListenerService.RankingMap rankingMap) { NotificationListenerService.Ranking ranking = getRanking(); if (rankingMap.getRanking(alertEntry.getKey(), ranking)) { NotificationChannel notificationChannel = ranking.getChannel(); // If sound is not set on the notification channel and default is not chosen it // can be null. if (notificationChannel.getSound() != null) { // make the sound mBeeper.beep(alertEntry.getStatusBarNotification().getPackageName(), notificationChannel.getSound()); } } } private boolean shouldDismissOnSwipe(AlertEntry alertEntry) { return !(hasFullScreenIntent(alertEntry) && Objects.equals(alertEntry.getNotification().category, Notification.CATEGORY_CALL) && alertEntry.getStatusBarNotification().isOngoing()); } @VisibleForTesting protected Map getActiveHeadsUpNotifications() { return mActiveHeadsUpNotifications; } private void setAutoDismissViews(HeadsUpEntry currentNotification, AlertEntry alertEntry) { // Should not auto dismiss if HUN has a full screen Intent. if (hasFullScreenIntent(alertEntry)) { return; } currentNotification.getHandler().removeCallbacksAndMessages(null); currentNotification.getHandler().postDelayed(() -> dismissHun(alertEntry), mDuration); } /** * Returns true if AlertEntry has a full screen Intent. */ private boolean hasFullScreenIntent(AlertEntry alertEntry) { return alertEntry.getNotification().fullScreenIntent != null; } /** * Animates the heads up notification out of the screen and reset the views. */ private void animateOutHun(AlertEntry alertEntry, boolean isRemoved) { Log.d(TAG, "clearViews for Heads Up Notification: "); // get the current notification to perform animations and remove it immediately from the // active notification maps and cancel all other call backs if any. HeadsUpEntry currentHeadsUpNotification = mActiveHeadsUpNotifications.get( alertEntry.getKey()); // view can also be removed when swiped away. if (currentHeadsUpNotification == null) { return; } currentHeadsUpNotification.getHandler().removeCallbacksAndMessages(null); resetViewTreeListenersEntry(currentHeadsUpNotification); View view = currentHeadsUpNotification.getNotificationView(); AnimatorSet animatorSet = mAnimationHelper.getAnimateOutAnimator(mContext, view); animatorSet.setTarget(view); animatorSet.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { mHunContainer.removeNotification(view); // Remove HUN after the animation ends to prevent accidental touch on the card // triggering another remove call. mActiveHeadsUpNotifications.remove(alertEntry.getKey()); // If the HUN was not specifically removed then add it to the panel. if (!isRemoved) { handleHeadsUpNotificationStateChanged(alertEntry, /* isHeadsUp= */ false); } } }); animatorSet.start(); } private void dismissHun(AlertEntry alertEntry) { animateOutHun(alertEntry, /* isRemoved= */ false); } private void removeHun(AlertEntry alertEntry) { animateOutHun(alertEntry, /* isRemoved= */ true); } /** * Removes the view for the active heads up notification and also removes the HUN from the map * of active Notifications. */ private void resetView(AlertEntry alertEntry) { HeadsUpEntry currentHeadsUpNotification = mActiveHeadsUpNotifications.get( alertEntry.getKey()); if (currentHeadsUpNotification == null) return; currentHeadsUpNotification.getHandler().removeCallbacksAndMessages(null); mHunContainer.removeNotification(currentHeadsUpNotification.getNotificationView()); mActiveHeadsUpNotifications.remove(alertEntry.getKey()); handleHeadsUpNotificationStateChanged(alertEntry, /* isHeadsUp= */ false); resetViewTreeListenersEntry(currentHeadsUpNotification); } /** * Helper method that determines whether a notification should show as a heads-up. * *

A notification will never be shown as a heads-up if: *

    *
  • Keyguard (lock screen) is showing *
  • OEMs configured CATEGORY_NAVIGATION should not be shown *
  • Notification is muted. *
* *

A notification will be shown as a heads-up if: *

    *
  • Importance >= HIGH *
  • it comes from an app signed with the platform key. *
  • it comes from a privileged system app. *
  • is a car compatible notification. * {@link com.android.car.assist.client.CarAssistUtils#isCarCompatibleMessagingNotification} *
  • Notification category is one of CATEGORY_CALL or CATEGORY_NAVIGATION *
* *

Group alert behavior still follows API documentation. * * @return true if a notification should be shown as a heads-up */ private boolean shouldShowHeadsUp( AlertEntry alertEntry, NotificationListenerService.RankingMap rankingMap) { if (mKeyguardManager.isKeyguardLocked()) { if (DEBUG) { Log.d(TAG, "Unable to show as HUN: Keyguard is locked"); } return false; } Notification notification = alertEntry.getNotification(); // Navigation notification configured by OEM if (!mEnableNavigationHeadsup && Notification.CATEGORY_NAVIGATION.equals( notification.category)) { if (DEBUG) { Log.d(TAG, "Unable to show as HUN: OEM has disabled navigation HUN"); } return false; } // Group alert behavior if (notification.suppressAlertingDueToGrouping()) { if (DEBUG) { Log.d(TAG, "Unable to show as HUN: Grouping notification"); } return false; } // Messaging notification muted by user. if (mNotificationDataManager.isMessageNotificationMuted(alertEntry)) { if (DEBUG) { Log.d(TAG, "Unable to show as HUN: Messaging notification is muted by user"); } return false; } // Do not show if importance < HIGH NotificationListenerService.Ranking ranking = getRanking(); if (rankingMap.getRanking(alertEntry.getKey(), ranking)) { if (ranking.getImportance() < NotificationManager.IMPORTANCE_HIGH) { if (DEBUG) { Log.d(TAG, "Unable to show as HUN: importance is not sufficient"); } return false; } } if (NotificationUtils.isSystemPrivilegedOrPlatformKey(mContext, alertEntry)) { if (DEBUG) { Log.d(TAG, "Show as HUN: application is system privileged or signed with " + "platform key"); } return true; } // Allow car messaging type. if (isCarCompatibleMessagingNotification(alertEntry.getStatusBarNotification())) { if (DEBUG) { Log.d(TAG, "Show as HUN: car messaging type notification"); } return true; } if (notification.category == null) { Log.d(TAG, "category not set for: " + alertEntry.getStatusBarNotification().getPackageName()); } if (DEBUG) { Log.d(TAG, "Notification category: " + notification.category); } // Allow for Call, and nav TBT categories. return Notification.CATEGORY_CALL.equals(notification.category) || Notification.CATEGORY_NAVIGATION.equals(notification.category); } @VisibleForTesting protected NotificationListenerService.Ranking getRanking() { return new NotificationListenerService.Ranking(); } @Override public void onUxRestrictionsChanged(CarUxRestrictions restrictions) { mShouldRestrictMessagePreview = (restrictions.getActiveRestrictions() & CarUxRestrictions.UX_RESTRICTIONS_NO_TEXT_MESSAGE) != 0; } /** * Sets the source of {@link View.OnClickListener} * * @param clickHandlerFactory used to generate onClickListeners */ @VisibleForTesting public void setClickHandlerFactory(NotificationClickHandlerFactory clickHandlerFactory) { mClickHandlerFactory = clickHandlerFactory; } }