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 package com.android.car.notification.template; 17 18 import android.app.Notification; 19 import android.content.Context; 20 import android.content.res.TypedArray; 21 import android.graphics.PorterDuff; 22 import android.graphics.PorterDuffColorFilter; 23 import android.graphics.drawable.Drawable; 24 import android.graphics.drawable.Icon; 25 import android.os.Build; 26 import android.os.Handler; 27 import android.os.Looper; 28 import android.util.AttributeSet; 29 import android.util.Log; 30 import android.view.View; 31 import android.widget.LinearLayout; 32 33 import androidx.annotation.ColorInt; 34 import androidx.annotation.NonNull; 35 import androidx.annotation.Nullable; 36 import androidx.annotation.VisibleForTesting; 37 38 import com.android.car.assist.client.CarAssistUtils; 39 import com.android.car.notification.AlertEntry; 40 import com.android.car.notification.NotificationClickHandlerFactory; 41 import com.android.car.notification.NotificationDataManager; 42 import com.android.car.notification.PreprocessingManager; 43 import com.android.car.notification.R; 44 45 import java.util.ArrayList; 46 import java.util.List; 47 48 /** 49 * Notification actions view that contains the buttons that fire actions. 50 */ 51 public class CarNotificationActionsView extends LinearLayout implements 52 PreprocessingManager.CallStateListener { 53 private static final boolean DEBUG = Build.IS_DEBUGGABLE; 54 private static final String TAG = "CarNotificationActionsView"; 55 56 // Maximum 3 actions 57 // https://developer.android.com/reference/android/app/Notification.Builder.html#addAction 58 @VisibleForTesting 59 static final int MAX_NUM_ACTIONS = 3; 60 @VisibleForTesting 61 static final int FIRST_MESSAGE_ACTION_BUTTON_INDEX = 0; 62 @VisibleForTesting 63 static final int SECOND_MESSAGE_ACTION_BUTTON_INDEX = 1; 64 @VisibleForTesting 65 static final int THIRD_MESSAGE_ACTION_BUTTON_INDEX = 2; 66 67 private final List<CarNotificationActionButton> mActionButtons = new ArrayList<>(); 68 private final Context mContext; 69 private final CarAssistUtils mCarAssistUtils; 70 private final Drawable mActionButtonBackground; 71 private final Drawable mCallButtonBackground; 72 private final Drawable mDeclineButtonBackground; 73 private final Drawable mUnmuteButtonBackground; 74 private final String mReplyButtonText; 75 private final String mPlayButtonText; 76 private final String mMuteText; 77 private final String mUnmuteText; 78 @ColorInt 79 private final int mUnmuteTextColor; 80 private final boolean mEnableDirectReply; 81 private final boolean mEnablePlay; 82 83 @VisibleForTesting 84 final Drawable mPlayButtonDrawable; 85 @VisibleForTesting 86 final Drawable mReplyButtonDrawable; 87 @VisibleForTesting 88 final Drawable mMuteButtonDrawable; 89 @VisibleForTesting 90 final Drawable mUnmuteButtonDrawable; 91 92 93 private NotificationDataManager mNotificationDataManager; 94 private NotificationClickHandlerFactory mNotificationClickHandlerFactory; 95 private AlertEntry mAlertEntry; 96 private boolean mIsCategoryCall; 97 private boolean mIsInCall; 98 CarNotificationActionsView(Context context)99 public CarNotificationActionsView(Context context) { 100 this(context, /* attrs= */ null); 101 } 102 CarNotificationActionsView(Context context, AttributeSet attrs)103 public CarNotificationActionsView(Context context, AttributeSet attrs) { 104 this(context, attrs, /* defStyleAttr= */ 0); 105 } 106 CarNotificationActionsView(Context context, AttributeSet attrs, int defStyleAttr)107 public CarNotificationActionsView(Context context, AttributeSet attrs, int defStyleAttr) { 108 this(context, attrs, defStyleAttr, /* defStyleRes= */ 0); 109 } 110 CarNotificationActionsView( Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)111 public CarNotificationActionsView( 112 Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { 113 this(context, attrs, defStyleAttr, defStyleRes, new CarAssistUtils(context)); 114 } 115 116 @VisibleForTesting CarNotificationActionsView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes, @NonNull CarAssistUtils carAssistUtils)117 CarNotificationActionsView(Context context, AttributeSet attrs, int defStyleAttr, 118 int defStyleRes, @NonNull CarAssistUtils carAssistUtils) { 119 super(context, attrs, defStyleAttr, defStyleRes); 120 121 mContext = context; 122 mCarAssistUtils = carAssistUtils; 123 mNotificationDataManager = NotificationDataManager.getInstance(); 124 mActionButtonBackground = mContext.getDrawable(R.drawable.action_button_background); 125 mCallButtonBackground = mContext.getDrawable(R.drawable.call_action_button_background); 126 mCallButtonBackground.setColorFilter( 127 new PorterDuffColorFilter(mContext.getColor(R.color.call_accept_button), 128 PorterDuff.Mode.SRC_IN)); 129 mDeclineButtonBackground = mContext.getDrawable(R.drawable.call_action_button_background); 130 mDeclineButtonBackground.setColorFilter( 131 new PorterDuffColorFilter(mContext.getColor(R.color.call_decline_button), 132 PorterDuff.Mode.SRC_IN)); 133 mUnmuteButtonBackground = mContext.getDrawable(R.drawable.call_action_button_background); 134 mUnmuteButtonBackground.setColorFilter( 135 new PorterDuffColorFilter(mContext.getColor(R.color.unmute_button), 136 PorterDuff.Mode.SRC_IN)); 137 mPlayButtonText = mContext.getString(R.string.assist_action_play_label); 138 mReplyButtonText = mContext.getString(R.string.assist_action_reply_label); 139 mMuteText = mContext.getString(R.string.action_mute_short); 140 mUnmuteText = mContext.getString(R.string.action_unmute_short); 141 mPlayButtonDrawable = mContext.getDrawable(R.drawable.ic_play_arrow); 142 mReplyButtonDrawable = mContext.getDrawable(R.drawable.ic_reply); 143 mMuteButtonDrawable = mContext.getDrawable(R.drawable.ic_mute); 144 mUnmuteButtonDrawable = mContext.getDrawable(R.drawable.ic_unmute); 145 mEnablePlay = 146 mContext.getResources().getBoolean(R.bool.config_enableMessageNotificationPlay); 147 mEnableDirectReply = mContext.getResources() 148 .getBoolean(R.bool.config_enableMessageNotificationDirectReply); 149 mUnmuteTextColor = mContext.getColor(R.color.dark_icon_tint); 150 init(attrs); 151 } 152 153 @VisibleForTesting setNotificationDataManager(NotificationDataManager notificationDataManager)154 void setNotificationDataManager(NotificationDataManager notificationDataManager) { 155 mNotificationDataManager = notificationDataManager; 156 } 157 init(@ullable AttributeSet attrs)158 private void init(@Nullable AttributeSet attrs) { 159 if (attrs != null) { 160 TypedArray attributes = 161 mContext.obtainStyledAttributes(attrs, R.styleable.CarNotificationActionsView); 162 mIsCategoryCall = 163 attributes.getBoolean(R.styleable.CarNotificationActionsView_categoryCall, 164 /* defaultValue= */ false); 165 attributes.recycle(); 166 } 167 168 inflate(mContext, R.layout.car_notification_actions_view, /* root= */ this); 169 } 170 171 /** 172 * Binds the notification action buttons. 173 * 174 * @param clickHandlerFactory factory class used to generate {@link OnClickListener}s. 175 * @param alertEntry the notification that contains the actions. 176 */ bind(NotificationClickHandlerFactory clickHandlerFactory, AlertEntry alertEntry)177 public void bind(NotificationClickHandlerFactory clickHandlerFactory, AlertEntry alertEntry) { 178 Notification notification = alertEntry.getNotification(); 179 Notification.Action[] actions = notification.actions; 180 if (actions == null || actions.length == 0) { 181 setVisibility(View.GONE); 182 return; 183 } 184 185 PreprocessingManager.getInstance(mContext).addCallStateListener(this); 186 187 mNotificationClickHandlerFactory = clickHandlerFactory; 188 mAlertEntry = alertEntry; 189 190 setVisibility(View.VISIBLE); 191 192 if (CarAssistUtils.isCarCompatibleMessagingNotification( 193 alertEntry.getStatusBarNotification())) { 194 boolean canPlayMessage = mEnablePlay && mCarAssistUtils.hasActiveAssistant() 195 || mCarAssistUtils.isFallbackAssistantEnabled(); 196 boolean canReplyMessage = mEnableDirectReply && mCarAssistUtils.hasActiveAssistant() 197 && clickHandlerFactory.getReplyAction(alertEntry.getNotification()) != null; 198 if (canPlayMessage) { 199 createPlayButton(clickHandlerFactory, alertEntry); 200 } 201 if (canReplyMessage) { 202 createReplyButton(clickHandlerFactory, alertEntry); 203 } 204 createMuteButton(clickHandlerFactory, alertEntry, canReplyMessage); 205 return; 206 } 207 208 Context packageContext = alertEntry.getStatusBarNotification().getPackageContext(mContext); 209 int length = Math.min(actions.length, MAX_NUM_ACTIONS); 210 for (int i = 0; i < length; i++) { 211 Notification.Action action = actions[i]; 212 CarNotificationActionButton button = mActionButtons.get(i); 213 button.setVisibility(View.VISIBLE); 214 // clear spannables and only use the text 215 button.setText(action.title.toString()); 216 217 if (action.actionIntent != null) { 218 button.setOnClickListener(clickHandlerFactory.getActionClickHandler(alertEntry, i)); 219 } 220 221 Icon icon = action.getIcon(); 222 if (icon != null) { 223 icon.loadDrawableAsync(packageContext, button::setImageDrawable, getAsyncHandler()); 224 } 225 } 226 227 if (mIsCategoryCall) { 228 mActionButtons.get(0).setBackground(mCallButtonBackground); 229 mActionButtons.get(1).setBackground(mDeclineButtonBackground); 230 } 231 } 232 233 /** 234 * Resets the notification actions empty for recycling. 235 */ reset()236 public void reset() { 237 resetButtons(); 238 PreprocessingManager.getInstance(getContext()).removeCallStateListener(this); 239 mAlertEntry = null; 240 mNotificationClickHandlerFactory = null; 241 } 242 resetButtons()243 private void resetButtons() { 244 for (CarNotificationActionButton button : mActionButtons) { 245 button.setVisibility(View.GONE); 246 button.setText(null); 247 button.setImageDrawable(null); 248 button.setOnClickListener(null); 249 } 250 } 251 252 @Override onFinishInflate()253 protected void onFinishInflate() { 254 super.onFinishInflate(); 255 mActionButtons.add(findViewById(R.id.action_1)); 256 mActionButtons.add(findViewById(R.id.action_2)); 257 mActionButtons.add(findViewById(R.id.action_3)); 258 } 259 260 @VisibleForTesting getActionButtons()261 List<CarNotificationActionButton> getActionButtons() { 262 return mActionButtons; 263 } 264 265 @VisibleForTesting setCategoryIsCall(boolean isCall)266 void setCategoryIsCall(boolean isCall) { 267 mIsCategoryCall = isCall; 268 } 269 270 /** 271 * The Play button triggers the assistant to read the message aloud, optionally prompting the 272 * user to reply to the message afterwards. 273 */ createPlayButton(NotificationClickHandlerFactory clickHandlerFactory, AlertEntry alertEntry)274 private void createPlayButton(NotificationClickHandlerFactory clickHandlerFactory, 275 AlertEntry alertEntry) { 276 if (mIsInCall) return; 277 278 CarNotificationActionButton button = mActionButtons.get(FIRST_MESSAGE_ACTION_BUTTON_INDEX); 279 button.setText(mPlayButtonText); 280 button.setImageDrawable(mPlayButtonDrawable); 281 button.setVisibility(View.VISIBLE); 282 button.setOnClickListener( 283 clickHandlerFactory.getPlayClickHandler(alertEntry)); 284 } 285 286 /** 287 * The Reply button triggers the assistant to read the message aloud, optionally prompting the 288 * user to reply to the message afterwards. 289 */ createReplyButton(NotificationClickHandlerFactory clickHandlerFactory, AlertEntry alertEntry)290 private void createReplyButton(NotificationClickHandlerFactory clickHandlerFactory, 291 AlertEntry alertEntry) { 292 if (mIsInCall) return; 293 int index = SECOND_MESSAGE_ACTION_BUTTON_INDEX; 294 295 CarNotificationActionButton button = mActionButtons.get(index); 296 button.setText(mReplyButtonText); 297 button.setImageDrawable(mReplyButtonDrawable); 298 button.setVisibility(View.VISIBLE); 299 button.setOnClickListener( 300 clickHandlerFactory.getReplyClickHandler(alertEntry)); 301 } 302 303 /** 304 * The Mute button allows users to toggle whether or not incoming notification with the same 305 * statusBarNotification key will be shown with a HUN and trigger a notification sound. 306 */ createMuteButton(NotificationClickHandlerFactory clickHandlerFactory, AlertEntry alertEntry, boolean canReply)307 private void createMuteButton(NotificationClickHandlerFactory clickHandlerFactory, 308 AlertEntry alertEntry, boolean canReply) { 309 int index = THIRD_MESSAGE_ACTION_BUTTON_INDEX; 310 if (!canReply) index = SECOND_MESSAGE_ACTION_BUTTON_INDEX; 311 if (mIsInCall) index = FIRST_MESSAGE_ACTION_BUTTON_INDEX; 312 313 CarNotificationActionButton button = mActionButtons.get(index); 314 setMuteStatus(button, mNotificationDataManager.isMessageNotificationMuted(alertEntry)); 315 button.setVisibility(View.VISIBLE); 316 button.setOnClickListener( 317 clickHandlerFactory.getMuteClickHandler(button, alertEntry, this::setMuteStatus)); 318 } 319 setMuteStatus(CarNotificationActionButton button, boolean isMuted)320 private void setMuteStatus(CarNotificationActionButton button, boolean isMuted) { 321 button.setText(isMuted ? mUnmuteText : mMuteText); 322 button.setTextColor(isMuted ? mUnmuteTextColor : button.getDefaultTextColor()); 323 button.setImageDrawable(isMuted ? mUnmuteButtonDrawable : mMuteButtonDrawable); 324 button.setBackground(isMuted ? mUnmuteButtonBackground : mActionButtonBackground); 325 } 326 327 /** Implementation of {@link PreprocessingManager.CallStateListener} **/ 328 @Override onCallStateChanged(boolean isInCall)329 public void onCallStateChanged(boolean isInCall) { 330 if (mIsInCall == isInCall) { 331 return; 332 } 333 334 mIsInCall = isInCall; 335 336 if (mNotificationClickHandlerFactory == null || mAlertEntry == null) { 337 return; 338 } 339 340 if (DEBUG) { 341 if (isInCall) { 342 Log.d(TAG, "Call state activated: " + mAlertEntry); 343 } else { 344 Log.d(TAG, "Call state deactivated: " + mAlertEntry); 345 } 346 } 347 348 int focusedButtonIndex = getFocusedButtonIndex(); 349 resetButtons(); 350 bind(mNotificationClickHandlerFactory, mAlertEntry); 351 352 // If not in touch mode and action button had focus, then have original or preceding button 353 // request focus. 354 if (!isInTouchMode() && focusedButtonIndex != -1) { 355 for (int i = focusedButtonIndex; i != -1; i--) { 356 CarNotificationActionButton button = getActionButtons().get(i); 357 if (button.getVisibility() == View.VISIBLE) { 358 button.requestFocus(); 359 return; 360 } 361 } 362 } 363 } 364 getFocusedButtonIndex()365 private int getFocusedButtonIndex() { 366 for (int i = FIRST_MESSAGE_ACTION_BUTTON_INDEX; i <= THIRD_MESSAGE_ACTION_BUTTON_INDEX; 367 i++) { 368 boolean hasFocus = getActionButtons().get(i).hasFocus(); 369 if (hasFocus) { 370 return i; 371 } 372 } 373 return -1; 374 } 375 376 /** Will be overwritten by test to return a mock Handler **/ 377 @VisibleForTesting getAsyncHandler()378 Handler getAsyncHandler() { 379 return Handler.createAsync(Looper.myLooper()); 380 } 381 } 382