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