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