Fix notifications to avoid flicker on KitKat

On KitKat you need to reuse the same notification builder when
generating a notification that's intended to replace a previous
one. See:

https://stackoverflow.com/questions/6406730/updating-an-ongoing-notification-quietly

PiperOrigin-RevId: 232503682
This commit is contained in:
olly 2019-02-05 17:45:39 +00:00 committed by Oliver Woodman
parent 391f2bb6c2
commit e3981ec484
5 changed files with 284 additions and 161 deletions

View file

@ -43,6 +43,10 @@
and `useSurfaceYuvOutput`.
* Change signature of `PlayerNotificationManager.NotificationListener` to better
fit service requirements. Remove ability to set a custom stop action.
* Fix issues with flickering notifications on KitKat.
`PlayerNotificationManager` has been fixed. Apps using
`DownloadNotificationUtil` should switch to using
`DownloadNotificationHelper`.
### 2.9.5 ###

View file

@ -20,7 +20,7 @@ import com.google.android.exoplayer2.offline.DownloadManager;
import com.google.android.exoplayer2.offline.DownloadService;
import com.google.android.exoplayer2.offline.DownloadState;
import com.google.android.exoplayer2.scheduler.PlatformScheduler;
import com.google.android.exoplayer2.ui.DownloadNotificationUtil;
import com.google.android.exoplayer2.ui.DownloadNotificationHelper;
import com.google.android.exoplayer2.util.NotificationUtil;
import com.google.android.exoplayer2.util.Util;
@ -33,6 +33,8 @@ public class DemoDownloadService extends DownloadService {
private static int nextNotificationId = FOREGROUND_NOTIFICATION_ID + 1;
private DownloadNotificationHelper notificationHelper;
public DemoDownloadService() {
super(
FOREGROUND_NOTIFICATION_ID,
@ -42,6 +44,12 @@ public class DemoDownloadService extends DownloadService {
nextNotificationId = FOREGROUND_NOTIFICATION_ID + 1;
}
@Override
public void onCreate() {
super.onCreate();
notificationHelper = new DownloadNotificationHelper(this, CHANNEL_ID);
}
@Override
protected DownloadManager getDownloadManager() {
return ((DemoApplication) getApplication()).getDownloadManager();
@ -54,13 +62,8 @@ public class DemoDownloadService extends DownloadService {
@Override
protected Notification getForegroundNotification(DownloadState[] downloadStates) {
return DownloadNotificationUtil.buildProgressNotification(
/* context= */ this,
R.drawable.ic_download,
CHANNEL_ID,
/* contentIntent= */ null,
/* message= */ null,
downloadStates);
return notificationHelper.buildProgressNotification(
R.drawable.ic_download, /* contentIntent= */ null, /* message= */ null, downloadStates);
}
@Override
@ -68,18 +71,14 @@ public class DemoDownloadService extends DownloadService {
Notification notification;
if (downloadState.state == DownloadState.STATE_COMPLETED) {
notification =
DownloadNotificationUtil.buildDownloadCompletedNotification(
/* context= */ this,
notificationHelper.buildDownloadCompletedNotification(
R.drawable.ic_download_done,
CHANNEL_ID,
/* contentIntent= */ null,
Util.fromUtf8Bytes(downloadState.customMetadata));
} else if (downloadState.state == DownloadState.STATE_FAILED) {
notification =
DownloadNotificationUtil.buildDownloadFailedNotification(
/* context= */ this,
notificationHelper.buildDownloadFailedNotification(
R.drawable.ic_download_done,
CHANNEL_ID,
/* contentIntent= */ null,
Util.fromUtf8Bytes(downloadState.customMetadata));
} else {

View file

@ -0,0 +1,171 @@
/*
* Copyright (C) 2018 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.exoplayer2.ui;
import android.app.Notification;
import android.app.PendingIntent;
import android.content.Context;
import android.support.annotation.DrawableRes;
import android.support.annotation.Nullable;
import android.support.annotation.StringRes;
import android.support.v4.app.NotificationCompat;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.offline.DownloadState;
/** Helper for creating download notifications. */
public final class DownloadNotificationHelper {
private static final @StringRes int NULL_STRING_ID = 0;
private final Context context;
private final NotificationCompat.Builder notificationBuilder;
/**
* @param context A context.
* @param channelId The id of the notification channel to use.
*/
public DownloadNotificationHelper(Context context, String channelId) {
context = context.getApplicationContext();
this.context = context;
this.notificationBuilder = new NotificationCompat.Builder(context, channelId);
}
/**
* Returns a progress notification for the given download states.
*
* @param smallIcon A small icon for the notification.
* @param contentIntent An optional content intent to send when the notification is clicked.
* @param message An optional message to display on the notification.
* @param downloadStates The download states.
* @return The notification.
*/
public Notification buildProgressNotification(
@DrawableRes int smallIcon,
@Nullable PendingIntent contentIntent,
@Nullable String message,
DownloadState[] downloadStates) {
float totalPercentage = 0;
int downloadTaskCount = 0;
boolean allDownloadPercentagesUnknown = true;
boolean haveDownloadedBytes = false;
boolean haveDownloadTasks = false;
boolean haveRemoveTasks = false;
for (DownloadState downloadState : downloadStates) {
if (downloadState.state == DownloadState.STATE_REMOVING
|| downloadState.state == DownloadState.STATE_RESTARTING
|| downloadState.state == DownloadState.STATE_REMOVED) {
haveRemoveTasks = true;
continue;
}
if (downloadState.state != DownloadState.STATE_DOWNLOADING
&& downloadState.state != DownloadState.STATE_COMPLETED) {
continue;
}
haveDownloadTasks = true;
if (downloadState.downloadPercentage != C.PERCENTAGE_UNSET) {
allDownloadPercentagesUnknown = false;
totalPercentage += downloadState.downloadPercentage;
}
haveDownloadedBytes |= downloadState.downloadedBytes > 0;
downloadTaskCount++;
}
int titleStringId =
haveDownloadTasks
? R.string.exo_download_downloading
: (haveRemoveTasks ? R.string.exo_download_removing : NULL_STRING_ID);
int progress = 0;
boolean indeterminate = true;
if (haveDownloadTasks) {
progress = (int) (totalPercentage / downloadTaskCount);
indeterminate = allDownloadPercentagesUnknown && haveDownloadedBytes;
}
return buildNotification(
smallIcon,
contentIntent,
message,
titleStringId,
progress,
indeterminate,
/* ongoing= */ true,
/* showWhen= */ false);
}
/**
* Returns a notification for a completed download.
*
* @param smallIcon A small icon for the notifications.
* @param contentIntent An optional content intent to send when the notification is clicked.
* @param message An optional message to display on the notification.
* @return The notification.
*/
public Notification buildDownloadCompletedNotification(
@DrawableRes int smallIcon, @Nullable PendingIntent contentIntent, @Nullable String message) {
int titleStringId = R.string.exo_download_completed;
return buildEndStateNotification(smallIcon, contentIntent, message, titleStringId);
}
/**
* Returns a notification for a failed download.
*
* @param smallIcon A small icon for the notifications.
* @param contentIntent An optional content intent to send when the notification is clicked.
* @param message An optional message to display on the notification.
* @return The notification.
*/
public Notification buildDownloadFailedNotification(
@DrawableRes int smallIcon, @Nullable PendingIntent contentIntent, @Nullable String message) {
@StringRes int titleStringId = R.string.exo_download_failed;
return buildEndStateNotification(smallIcon, contentIntent, message, titleStringId);
}
private Notification buildEndStateNotification(
@DrawableRes int smallIcon,
@Nullable PendingIntent contentIntent,
@Nullable String message,
@StringRes int titleStringId) {
return buildNotification(
smallIcon,
contentIntent,
message,
titleStringId,
/* progress= */ 0,
/* indeterminateProgress= */ false,
/* ongoing= */ false,
/* showWhen= */ true);
}
private Notification buildNotification(
@DrawableRes int smallIcon,
@Nullable PendingIntent contentIntent,
@Nullable String message,
@StringRes int titleStringId,
int progress,
boolean indeterminateProgress,
boolean ongoing,
boolean showWhen) {
notificationBuilder.setSmallIcon(smallIcon);
notificationBuilder.setContentTitle(
titleStringId == NULL_STRING_ID ? null : context.getResources().getString(titleStringId));
notificationBuilder.setContentIntent(contentIntent);
notificationBuilder.setStyle(
message == null ? null : new NotificationCompat.BigTextStyle().bigText(message));
notificationBuilder.setProgress(/* max= */ 100, progress, indeterminateProgress);
notificationBuilder.setOngoing(ongoing);
notificationBuilder.setShowWhen(showWhen);
return notificationBuilder.build();
}
}

View file

@ -20,16 +20,16 @@ import android.app.PendingIntent;
import android.content.Context;
import android.support.annotation.DrawableRes;
import android.support.annotation.Nullable;
import android.support.annotation.StringRes;
import android.support.v4.app.NotificationCompat;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.offline.DownloadState;
import com.google.android.exoplayer2.util.Util;
/** Helper for creating download notifications. */
/**
* @deprecated Using this class can cause notifications to flicker on devices with {@link
* Util#SDK_INT} < 21. Use {@link DownloadNotificationHelper} instead.
*/
@Deprecated
public final class DownloadNotificationUtil {
private static final @StringRes int NULL_STRING_ID = 0;
private DownloadNotificationUtil() {}
/**
@ -37,8 +37,7 @@ public final class DownloadNotificationUtil {
*
* @param context A context for accessing resources.
* @param smallIcon A small icon for the notification.
* @param channelId The id of the notification channel to use. Only required for API level 26 and
* above.
* @param channelId The id of the notification channel to use.
* @param contentIntent An optional content intent to send when the notification is clicked.
* @param message An optional message to display on the notification.
* @param downloadStates The download states.
@ -51,50 +50,8 @@ public final class DownloadNotificationUtil {
@Nullable PendingIntent contentIntent,
@Nullable String message,
DownloadState[] downloadStates) {
float totalPercentage = 0;
int downloadTaskCount = 0;
boolean allDownloadPercentagesUnknown = true;
boolean haveDownloadedBytes = false;
boolean haveDownloadTasks = false;
boolean haveRemoveTasks = false;
for (DownloadState downloadState : downloadStates) {
if (downloadState.state == DownloadState.STATE_REMOVING
|| downloadState.state == DownloadState.STATE_RESTARTING
|| downloadState.state == DownloadState.STATE_REMOVED) {
haveRemoveTasks = true;
continue;
}
if (downloadState.state != DownloadState.STATE_DOWNLOADING
&& downloadState.state != DownloadState.STATE_COMPLETED) {
continue;
}
haveDownloadTasks = true;
if (downloadState.downloadPercentage != C.PERCENTAGE_UNSET) {
allDownloadPercentagesUnknown = false;
totalPercentage += downloadState.downloadPercentage;
}
haveDownloadedBytes |= downloadState.downloadedBytes > 0;
downloadTaskCount++;
}
int titleStringId =
haveDownloadTasks
? R.string.exo_download_downloading
: (haveRemoveTasks ? R.string.exo_download_removing : NULL_STRING_ID);
NotificationCompat.Builder notificationBuilder =
newNotificationBuilder(
context, smallIcon, channelId, contentIntent, message, titleStringId);
int progress = 0;
boolean indeterminate = true;
if (haveDownloadTasks) {
progress = (int) (totalPercentage / downloadTaskCount);
indeterminate = allDownloadPercentagesUnknown && haveDownloadedBytes;
}
notificationBuilder.setProgress(/* max= */ 100, progress, indeterminate);
notificationBuilder.setOngoing(true);
notificationBuilder.setShowWhen(false);
return notificationBuilder.build();
return new DownloadNotificationHelper(context, channelId)
.buildProgressNotification(smallIcon, contentIntent, message, downloadStates);
}
/**
@ -102,8 +59,7 @@ public final class DownloadNotificationUtil {
*
* @param context A context for accessing resources.
* @param smallIcon A small icon for the notifications.
* @param channelId The id of the notification channel to use. Only required for API level 26 and
* above.
* @param channelId The id of the notification channel to use.
* @param contentIntent An optional content intent to send when the notification is clicked.
* @param message An optional message to display on the notification.
* @return The notification.
@ -114,10 +70,8 @@ public final class DownloadNotificationUtil {
String channelId,
@Nullable PendingIntent contentIntent,
@Nullable String message) {
int titleStringId = R.string.exo_download_completed;
return newNotificationBuilder(
context, smallIcon, channelId, contentIntent, message, titleStringId)
.build();
return new DownloadNotificationHelper(context, channelId)
.buildDownloadCompletedNotification(smallIcon, contentIntent, message);
}
/**
@ -125,8 +79,7 @@ public final class DownloadNotificationUtil {
*
* @param context A context for accessing resources.
* @param smallIcon A small icon for the notifications.
* @param channelId The id of the notification channel to use. Only required for API level 26 and
* above.
* @param channelId The id of the notification channel to use.
* @param contentIntent An optional content intent to send when the notification is clicked.
* @param message An optional message to display on the notification.
* @return The notification.
@ -137,30 +90,7 @@ public final class DownloadNotificationUtil {
String channelId,
@Nullable PendingIntent contentIntent,
@Nullable String message) {
@StringRes int titleStringId = R.string.exo_download_failed;
return newNotificationBuilder(
context, smallIcon, channelId, contentIntent, message, titleStringId)
.build();
}
private static NotificationCompat.Builder newNotificationBuilder(
Context context,
@DrawableRes int smallIcon,
String channelId,
@Nullable PendingIntent contentIntent,
@Nullable String message,
@StringRes int titleStringId) {
NotificationCompat.Builder notificationBuilder =
new NotificationCompat.Builder(context, channelId).setSmallIcon(smallIcon);
if (titleStringId != NULL_STRING_ID) {
notificationBuilder.setContentTitle(context.getResources().getString(titleStringId));
}
if (contentIntent != null) {
notificationBuilder.setContentIntent(contentIntent);
}
if (message != null) {
notificationBuilder.setStyle(new NotificationCompat.BigTextStyle().bigText(message));
}
return notificationBuilder;
return new DownloadNotificationHelper(context, channelId)
.buildDownloadFailedNotification(smallIcon, contentIntent, message);
}
}

View file

@ -338,6 +338,7 @@ public class PlayerNotificationManager {
private final Context context;
private final String channelId;
private final NotificationCompat.Builder builder;
private final int notificationId;
private final MediaDescriptionAdapter mediaDescriptionAdapter;
private final @Nullable CustomActionReceiver customActionReceiver;
@ -530,12 +531,14 @@ public class PlayerNotificationManager {
MediaDescriptionAdapter mediaDescriptionAdapter,
@Nullable NotificationListener notificationListener,
@Nullable CustomActionReceiver customActionReceiver) {
this.context = context.getApplicationContext();
context = context.getApplicationContext();
this.context = context;
this.channelId = channelId;
this.notificationId = notificationId;
this.mediaDescriptionAdapter = mediaDescriptionAdapter;
this.notificationListener = notificationListener;
this.customActionReceiver = customActionReceiver;
builder = new NotificationCompat.Builder(context, channelId);
controlDispatcher = new DefaultControlDispatcher();
window = new Timeline.Window();
instanceId = instanceIdCounter++;
@ -887,7 +890,7 @@ public class PlayerNotificationManager {
private Notification startOrUpdateNotification(@Nullable Bitmap bitmap) {
Player player = this.player;
boolean ongoing = getOngoing(player);
Notification notification = createNotification(player, ongoing, bitmap);
Notification notification = createNotification(player, builder, ongoing, bitmap);
if (notification == null) {
stopNotification(/* dismissedByUser= */ false);
return null;
@ -923,6 +926,11 @@ public class PlayerNotificationManager {
* Creates the notification given the current player state.
*
* @param player The player for which state to build a notification.
* @param builder A builder that can optionally be used for creating the notification. The same
* builder is passed each time this method is called, since reusing the same builder can
* prevent notification flicker when {@code Util#SDK_INT} < 21. This means implementations
* must take care to ensure anything set on the builder during a previous call is cleared, if
* no longer required.
* @param ongoing Whether the notification should be ongoing.
* @param largeIcon The large icon to be used.
* @return The {@link Notification} which has been built, or {@code null} if no notification
@ -930,11 +938,15 @@ public class PlayerNotificationManager {
*/
@Nullable
protected Notification createNotification(
Player player, boolean ongoing, @Nullable Bitmap largeIcon) {
Player player,
NotificationCompat.Builder builder,
boolean ongoing,
@Nullable Bitmap largeIcon) {
if (player.getPlaybackState() == Player.STATE_IDLE) {
return null;
}
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channelId);
builder.mActions.clear();
List<String> actionNames = getActions(player);
for (int i = 0; i < actionNames.size(); i++) {
String actionName = actionNames.get(i);
@ -946,7 +958,7 @@ public class PlayerNotificationManager {
builder.addAction(action);
}
}
// Create a media style notification.
MediaStyle mediaStyle = new MediaStyle();
if (mediaSessionToken != null) {
mediaStyle.setMediaSession(mediaSessionToken);
@ -955,9 +967,11 @@ public class PlayerNotificationManager {
// Configure dismiss action prior to API 21 ('x' button).
mediaStyle.setShowCancelButton(!ongoing);
mediaStyle.setCancelButtonIntent(dismissPendingIntent);
builder.setStyle(mediaStyle);
// Set intent which is sent if the user selects 'clear all'
builder.setDeleteIntent(dismissPendingIntent);
builder.setStyle(mediaStyle);
// Set notification properties from getters.
builder
.setBadgeIconType(badgeIconType)
@ -968,7 +982,10 @@ public class PlayerNotificationManager {
.setVisibility(visibility)
.setPriority(priority)
.setDefaults(defaults);
if (useChronometer
// Changing "showWhen" causes notification flicker if SDK_INT < 21.
if (Util.SDK_INT >= 21
&& useChronometer
&& !player.isPlayingAd()
&& !player.isCurrentWindowDynamic()
&& player.getPlayWhenReady()
@ -980,6 +997,7 @@ public class PlayerNotificationManager {
} else {
builder.setShowWhen(false).setUsesChronometer(false);
}
// Set media specific notification properties from MediaDescriptionAdapter.
builder.setContentTitle(mediaDescriptionAdapter.getCurrentContentTitle(player));
builder.setContentText(mediaDescriptionAdapter.getCurrentContentText(player));
@ -989,13 +1007,9 @@ public class PlayerNotificationManager {
mediaDescriptionAdapter.getCurrentLargeIcon(
player, new BitmapCallback(++currentNotificationTag));
}
if (largeIcon != null) {
builder.setLargeIcon(largeIcon);
}
PendingIntent contentIntent = mediaDescriptionAdapter.createCurrentContentIntent(player);
if (contentIntent != null) {
builder.setContentIntent(contentIntent);
}
setLargeIcon(builder, largeIcon);
builder.setContentIntent(mediaDescriptionAdapter.createCurrentContentIntent(player));
return builder.build();
}
@ -1086,54 +1100,6 @@ public class PlayerNotificationManager {
&& player.getPlayWhenReady();
}
private static Map<String, NotificationCompat.Action> createPlaybackActions(
Context context, int instanceId) {
Map<String, NotificationCompat.Action> actions = new HashMap<>();
actions.put(
ACTION_PLAY,
new NotificationCompat.Action(
R.drawable.exo_notification_play,
context.getString(R.string.exo_controls_play_description),
createBroadcastIntent(ACTION_PLAY, context, instanceId)));
actions.put(
ACTION_PAUSE,
new NotificationCompat.Action(
R.drawable.exo_notification_pause,
context.getString(R.string.exo_controls_pause_description),
createBroadcastIntent(ACTION_PAUSE, context, instanceId)));
actions.put(
ACTION_STOP,
new NotificationCompat.Action(
R.drawable.exo_notification_stop,
context.getString(R.string.exo_controls_stop_description),
createBroadcastIntent(ACTION_STOP, context, instanceId)));
actions.put(
ACTION_REWIND,
new NotificationCompat.Action(
R.drawable.exo_notification_rewind,
context.getString(R.string.exo_controls_rewind_description),
createBroadcastIntent(ACTION_REWIND, context, instanceId)));
actions.put(
ACTION_FAST_FORWARD,
new NotificationCompat.Action(
R.drawable.exo_notification_fastforward,
context.getString(R.string.exo_controls_fastforward_description),
createBroadcastIntent(ACTION_FAST_FORWARD, context, instanceId)));
actions.put(
ACTION_PREVIOUS,
new NotificationCompat.Action(
R.drawable.exo_notification_previous,
context.getString(R.string.exo_controls_previous_description),
createBroadcastIntent(ACTION_PREVIOUS, context, instanceId)));
actions.put(
ACTION_NEXT,
new NotificationCompat.Action(
R.drawable.exo_notification_next,
context.getString(R.string.exo_controls_next_description),
createBroadcastIntent(ACTION_NEXT, context, instanceId)));
return actions;
}
private void previous(Player player) {
Timeline timeline = player.getCurrentTimeline();
if (timeline.isEmpty() || player.isPlayingAd()) {
@ -1196,6 +1162,54 @@ public class PlayerNotificationManager {
&& player.getPlayWhenReady();
}
private static Map<String, NotificationCompat.Action> createPlaybackActions(
Context context, int instanceId) {
Map<String, NotificationCompat.Action> actions = new HashMap<>();
actions.put(
ACTION_PLAY,
new NotificationCompat.Action(
R.drawable.exo_notification_play,
context.getString(R.string.exo_controls_play_description),
createBroadcastIntent(ACTION_PLAY, context, instanceId)));
actions.put(
ACTION_PAUSE,
new NotificationCompat.Action(
R.drawable.exo_notification_pause,
context.getString(R.string.exo_controls_pause_description),
createBroadcastIntent(ACTION_PAUSE, context, instanceId)));
actions.put(
ACTION_STOP,
new NotificationCompat.Action(
R.drawable.exo_notification_stop,
context.getString(R.string.exo_controls_stop_description),
createBroadcastIntent(ACTION_STOP, context, instanceId)));
actions.put(
ACTION_REWIND,
new NotificationCompat.Action(
R.drawable.exo_notification_rewind,
context.getString(R.string.exo_controls_rewind_description),
createBroadcastIntent(ACTION_REWIND, context, instanceId)));
actions.put(
ACTION_FAST_FORWARD,
new NotificationCompat.Action(
R.drawable.exo_notification_fastforward,
context.getString(R.string.exo_controls_fastforward_description),
createBroadcastIntent(ACTION_FAST_FORWARD, context, instanceId)));
actions.put(
ACTION_PREVIOUS,
new NotificationCompat.Action(
R.drawable.exo_notification_previous,
context.getString(R.string.exo_controls_previous_description),
createBroadcastIntent(ACTION_PREVIOUS, context, instanceId)));
actions.put(
ACTION_NEXT,
new NotificationCompat.Action(
R.drawable.exo_notification_next,
context.getString(R.string.exo_controls_next_description),
createBroadcastIntent(ACTION_NEXT, context, instanceId)));
return actions;
}
private static PendingIntent createBroadcastIntent(
String action, Context context, int instanceId) {
Intent intent = new Intent(action).setPackage(context.getPackageName());
@ -1204,6 +1218,11 @@ public class PlayerNotificationManager {
context, instanceId, intent, PendingIntent.FLAG_CANCEL_CURRENT);
}
@SuppressWarnings("nullness:argument.type.incompatible")
private static void setLargeIcon(NotificationCompat.Builder builder, @Nullable Bitmap largeIcon) {
builder.setLargeIcon(largeIcon);
}
private class PlayerListener implements Player.EventListener {
@Override