Commit 437e178e by christosts Committed by Ian Baker

Create MediaNotification.Provider

Define MediaNotification.Provider so that apps can customize
notification UX. Move MediaNotificationManager's functionality
around notifications on DefaultMediaNotificationProvider

PiperOrigin-RevId: 428024699
parent 40a5c012
/*
* Copyright 2022 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 androidx.media3.session;
import android.app.PendingIntent;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.support.v4.media.session.PlaybackStateCompat;
import android.view.KeyEvent;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.core.app.NotificationCompat;
import androidx.core.graphics.drawable.IconCompat;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
/** The default {@link MediaNotification.ActionFactory}. */
@UnstableApi
/* package */ final class DefaultActionFactory implements MediaNotification.ActionFactory {
private static final String ACTION_CUSTOM = "androidx.media3.session.CUSTOM_NOTIFICATION_ACTION";
private static final String EXTRAS_KEY_ACTION_CUSTOM =
"androidx.media3.session.EXTRAS_KEY_CUSTOM_NOTIFICATION_ACTION";
public static final String EXTRAS_KEY_ACTION_CUSTOM_EXTRAS =
"androidx.media3.session.EXTRAS_KEY_CUSTOM_NOTIFICATION_ACTION_EXTRAS";
private final Context context;
public DefaultActionFactory(Context context) {
this.context = context.getApplicationContext();
}
@Override
public NotificationCompat.Action createMediaAction(
IconCompat icon, CharSequence title, @Command long command) {
return new NotificationCompat.Action(icon, title, createMediaActionPendingIntent(command));
}
@Override
public NotificationCompat.Action createCustomAction(
IconCompat icon, CharSequence title, String customAction, Bundle extras) {
return new NotificationCompat.Action(
icon, title, createCustomActionPendingIntent(customAction, extras));
}
@Override
public PendingIntent createMediaActionPendingIntent(@Command long command) {
int keyCode = PlaybackStateCompat.toKeyCode(command);
Intent intent = new Intent(Intent.ACTION_MEDIA_BUTTON);
intent.setComponent(new ComponentName(context, context.getClass()));
intent.putExtra(Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_DOWN, keyCode));
if (Util.SDK_INT >= 26 && command != COMMAND_PAUSE && command != COMMAND_STOP) {
return Api26.createPendingIntent(context, /* requestCode= */ keyCode, intent);
} else {
return PendingIntent.getService(
context,
/* requestCode= */ keyCode,
intent,
Util.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0);
}
}
private PendingIntent createCustomActionPendingIntent(String action, Bundle extras) {
Intent intent = new Intent(ACTION_CUSTOM);
intent.setComponent(new ComponentName(context, context.getClass()));
intent.putExtra(EXTRAS_KEY_ACTION_CUSTOM, action);
intent.putExtra(EXTRAS_KEY_ACTION_CUSTOM_EXTRAS, extras);
if (Util.SDK_INT >= 26) {
return Api26.createPendingIntent(
context, /* requestCode= */ KeyEvent.KEYCODE_UNKNOWN, intent);
} else {
return PendingIntent.getService(
context,
/* requestCode= */ KeyEvent.KEYCODE_UNKNOWN,
intent,
Util.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0);
}
}
/** Returns whether {@code intent} was part of a {@link #createMediaAction media action}. */
public boolean isMediaAction(Intent intent) {
return Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction());
}
/** Returns whether {@code intent} was part of a {@link #createCustomAction custom action }. */
public boolean isCustomAction(Intent intent) {
return ACTION_CUSTOM.equals(intent.getAction());
}
/**
* Returns the {@link KeyEvent} that was included in the media action, or {@code null} if no
* {@link KeyEvent} is found in the {@code intent}.
*/
@Nullable
public KeyEvent getKeyEvent(Intent intent) {
@Nullable Bundle extras = intent.getExtras();
if (extras != null && extras.containsKey(Intent.EXTRA_KEY_EVENT)) {
return extras.getParcelable(Intent.EXTRA_KEY_EVENT);
}
return null;
}
/**
* Returns the custom action that was included in the {@link #createCustomAction custom action},
* or {@code null} if no custom action is found in the {@code intent}.
*/
@Nullable
public String getCustomAction(Intent intent) {
@Nullable Bundle extras = intent.getExtras();
@Nullable Object customAction = extras != null ? extras.get(EXTRAS_KEY_ACTION_CUSTOM) : null;
return customAction instanceof String ? (String) customAction : null;
}
/**
* Returns extras that were included in the {@link #createCustomAction custom action}, or {@link
* Bundle#EMPTY} is no extras are found.
*/
public Bundle getCustomActionExtras(Intent intent) {
@Nullable Bundle extras = intent.getExtras();
@Nullable
Object customExtras = extras != null ? extras.get(EXTRAS_KEY_ACTION_CUSTOM_EXTRAS) : null;
return customExtras instanceof Bundle ? (Bundle) customExtras : Bundle.EMPTY;
}
@RequiresApi(26)
private static final class Api26 {
private Api26() {}
public static PendingIntent createPendingIntent(Context context, int keyCode, Intent intent) {
return PendingIntent.getForegroundService(
context, /* requestCode= */ keyCode, intent, PendingIntent.FLAG_IMMUTABLE);
}
}
}
/*
* Copyright 2022 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 androidx.media3.session;
import static androidx.media3.common.util.Assertions.checkStateNotNull;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.os.Bundle;
import androidx.core.app.NotificationCompat;
import androidx.core.graphics.drawable.IconCompat;
import androidx.media3.common.MediaMetadata;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
/**
* The default {@link MediaNotification.Provider}.
*
* <h2>Actions</h2>
*
* The following actions are included in the provided notifications:
*
* <ul>
* <li>{@link MediaNotification.ActionFactory#COMMAND_PLAY} to start playback. Included when
* {@link MediaController#getPlayWhenReady()} returns {@code false}.
* <li>{@link MediaNotification.ActionFactory#COMMAND_PAUSE}, to pause playback. Included when
* ({@link MediaController#getPlayWhenReady()} returns {@code true}.
* <li>{@link MediaNotification.ActionFactory#COMMAND_SKIP_TO_PREVIOUS} to skip to the previous
* item.
* <li>{@link MediaNotification.ActionFactory#COMMAND_SKIP_TO_NEXT} to skip to the next item.
* </ul>
*
* <h2>Drawables</h2>
*
* The drawables used can be overridden by drawables with the same names defined the application.
* The drawables are:
*
* <ul>
* <li><b>{@code media3_notification_play}</b> - The play icon.
* <li><b>{@code media3_notification_pause}</b> - The pause icon.
* <li><b>{@code media3_notification_seek_to_previous}</b> - The previous icon.
* <li><b>{@code media3_notification_seek_to_next}</b> - The next icon.
* </ul>
*/
@UnstableApi
/* package */ final class DefaultMediaNotificationProvider implements MediaNotification.Provider {
private static final int NOTIFICATION_ID = 1001;
private static final String NOTIFICATION_CHANNEL_ID = "default_channel_id";
private static final String NOTIFICATION_CHANNEL_NAME = "Now playing";
private final Context context;
private final NotificationManager notificationManager;
/** Creates an instance. */
public DefaultMediaNotificationProvider(Context context) {
this.context = context.getApplicationContext();
notificationManager =
checkStateNotNull(
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE));
}
@Override
public MediaNotification createNotification(
MediaController mediaController,
MediaNotification.ActionFactory actionFactory,
Callback onNotificationChangedCallback) {
ensureNotificationChannel();
NotificationCompat.Builder builder =
new NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID);
// TODO(b/193193926): Filter actions depending on the player's available commands.
// Skip to previous action.
builder.addAction(
actionFactory.createMediaAction(
IconCompat.createWithResource(context, R.drawable.media3_notification_seek_to_previous),
context.getString(R.string.media3_controls_seek_to_previous_description),
MediaNotification.ActionFactory.COMMAND_SKIP_TO_PREVIOUS));
if (mediaController.getPlayWhenReady()) {
// Pause action.
builder.addAction(
actionFactory.createMediaAction(
IconCompat.createWithResource(context, R.drawable.media3_notification_pause),
context.getString(R.string.media3_controls_pause_description),
MediaNotification.ActionFactory.COMMAND_PAUSE));
} else {
// Play action.
builder.addAction(
actionFactory.createMediaAction(
IconCompat.createWithResource(context, R.drawable.media3_notification_play),
context.getString(R.string.media3_controls_play_description),
MediaNotification.ActionFactory.COMMAND_PLAY));
}
// Skip to next action.
builder.addAction(
actionFactory.createMediaAction(
IconCompat.createWithResource(context, R.drawable.media3_notification_seek_to_next),
context.getString(R.string.media3_controls_seek_to_next_description),
MediaNotification.ActionFactory.COMMAND_SKIP_TO_NEXT));
// Set metadata info in the notification.
MediaMetadata metadata = mediaController.getMediaMetadata();
builder.setContentTitle(metadata.title).setContentText(metadata.artist);
if (metadata.artworkData != null) {
Bitmap artworkBitmap =
BitmapFactory.decodeByteArray(metadata.artworkData, 0, metadata.artworkData.length);
builder.setLargeIcon(artworkBitmap);
}
androidx.media.app.NotificationCompat.MediaStyle mediaStyle =
new androidx.media.app.NotificationCompat.MediaStyle()
.setCancelButtonIntent(
actionFactory.createMediaActionPendingIntent(
MediaNotification.ActionFactory.COMMAND_STOP))
.setShowActionsInCompactView(1 /* Show play/pause button only in compact view */);
Notification notification =
builder
.setContentIntent(mediaController.getSessionActivity())
.setDeleteIntent(
actionFactory.createMediaActionPendingIntent(
MediaNotification.ActionFactory.COMMAND_STOP))
.setOnlyAlertOnce(true)
.setSmallIcon(getSmallIconResId(context))
.setStyle(mediaStyle)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOngoing(false)
.build();
return new MediaNotification(NOTIFICATION_ID, notification);
}
@Override
public void handleCustomAction(MediaController mediaController, String action, Bundle extras) {
// We don't handle custom commands.
}
private void ensureNotificationChannel() {
if (Util.SDK_INT < 26
|| notificationManager.getNotificationChannel(NOTIFICATION_CHANNEL_ID) != null) {
return;
}
NotificationChannel channel =
new NotificationChannel(
NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW);
notificationManager.createNotificationChannel(channel);
}
private static int getSmallIconResId(Context context) {
int appIcon = context.getApplicationInfo().icon;
if (appIcon != 0) {
return appIcon;
} else {
return Util.SDK_INT >= 21 ? R.drawable.media_session_service_notification_ic_music_note : 0;
}
}
}
/*
* Copyright 2022 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 androidx.media3.session;
import static androidx.media3.common.util.Assertions.checkNotNull;
import static java.lang.annotation.ElementType.TYPE_USE;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.os.Bundle;
import android.support.v4.media.session.PlaybackStateCompat;
import androidx.annotation.IntRange;
import androidx.annotation.LongDef;
import androidx.core.app.NotificationCompat;
import androidx.core.graphics.drawable.IconCompat;
import androidx.media3.common.util.UnstableApi;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/** A notification for media playbacks. */
public final class MediaNotification {
/**
* Creates {@link NotificationCompat.Action actions} and {@link PendingIntent pending intents} for
* notifications.
*/
@UnstableApi
public interface ActionFactory {
/**
* Commands that can be included in a media action. One of {@link #COMMAND_PLAY}, {@link
* #COMMAND_PAUSE}, {@link #COMMAND_STOP}, {@link #COMMAND_REWIND}, {@link
* #COMMAND_FAST_FORWARD}, {@link #COMMAND_SKIP_TO_PREVIOUS}, {@link #COMMAND_SKIP_TO_NEXT} or
* {@link #COMMAND_SET_CAPTIONING_ENABLED}.
*/
@Documented
@Retention(RetentionPolicy.SOURCE)
@Target({TYPE_USE})
@LongDef({
COMMAND_PLAY,
COMMAND_PAUSE,
COMMAND_STOP,
COMMAND_REWIND,
COMMAND_FAST_FORWARD,
COMMAND_SKIP_TO_PREVIOUS,
COMMAND_SKIP_TO_NEXT,
COMMAND_SET_CAPTIONING_ENABLED
})
@interface Command {}
/** The command to start playback. */
long COMMAND_PLAY = PlaybackStateCompat.ACTION_PLAY;
/** The command to pause playback. */
long COMMAND_PAUSE = PlaybackStateCompat.ACTION_PAUSE;
/** The command to stop playback. */
long COMMAND_STOP = PlaybackStateCompat.ACTION_STOP;
/** The command to rewind. */
long COMMAND_REWIND = PlaybackStateCompat.ACTION_REWIND;
/** The command to fast forward. */
long COMMAND_FAST_FORWARD = PlaybackStateCompat.ACTION_FAST_FORWARD;
/** The command to skip to the previous item in the queue. */
long COMMAND_SKIP_TO_PREVIOUS = PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS;
/** The command to skip to the next item in the queue. */
long COMMAND_SKIP_TO_NEXT = PlaybackStateCompat.ACTION_SKIP_TO_NEXT;
/** The command to set captioning enabled. */
long COMMAND_SET_CAPTIONING_ENABLED = PlaybackStateCompat.ACTION_SET_CAPTIONING_ENABLED;
/**
* Creates a {@link NotificationCompat.Action} for a notification. These actions will be handled
* by the library.
*
* @param icon The icon to show for this action.
* @param title The title of the action.
* @param command A command to send when users trigger this action.
*/
NotificationCompat.Action createMediaAction(
IconCompat icon, CharSequence title, @Command long command);
/**
* Creates a {@link NotificationCompat.Action} for a notification with a custom action. Actions
* created with this method are not expected to be handled by the library and will be forwarded
* to the {@link MediaNotification.Provider#handleCustomAction notification provider} that
* provided them.
*
* @param icon The icon to show for this action.
* @param title The title of the action.
* @param customAction The custom action set.
* @param extras Extras to be included in the action.
* @see MediaNotification.Provider#handleCustomAction
*/
NotificationCompat.Action createCustomAction(
IconCompat icon, CharSequence title, String customAction, Bundle extras);
/**
* Creates a {@link PendingIntent} for a media action that will be handled by the library.
*
* @param command The intent's command.
*/
PendingIntent createMediaActionPendingIntent(@Command long command);
}
/**
* Provides {@link MediaNotification media notifications} to be posted as notifications that
* reflect the state of a {@link MediaController} and to send media commands to a {@link
* MediaSession}.
*
* <p>The provider is required to create a {@link androidx.core.app.NotificationChannelCompat
* notification channel}, which is required to show notification for {@code SDK_INT >= 26}.
*/
@UnstableApi
public interface Provider {
/** Receives updates for a notification. */
interface Callback {
/**
* Called when a {@link MediaNotification} is changed.
*
* <p>This callback is called when notifications are updated, for example after a bitmap is
* loaded asynchronously and needs to be displayed.
*
* @param notification The updated {@link MediaNotification}
*/
void onNotificationChanged(MediaNotification notification);
}
/**
* Creates a new {@link MediaNotification}.
*
* @param mediaController The controller of the session.
* @param actionFactory The {@link ActionFactory} for creating notification {@link
* NotificationCompat.Action actions}.
* @param onNotificationChangedCallback A callback that the provider needs to notify when the
* notification has changed and needs to be posted again, for example after a bitmap has
* been loaded asynchronously.
*/
MediaNotification createNotification(
MediaController mediaController,
ActionFactory actionFactory,
Callback onNotificationChangedCallback);
/**
* Handles a notification's custom action.
*
* @param mediaController The controller of the session.
* @param action The custom action.
* @param extras Extras set in the custom action, otherwise {@link Bundle#EMPTY}.
* @see ActionFactory#createCustomAction
*/
void handleCustomAction(MediaController mediaController, String action, Bundle extras);
}
/** The notification id. */
@IntRange(from = 1)
public final int notificationId;
/** The {@link Notification}. */
public final Notification notification;
/**
* Creates an instance.
*
* @param notificationId The notification id to be used for {@link NotificationManager#notify(int,
* Notification)}.
* @param notification A {@link Notification} that reflects the sate of a {@link MediaController}
* and to send media commands to a {@link MediaSession}. The notification may be used to start
* a service in the <a
* href="https://developer.android.com/guide/components/foreground-services">foreground</a>.
* It's highly recommended to use a {@link androidx.media.app.NotificationCompat.MediaStyle
* media style} {@link Notification notification}.
*/
public MediaNotification(@IntRange(from = 1) int notificationId, Notification notification) {
this.notificationId = notificationId;
this.notification = checkNotNull(notification);
}
}
/*
* Copyright 2022 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 androidx.media3.session;
import static com.google.common.truth.Truth.assertThat;
import static org.robolectric.Shadows.shadowOf;
import android.app.PendingIntent;
import android.content.Intent;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.shadows.ShadowPendingIntent;
/** Tests for {@link DefaultActionFactory}. */
@RunWith(AndroidJUnit4.class)
public class DefaultActionFactoryTest {
@Test
public void createMediaPendingIntent_intentIsMediaAction() {
DefaultActionFactory actionFactory =
new DefaultActionFactory(ApplicationProvider.getApplicationContext());
PendingIntent pendingIntent =
actionFactory.createMediaActionPendingIntent(MediaNotification.ActionFactory.COMMAND_PLAY);
ShadowPendingIntent shadowPendingIntent = shadowOf(pendingIntent);
assertThat(actionFactory.isMediaAction(shadowPendingIntent.getSavedIntent())).isTrue();
}
@Test
public void isMediaAction_withNonMediaIntent_returnsFalse() {
DefaultActionFactory actionFactory =
new DefaultActionFactory(ApplicationProvider.getApplicationContext());
Intent intent = new Intent("invalid_action");
assertThat(actionFactory.isMediaAction(intent)).isFalse();
}
@Test
public void isCustomAction_withNonCustomActionIntent_returnsFalse() {
DefaultActionFactory actionFactory =
new DefaultActionFactory(ApplicationProvider.getApplicationContext());
Intent intent = new Intent("invalid_action");
assertThat(actionFactory.isCustomAction(intent)).isFalse();
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment