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