1 /* 2 * Copyright 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 17 package androidx.media.app; 18 19 import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP; 20 import static androidx.core.app.NotificationCompat.COLOR_DEFAULT; 21 22 import android.app.Notification; 23 import android.app.PendingIntent; 24 import android.media.session.MediaSession; 25 import android.os.Build; 26 import android.os.Bundle; 27 import android.os.IBinder; 28 import android.os.Parcel; 29 import android.support.v4.media.session.MediaSessionCompat; 30 import android.view.View; 31 import android.widget.RemoteViews; 32 33 import androidx.annotation.RequiresApi; 34 import androidx.annotation.RestrictTo; 35 import androidx.core.app.BundleCompat; 36 import androidx.core.app.NotificationBuilderWithBuilderAccessor; 37 import androidx.media.R; 38 39 /** 40 * Class containing media specfic {@link androidx.core.app.NotificationCompat.Style styles} 41 * that you can use with {@link androidx.core.app.NotificationCompat.Builder#setStyle}. 42 */ 43 public class NotificationCompat { 44 45 private NotificationCompat() { 46 } 47 48 /** 49 * Notification style for media playback notifications. 50 * 51 * In the expanded form, up to 5 52 * {@link androidx.core.app.NotificationCompat.Action actions} specified with 53 * {@link androidx.core.app.NotificationCompat.Builder 54 * #addAction(int, CharSequence, PendingIntent) addAction} will be shown as icon-only 55 * pushbuttons, suitable for transport controls. The Bitmap given to 56 * {@link androidx.core.app.NotificationCompat.Builder 57 * #setLargeIcon(android.graphics.Bitmap) setLargeIcon()} will 58 * be treated as album artwork. 59 * 60 * Unlike the other styles provided here, MediaStyle can also modify the standard-size 61 * content view; by providing action indices to 62 * {@link #setShowActionsInCompactView(int...)} you can promote up to 3 actions to be displayed 63 * in the standard view alongside the usual content. 64 * 65 * Notifications created with MediaStyle will have their category set to 66 * {@link androidx.core.app.NotificationCompat#CATEGORY_TRANSPORT CATEGORY_TRANSPORT} 67 * unless you set a different category using 68 * {@link androidx.core.app.NotificationCompat.Builder#setCategory(String) 69 * setCategory()}. 70 * 71 * Finally, if you attach a {@link MediaSession.Token} using 72 * {@link NotificationCompat.MediaStyle#setMediaSession}, the 73 * System UI can identify this as a notification representing an active media session and 74 * respond accordingly (by showing album artwork in the lockscreen, for example). 75 * 76 * To use this style with your Notification, feed it to 77 * {@link androidx.core.app.NotificationCompat.Builder#setStyle} like so: 78 * <pre class="prettyprint"> 79 * Notification noti = new NotificationCompat.Builder() 80 * .setSmallIcon(R.drawable.ic_stat_player) 81 * .setContentTitle("Track title") 82 * .setContentText("Artist - Album") 83 * .setLargeIcon(albumArtBitmap)) 84 * .setStyle(<b>new NotificationCompat.MediaStyle()</b> 85 * .setMediaSession(mySession)) 86 * .build(); 87 * </pre> 88 * 89 * @see Notification#bigContentView 90 */ 91 public static class MediaStyle extends androidx.core.app.NotificationCompat.Style { 92 93 /** 94 * Extracts a {@link MediaSessionCompat.Token} from the extra values 95 * in the {@link MediaStyle} {@link Notification notification}. 96 * 97 * @param notification The notification to extract a {@link MediaSessionCompat.Token} from. 98 * @return The {@link MediaSessionCompat.Token} in the {@code notification} if it contains, 99 * null otherwise. 100 */ 101 public static MediaSessionCompat.Token getMediaSession(Notification notification) { 102 Bundle extras = androidx.core.app.NotificationCompat.getExtras(notification); 103 if (extras != null) { 104 if (Build.VERSION.SDK_INT >= 21) { 105 Object tokenInner = extras.getParcelable( 106 androidx.core.app.NotificationCompat.EXTRA_MEDIA_SESSION); 107 if (tokenInner != null) { 108 return MediaSessionCompat.Token.fromToken(tokenInner); 109 } 110 } else { 111 IBinder tokenInner = BundleCompat.getBinder(extras, 112 androidx.core.app.NotificationCompat.EXTRA_MEDIA_SESSION); 113 if (tokenInner != null) { 114 Parcel p = Parcel.obtain(); 115 p.writeStrongBinder(tokenInner); 116 p.setDataPosition(0); 117 MediaSessionCompat.Token token = 118 MediaSessionCompat.Token.CREATOR.createFromParcel(p); 119 p.recycle(); 120 return token; 121 } 122 } 123 } 124 return null; 125 } 126 127 private static final int MAX_MEDIA_BUTTONS_IN_COMPACT = 3; 128 private static final int MAX_MEDIA_BUTTONS = 5; 129 130 int[] mActionsToShowInCompact = null; 131 MediaSessionCompat.Token mToken; 132 boolean mShowCancelButton; 133 PendingIntent mCancelButtonIntent; 134 135 public MediaStyle() { 136 } 137 138 public MediaStyle(androidx.core.app.NotificationCompat.Builder builder) { 139 setBuilder(builder); 140 } 141 142 /** 143 * Requests up to 3 actions (by index in the order of addition) to be shown in the compact 144 * notification view. 145 * 146 * @param actions the indices of the actions to show in the compact notification view 147 */ 148 public MediaStyle setShowActionsInCompactView(int...actions) { 149 mActionsToShowInCompact = actions; 150 return this; 151 } 152 153 /** 154 * Attaches a {@link MediaSessionCompat.Token} to this Notification 155 * to provide additional playback information and control to the SystemUI. 156 */ 157 public MediaStyle setMediaSession(MediaSessionCompat.Token token) { 158 mToken = token; 159 return this; 160 } 161 162 /** 163 * Sets whether a cancel button at the top right should be shown in the notification on 164 * platforms before Lollipop. 165 * 166 * <p>Prior to Lollipop, there was a bug in the framework which prevented the developer to 167 * make a notification dismissable again after having used the same notification as the 168 * ongoing notification for a foreground service. When the notification was posted by 169 * {@link android.app.Service#startForeground}, but then the service exited foreground mode 170 * via {@link android.app.Service#stopForeground}, without removing the notification, the 171 * notification stayed ongoing, and thus not dismissable. 172 * 173 * <p>This is a common scenario for media notifications, as this is exactly the service 174 * lifecycle that happens when playing/pausing media. Thus, a workaround is provided by the 175 * support library: Instead of making the notification ongoing depending on the playback 176 * state, the support library provides the ability to add an explicit cancel button to the 177 * notification. 178 * 179 * <p>Note that the notification is enforced to be ongoing if a cancel button is shown to 180 * provide a consistent user experience. 181 * 182 * <p>Also note that this method is a no-op when running on Lollipop and later. 183 * 184 * @param show whether to show a cancel button 185 */ 186 public MediaStyle setShowCancelButton(boolean show) { 187 if (Build.VERSION.SDK_INT < 21) { 188 mShowCancelButton = show; 189 } 190 return this; 191 } 192 193 /** 194 * Sets the pending intent to be sent when the cancel button is pressed. See {@link 195 * #setShowCancelButton}. 196 * 197 * @param pendingIntent the intent to be sent when the cancel button is pressed 198 */ 199 public MediaStyle setCancelButtonIntent(PendingIntent pendingIntent) { 200 mCancelButtonIntent = pendingIntent; 201 return this; 202 } 203 204 /** 205 * @hide 206 */ 207 @RestrictTo(LIBRARY_GROUP) 208 @Override 209 public void apply(NotificationBuilderWithBuilderAccessor builder) { 210 if (Build.VERSION.SDK_INT >= 21) { 211 builder.getBuilder().setStyle( 212 fillInMediaStyle(new Notification.MediaStyle())); 213 } else if (mShowCancelButton) { 214 builder.getBuilder().setOngoing(true); 215 } 216 } 217 218 @RequiresApi(21) 219 Notification.MediaStyle fillInMediaStyle(Notification.MediaStyle style) { 220 if (mActionsToShowInCompact != null) { 221 style.setShowActionsInCompactView(mActionsToShowInCompact); 222 } 223 if (mToken != null) { 224 style.setMediaSession((MediaSession.Token) mToken.getToken()); 225 } 226 return style; 227 } 228 229 /** 230 * @hide 231 */ 232 @RestrictTo(LIBRARY_GROUP) 233 @Override 234 public RemoteViews makeContentView(NotificationBuilderWithBuilderAccessor builder) { 235 if (Build.VERSION.SDK_INT >= 21) { 236 // No custom content view required 237 return null; 238 } 239 return generateContentView(); 240 } 241 242 RemoteViews generateContentView() { 243 RemoteViews view = applyStandardTemplate(false /* showSmallIcon */, 244 getContentViewLayoutResource(), true /* fitIn1U */); 245 246 final int numActions = mBuilder.mActions.size(); 247 final int numActionsInCompact = mActionsToShowInCompact == null 248 ? 0 249 : Math.min(mActionsToShowInCompact.length, MAX_MEDIA_BUTTONS_IN_COMPACT); 250 view.removeAllViews(R.id.media_actions); 251 if (numActionsInCompact > 0) { 252 for (int i = 0; i < numActionsInCompact; i++) { 253 if (i >= numActions) { 254 throw new IllegalArgumentException(String.format( 255 "setShowActionsInCompactView: action %d out of bounds (max %d)", 256 i, numActions - 1)); 257 } 258 259 final androidx.core.app.NotificationCompat.Action action = 260 mBuilder.mActions.get(mActionsToShowInCompact[i]); 261 final RemoteViews button = generateMediaActionButton(action); 262 view.addView(R.id.media_actions, button); 263 } 264 } 265 if (mShowCancelButton) { 266 view.setViewVisibility(R.id.end_padder, View.GONE); 267 view.setViewVisibility(R.id.cancel_action, View.VISIBLE); 268 view.setOnClickPendingIntent(R.id.cancel_action, mCancelButtonIntent); 269 view.setInt(R.id.cancel_action, "setAlpha", mBuilder.mContext 270 .getResources().getInteger(R.integer.cancel_button_image_alpha)); 271 } else { 272 view.setViewVisibility(R.id.end_padder, View.VISIBLE); 273 view.setViewVisibility(R.id.cancel_action, View.GONE); 274 } 275 return view; 276 } 277 278 private RemoteViews generateMediaActionButton( 279 androidx.core.app.NotificationCompat.Action action) { 280 final boolean tombstone = (action.getActionIntent() == null); 281 RemoteViews button = new RemoteViews(mBuilder.mContext.getPackageName(), 282 R.layout.notification_media_action); 283 button.setImageViewResource(R.id.action0, action.getIcon()); 284 if (!tombstone) { 285 button.setOnClickPendingIntent(R.id.action0, action.getActionIntent()); 286 } 287 if (Build.VERSION.SDK_INT >= 15) { 288 button.setContentDescription(R.id.action0, action.getTitle()); 289 } 290 return button; 291 } 292 293 int getContentViewLayoutResource() { 294 return R.layout.notification_template_media; 295 } 296 297 /** 298 * @hide 299 */ 300 @RestrictTo(LIBRARY_GROUP) 301 @Override 302 public RemoteViews makeBigContentView(NotificationBuilderWithBuilderAccessor builder) { 303 if (Build.VERSION.SDK_INT >= 21) { 304 // No custom content view required 305 return null; 306 } 307 return generateBigContentView(); 308 } 309 310 RemoteViews generateBigContentView() { 311 final int actionCount = Math.min(mBuilder.mActions.size(), MAX_MEDIA_BUTTONS); 312 RemoteViews big = applyStandardTemplate(false /* showSmallIcon */, 313 getBigContentViewLayoutResource(actionCount), false /* fitIn1U */); 314 315 big.removeAllViews(R.id.media_actions); 316 if (actionCount > 0) { 317 for (int i = 0; i < actionCount; i++) { 318 final RemoteViews button = generateMediaActionButton(mBuilder.mActions.get(i)); 319 big.addView(R.id.media_actions, button); 320 } 321 } 322 if (mShowCancelButton) { 323 big.setViewVisibility(R.id.cancel_action, View.VISIBLE); 324 big.setInt(R.id.cancel_action, "setAlpha", mBuilder.mContext 325 .getResources().getInteger(R.integer.cancel_button_image_alpha)); 326 big.setOnClickPendingIntent(R.id.cancel_action, mCancelButtonIntent); 327 } else { 328 big.setViewVisibility(R.id.cancel_action, View.GONE); 329 } 330 return big; 331 } 332 333 int getBigContentViewLayoutResource(int actionCount) { 334 return actionCount <= 3 335 ? R.layout.notification_template_big_media_narrow 336 : R.layout.notification_template_big_media; 337 } 338 } 339 340 /** 341 * Notification style for media custom views that are decorated by the system. 342 * 343 * <p>Instead of providing a media notification that is completely custom, a developer can set 344 * this style and still obtain system decorations like the notification header with the expand 345 * affordance and actions. 346 * 347 * <p>Use {@link androidx.core.app.NotificationCompat.Builder 348 * #setCustomContentView(RemoteViews)}, 349 * {@link androidx.core.app.NotificationCompat.Builder 350 * #setCustomBigContentView(RemoteViews)} and 351 * {@link androidx.core.app.NotificationCompat.Builder 352 * #setCustomHeadsUpContentView(RemoteViews)} to set the 353 * corresponding custom views to display. 354 * 355 * <p>To use this style with your Notification, feed it to 356 * {@link androidx.core.app.NotificationCompat.Builder 357 * #setStyle(androidx.core.app.NotificationCompat.Style)} like so: 358 * <pre class="prettyprint"> 359 * Notification noti = new NotificationCompat.Builder() 360 * .setSmallIcon(R.drawable.ic_stat_player) 361 * .setLargeIcon(albumArtBitmap)) 362 * .setCustomContentView(contentView) 363 * .setStyle(<b>new NotificationCompat.DecoratedMediaCustomViewStyle()</b> 364 * .setMediaSession(mySession)) 365 * .build(); 366 * </pre> 367 * 368 * <p>If you are using this style, consider using the corresponding styles like 369 * {@link androidx.media.R.style#TextAppearance_Compat_Notification_Media} or 370 * {@link 371 * androidx.media.R.style#TextAppearance_Compat_Notification_Title_Media} in 372 * your custom views in order to get the correct styling on each platform version. 373 * 374 * @see androidx.core.app.NotificationCompat.DecoratedCustomViewStyle 375 * @see MediaStyle 376 */ 377 public static class DecoratedMediaCustomViewStyle extends MediaStyle { 378 379 public DecoratedMediaCustomViewStyle() { 380 } 381 382 /** 383 * @hide 384 */ 385 @RestrictTo(LIBRARY_GROUP) 386 @Override 387 public void apply(NotificationBuilderWithBuilderAccessor builder) { 388 if (Build.VERSION.SDK_INT >= 24) { 389 builder.getBuilder().setStyle( 390 fillInMediaStyle(new Notification.DecoratedMediaCustomViewStyle())); 391 } else { 392 super.apply(builder); 393 } 394 } 395 396 /** 397 * @hide 398 */ 399 @RestrictTo(LIBRARY_GROUP) 400 @Override 401 public RemoteViews makeContentView(NotificationBuilderWithBuilderAccessor builder) { 402 if (Build.VERSION.SDK_INT >= 24) { 403 // No custom content view required 404 return null; 405 } 406 boolean hasContentView = mBuilder.getContentView() != null; 407 if (Build.VERSION.SDK_INT >= 21) { 408 // If we are on L/M the media notification will only be colored if the expanded 409 // version is of media style, so we have to create a custom view for the collapsed 410 // version as well in that case. 411 boolean createCustomContent = hasContentView 412 || mBuilder.getBigContentView() != null; 413 if (createCustomContent) { 414 RemoteViews contentView = generateContentView(); 415 if (hasContentView) { 416 buildIntoRemoteViews(contentView, mBuilder.getContentView()); 417 } 418 setBackgroundColor(contentView); 419 return contentView; 420 } 421 } else { 422 RemoteViews contentView = generateContentView(); 423 if (hasContentView) { 424 buildIntoRemoteViews(contentView, mBuilder.getContentView()); 425 return contentView; 426 } 427 } 428 return null; 429 } 430 431 @Override 432 int getContentViewLayoutResource() { 433 return mBuilder.getContentView() != null 434 ? R.layout.notification_template_media_custom 435 : super.getContentViewLayoutResource(); 436 } 437 438 /** 439 * @hide 440 */ 441 @RestrictTo(LIBRARY_GROUP) 442 @Override 443 public RemoteViews makeBigContentView(NotificationBuilderWithBuilderAccessor builder) { 444 if (Build.VERSION.SDK_INT >= 24) { 445 // No custom big content view required 446 return null; 447 } 448 RemoteViews innerView = mBuilder.getBigContentView() != null 449 ? mBuilder.getBigContentView() 450 : mBuilder.getContentView(); 451 if (innerView == null) { 452 // No expandable notification 453 return null; 454 } 455 RemoteViews bigContentView = generateBigContentView(); 456 buildIntoRemoteViews(bigContentView, innerView); 457 if (Build.VERSION.SDK_INT >= 21) { 458 setBackgroundColor(bigContentView); 459 } 460 return bigContentView; 461 } 462 463 @Override 464 int getBigContentViewLayoutResource(int actionCount) { 465 return actionCount <= 3 466 ? R.layout.notification_template_big_media_narrow_custom 467 : R.layout.notification_template_big_media_custom; 468 } 469 470 /** 471 * @hide 472 */ 473 @RestrictTo(LIBRARY_GROUP) 474 @Override 475 public RemoteViews makeHeadsUpContentView(NotificationBuilderWithBuilderAccessor builder) { 476 if (Build.VERSION.SDK_INT >= 24) { 477 // No custom heads up content view required 478 return null; 479 } 480 RemoteViews innerView = mBuilder.getHeadsUpContentView() != null 481 ? mBuilder.getHeadsUpContentView() 482 : mBuilder.getContentView(); 483 if (innerView == null) { 484 // No expandable notification 485 return null; 486 } 487 RemoteViews headsUpContentView = generateBigContentView(); 488 buildIntoRemoteViews(headsUpContentView, innerView); 489 if (Build.VERSION.SDK_INT >= 21) { 490 setBackgroundColor(headsUpContentView); 491 } 492 return headsUpContentView; 493 } 494 495 private void setBackgroundColor(RemoteViews views) { 496 int color = mBuilder.getColor() != COLOR_DEFAULT 497 ? mBuilder.getColor() 498 : mBuilder.mContext.getResources().getColor( 499 R.color.notification_material_background_media_default_color); 500 views.setInt(R.id.status_bar_latest_event_content, "setBackgroundColor", color); 501 } 502 } 503 } 504