Create MediaNotification.Provider
Define MediaNotification.Provider so that apps can customize notification UX. Move MediaNotificationManager's functionality around notifications on DefaultMediaNotificationProvider PiperOrigin-RevId: 428024699
This commit is contained in:
parent
40a5c01275
commit
437e178ef8
@ -0,0 +1,149 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,173 @@
|
||||
/*
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,190 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
}
|
@ -15,30 +15,14 @@
|
||||
*/
|
||||
package androidx.media3.session;
|
||||
|
||||
import static android.support.v4.media.session.PlaybackStateCompat.ACTION_PAUSE;
|
||||
import static android.support.v4.media.session.PlaybackStateCompat.ACTION_PLAY;
|
||||
import static android.support.v4.media.session.PlaybackStateCompat.ACTION_SKIP_TO_NEXT;
|
||||
import static android.support.v4.media.session.PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS;
|
||||
import static android.support.v4.media.session.PlaybackStateCompat.ACTION_STOP;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.support.v4.media.session.PlaybackStateCompat;
|
||||
import android.view.KeyEvent;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.media.app.NotificationCompat.MediaStyle;
|
||||
import androidx.media3.common.MediaMetadata;
|
||||
import androidx.media3.common.Player;
|
||||
import androidx.media3.common.util.Util;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
@ -52,63 +36,33 @@ import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
/**
|
||||
* Provides default media notifications for {@link MediaSessionService} and sets the service as
|
||||
* Manages media notifications for a {@link MediaSessionService} and sets the service as
|
||||
* foreground/background according to the player state.
|
||||
*/
|
||||
/* package */ final class MediaNotificationManager {
|
||||
|
||||
private static final int NOTIFICATION_ID = 1001;
|
||||
private static final String NOTIFICATION_CHANNEL_ID = "default_channel_id";
|
||||
|
||||
private final MediaSessionService service;
|
||||
private final MediaSessionService mediaSessionService;
|
||||
private final MediaNotification.Provider mediaNotificationProvider;
|
||||
private final MediaNotification.ActionFactory actionFactory;
|
||||
private final NotificationManagerCompat notificationManagerCompat;
|
||||
private final Executor mainExecutor;
|
||||
private final NotificationManager notificationManager;
|
||||
private final String notificationChannelName;
|
||||
|
||||
private final Intent startSelfIntent;
|
||||
private final NotificationCompat.Action playAction;
|
||||
private final NotificationCompat.Action pauseAction;
|
||||
private final NotificationCompat.Action skipToPrevAction;
|
||||
private final NotificationCompat.Action skipToNextAction;
|
||||
|
||||
private final Map<MediaSession, ListenableFuture<MediaController>> controllerMap;
|
||||
|
||||
public MediaNotificationManager(MediaSessionService service) {
|
||||
this.service = service;
|
||||
private int totalNotificationCount;
|
||||
|
||||
public MediaNotificationManager(
|
||||
MediaSessionService mediaSessionService,
|
||||
MediaNotification.Provider mediaNotificationProvider,
|
||||
MediaNotification.ActionFactory actionFactory) {
|
||||
this.mediaSessionService = mediaSessionService;
|
||||
this.mediaNotificationProvider = mediaNotificationProvider;
|
||||
this.actionFactory = actionFactory;
|
||||
notificationManagerCompat = NotificationManagerCompat.from(mediaSessionService);
|
||||
Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
mainExecutor = (runnable) -> Util.postOrRun(mainHandler, runnable);
|
||||
startSelfIntent = new Intent(service, service.getClass());
|
||||
startSelfIntent = new Intent(mediaSessionService, mediaSessionService.getClass());
|
||||
controllerMap = new HashMap<>();
|
||||
|
||||
notificationManager =
|
||||
(NotificationManager) service.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
notificationChannelName =
|
||||
service.getResources().getString(R.string.default_notification_channel_name);
|
||||
|
||||
playAction =
|
||||
createNotificationAction(
|
||||
service,
|
||||
android.R.drawable.ic_media_play,
|
||||
R.string.media3_controls_play_description,
|
||||
ACTION_PLAY);
|
||||
pauseAction =
|
||||
createNotificationAction(
|
||||
service,
|
||||
android.R.drawable.ic_media_pause,
|
||||
R.string.media3_controls_pause_description,
|
||||
ACTION_PAUSE);
|
||||
skipToPrevAction =
|
||||
createNotificationAction(
|
||||
service,
|
||||
android.R.drawable.ic_media_previous,
|
||||
R.string.media3_controls_seek_to_previous_description,
|
||||
ACTION_SKIP_TO_PREVIOUS);
|
||||
skipToNextAction =
|
||||
createNotificationAction(
|
||||
service,
|
||||
android.R.drawable.ic_media_next,
|
||||
R.string.media3_controls_seek_to_next_description,
|
||||
ACTION_SKIP_TO_NEXT);
|
||||
}
|
||||
|
||||
public void addSession(MediaSession session) {
|
||||
@ -117,26 +71,22 @@ import java.util.concurrent.TimeoutException;
|
||||
}
|
||||
MediaControllerListener listener = new MediaControllerListener(session);
|
||||
ListenableFuture<MediaController> controllerFuture =
|
||||
new MediaController.Builder(service, session.getToken())
|
||||
new MediaController.Builder(mediaSessionService, session.getToken())
|
||||
.setListener(listener)
|
||||
.setApplicationLooper(Looper.getMainLooper())
|
||||
.buildAsync();
|
||||
controllerFuture.addListener(
|
||||
() -> {
|
||||
MediaController controller;
|
||||
try {
|
||||
controller = controllerFuture.get(/* time= */ 0, TimeUnit.MILLISECONDS);
|
||||
MediaController controller = controllerFuture.get(/* time= */ 0, TimeUnit.MILLISECONDS);
|
||||
listener.onConnected();
|
||||
controller.addListener(listener);
|
||||
} catch (CancellationException
|
||||
| ExecutionException
|
||||
| InterruptedException
|
||||
| TimeoutException e) {
|
||||
// MediaSession or MediaController is released too early. Stop monitoring session.
|
||||
service.removeSession(session);
|
||||
return;
|
||||
}
|
||||
if (controller != null) {
|
||||
listener.onConnected();
|
||||
controller.addListener(listener);
|
||||
// MediaSession or MediaController is released too early. Stop monitoring the session.
|
||||
mediaSessionService.removeSession(session);
|
||||
}
|
||||
},
|
||||
mainExecutor);
|
||||
@ -144,21 +94,59 @@ import java.util.concurrent.TimeoutException;
|
||||
}
|
||||
|
||||
public void removeSession(MediaSession session) {
|
||||
ListenableFuture<MediaController> controllerFuture = controllerMap.remove(session);
|
||||
@Nullable ListenableFuture<MediaController> controllerFuture = controllerMap.remove(session);
|
||||
if (controllerFuture != null) {
|
||||
MediaController.releaseFuture(controllerFuture);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateNotificationIfNeeded(MediaSession session) {
|
||||
@Nullable
|
||||
MediaSessionService.MediaNotification mediaNotification = service.onUpdateNotification(session);
|
||||
if (mediaNotification == null) {
|
||||
// The service implementation doesn't want to use the automatic start/stopForeground
|
||||
// feature.
|
||||
public void onCustomAction(MediaSession session, String action, Bundle extras) {
|
||||
@Nullable ListenableFuture<MediaController> controllerFuture = controllerMap.get(session);
|
||||
if (controllerFuture == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
MediaController mediaController = controllerFuture.get(0, TimeUnit.MILLISECONDS);
|
||||
mediaNotificationProvider.handleCustomAction(mediaController, action, extras);
|
||||
} catch (InterruptedException | ExecutionException | TimeoutException e) {
|
||||
// We should never reach this.
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateNotification(MediaSession session) {
|
||||
@Nullable ListenableFuture<MediaController> controllerFuture = controllerMap.get(session);
|
||||
if (controllerFuture == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
MediaController mediaController;
|
||||
try {
|
||||
mediaController = controllerFuture.get(0, TimeUnit.MILLISECONDS);
|
||||
} catch (ExecutionException | InterruptedException | TimeoutException e) {
|
||||
// We should never reach this point.
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
|
||||
int notificationSequence = ++this.totalNotificationCount;
|
||||
MediaNotification.Provider.Callback callback =
|
||||
notification ->
|
||||
mainExecutor.execute(
|
||||
() -> onNotificationUpdated(notificationSequence, session, notification));
|
||||
|
||||
MediaNotification mediaNotification =
|
||||
this.mediaNotificationProvider.createNotification(mediaController, actionFactory, callback);
|
||||
updateNotification(session, mediaNotification);
|
||||
}
|
||||
|
||||
private void onNotificationUpdated(
|
||||
int notificationSequence, MediaSession session, MediaNotification mediaNotification) {
|
||||
if (notificationSequence == this.totalNotificationCount) {
|
||||
updateNotification(session, mediaNotification);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateNotification(MediaSession session, MediaNotification mediaNotification) {
|
||||
int id = mediaNotification.notificationId;
|
||||
Notification notification = mediaNotification.notification;
|
||||
|
||||
@ -172,16 +160,16 @@ import java.util.concurrent.TimeoutException;
|
||||
|
||||
Player player = session.getPlayer();
|
||||
if (player.getPlayWhenReady()) {
|
||||
ContextCompat.startForegroundService(service, startSelfIntent);
|
||||
service.startForeground(id, notification);
|
||||
ContextCompat.startForegroundService(mediaSessionService, startSelfIntent);
|
||||
mediaSessionService.startForeground(id, notification);
|
||||
} else {
|
||||
stopForegroundServiceIfNeeded();
|
||||
notificationManager.notify(id, notification);
|
||||
notificationManagerCompat.notify(id, notification);
|
||||
}
|
||||
}
|
||||
|
||||
private void stopForegroundServiceIfNeeded() {
|
||||
List<MediaSession> sessions = service.getSessions();
|
||||
List<MediaSession> sessions = mediaSessionService.getSessions();
|
||||
for (int i = 0; i < sessions.size(); i++) {
|
||||
Player player = sessions.get(i).getPlayer();
|
||||
if (player.getPlayWhenReady()) {
|
||||
@ -191,104 +179,7 @@ import java.util.concurrent.TimeoutException;
|
||||
// Calling stopForeground(true) is a workaround for pre-L devices which prevents
|
||||
// the media notification from being undismissable.
|
||||
boolean shouldRemoveNotification = Util.SDK_INT < 21;
|
||||
service.stopForeground(shouldRemoveNotification);
|
||||
}
|
||||
|
||||
/** Creates a default media style notification for {@link MediaSessionService}. */
|
||||
public MediaSessionService.MediaNotification onUpdateNotification(MediaSession session) {
|
||||
Player player = session.getPlayer();
|
||||
|
||||
ensureNotificationChannel();
|
||||
|
||||
NotificationCompat.Builder builder =
|
||||
new NotificationCompat.Builder(service, NOTIFICATION_CHANNEL_ID);
|
||||
|
||||
// TODO(b/193193926): Filter actions depending on the player's available commands.
|
||||
builder.addAction(skipToPrevAction);
|
||||
if (player.getPlayWhenReady()) {
|
||||
builder.addAction(pauseAction);
|
||||
} else {
|
||||
builder.addAction(playAction);
|
||||
}
|
||||
builder.addAction(skipToNextAction);
|
||||
|
||||
// Set metadata info in the notification.
|
||||
MediaMetadata metadata = player.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);
|
||||
}
|
||||
|
||||
MediaStyle mediaStyle =
|
||||
new MediaStyle()
|
||||
.setCancelButtonIntent(createPendingIntent(service, ACTION_STOP))
|
||||
.setMediaSession(session.getSessionCompat().getSessionToken())
|
||||
.setShowActionsInCompactView(1 /* Show play/pause button only in compact view */);
|
||||
|
||||
Notification notification =
|
||||
builder
|
||||
.setContentIntent(session.getImpl().getSessionActivity())
|
||||
.setDeleteIntent(createPendingIntent(service, ACTION_STOP))
|
||||
.setOnlyAlertOnce(true)
|
||||
.setSmallIcon(getSmallIconResId())
|
||||
.setStyle(mediaStyle)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setOngoing(false)
|
||||
.build();
|
||||
|
||||
return new MediaSessionService.MediaNotification(NOTIFICATION_ID, notification);
|
||||
}
|
||||
|
||||
private void ensureNotificationChannel() {
|
||||
if (Util.SDK_INT < 26
|
||||
|| notificationManager.getNotificationChannel(NOTIFICATION_CHANNEL_ID) != null) {
|
||||
return;
|
||||
}
|
||||
// Need to create a notification channel.
|
||||
NotificationChannel channel =
|
||||
new NotificationChannel(
|
||||
NOTIFICATION_CHANNEL_ID, notificationChannelName, NotificationManager.IMPORTANCE_LOW);
|
||||
notificationManager.createNotificationChannel(channel);
|
||||
}
|
||||
|
||||
private int getSmallIconResId() {
|
||||
int appIcon = service.getApplicationInfo().icon;
|
||||
if (appIcon != 0) {
|
||||
return appIcon;
|
||||
} else {
|
||||
// App icon is not set.
|
||||
return Util.SDK_INT >= 21 ? R.drawable.media_session_service_notification_ic_music_note : 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static NotificationCompat.Action createNotificationAction(
|
||||
MediaSessionService service,
|
||||
int iconResId,
|
||||
int titleResId,
|
||||
@PlaybackStateCompat.Actions long action) {
|
||||
CharSequence title = service.getResources().getText(titleResId);
|
||||
return new NotificationCompat.Action(iconResId, title, createPendingIntent(service, action));
|
||||
}
|
||||
|
||||
private static PendingIntent createPendingIntent(
|
||||
MediaSessionService service, @PlaybackStateCompat.Actions long action) {
|
||||
int keyCode = PlaybackStateCompat.toKeyCode(action);
|
||||
Intent intent = new Intent(Intent.ACTION_MEDIA_BUTTON);
|
||||
intent.setComponent(new ComponentName(service, service.getClass()));
|
||||
intent.putExtra(Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_DOWN, keyCode));
|
||||
|
||||
if (Util.SDK_INT >= 26 && action != ACTION_PAUSE && action != ACTION_STOP) {
|
||||
return PendingIntent.getForegroundService(
|
||||
service, /* requestCode= */ keyCode, intent, PendingIntent.FLAG_IMMUTABLE);
|
||||
} else {
|
||||
return PendingIntent.getService(
|
||||
service,
|
||||
/* requestCode= */ keyCode,
|
||||
intent,
|
||||
Util.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0);
|
||||
}
|
||||
mediaSessionService.stopForeground(shouldRemoveNotification);
|
||||
}
|
||||
|
||||
private final class MediaControllerListener implements MediaController.Listener, Player.Listener {
|
||||
@ -299,20 +190,20 @@ import java.util.concurrent.TimeoutException;
|
||||
}
|
||||
|
||||
public void onConnected() {
|
||||
updateNotificationIfNeeded(session);
|
||||
updateNotification(session);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEvents(Player player, Player.Events events) {
|
||||
if (events.containsAny(
|
||||
Player.EVENT_PLAY_WHEN_READY_CHANGED, Player.EVENT_MEDIA_METADATA_CHANGED)) {
|
||||
updateNotificationIfNeeded(session);
|
||||
updateNotification(session);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisconnected(MediaController controller) {
|
||||
service.removeSession(session);
|
||||
mediaSessionService.removeSession(session);
|
||||
stopForegroundServiceIfNeeded();
|
||||
}
|
||||
}
|
||||
|
@ -20,8 +20,6 @@ import static androidx.media3.common.util.Assertions.checkNotNull;
|
||||
import static androidx.media3.common.util.Assertions.checkStateNotNull;
|
||||
import static androidx.media3.common.util.Util.postOrRun;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.Service;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
@ -35,13 +33,12 @@ import android.os.RemoteException;
|
||||
import android.view.KeyEvent;
|
||||
import androidx.annotation.CallSuper;
|
||||
import androidx.annotation.GuardedBy;
|
||||
import androidx.annotation.IntRange;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.collection.ArrayMap;
|
||||
import androidx.media.MediaBrowserServiceCompat;
|
||||
import androidx.media.MediaSessionManager;
|
||||
import androidx.media3.common.Player;
|
||||
import androidx.media3.common.util.Log;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.session.MediaSession.ControllerInfo;
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.ArrayList;
|
||||
@ -50,16 +47,17 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
|
||||
|
||||
/**
|
||||
* Superclass to be extended by services hosting {@link MediaSession media sessions}.
|
||||
*
|
||||
* <p>It's highly recommended for an app to use this class if they want to keep media playback in
|
||||
* the background. The service allows other apps to know that your app supports {@link MediaSession}
|
||||
* even when your app isn't running. For example, user's voice command may start your app to play
|
||||
* even when your app isn't running. For example, a user voice command may start your app to play
|
||||
* media.
|
||||
*
|
||||
* <p>To extend this class, declare the intent filter in your {@code AndroidManifest.xml}.
|
||||
* <p>To extend this class, declare the intent filter in your {@code AndroidManifest.xml}:
|
||||
*
|
||||
* <pre>{@code
|
||||
* <service android:name="NameOfYourService">
|
||||
@ -89,20 +87,21 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
*
|
||||
* <p>A media session service is a bound service. When a {@link MediaController} is created for the
|
||||
* service, the controller binds to the service. {@link #onGetSession(ControllerInfo)} will be
|
||||
* called inside of the {@link #onBind(Intent)}.
|
||||
* called from {@link #onBind(Intent)}.
|
||||
*
|
||||
* <p>After the binding, the session's {@link MediaSession.SessionCallback#onConnect(MediaSession,
|
||||
* <p>After binding, the session's {@link MediaSession.SessionCallback#onConnect(MediaSession,
|
||||
* MediaSession.ControllerInfo)} will be called to accept or reject the connection request from the
|
||||
* controller. If it's accepted, the controller will be available and keep the binding. If it's
|
||||
* rejected, the controller will unbind.
|
||||
*
|
||||
* <p>When a playback is started on the service, {@link #onUpdateNotification(MediaSession)} is
|
||||
* called and the service will become a <a
|
||||
* href="https://developer.android.com/guide/components/foreground-services">foreground service</a>.
|
||||
* It's required to keep the playback after the controller is destroyed. The service will become a
|
||||
* background service when all playbacks are stopped. Apps targeting {@code SDK_INT >= 28} must
|
||||
* request the permission, {@link android.Manifest.permission#FOREGROUND_SERVICE}, in order to make
|
||||
* the service foreground.
|
||||
* <p>When a playback is started on the service, the service will obtain a {@link MediaNotification}
|
||||
* from the {@link MediaNotification.Provider} that's set with {@link #setMediaNotificationProvider}
|
||||
* (or {@link DefaultMediaNotificationProvider}, if no provider is set), and the service will become
|
||||
* a <a href="https://developer.android.com/guide/components/foreground-services">foreground
|
||||
* service</a>. It's required to keep the playback after the controller is destroyed. The service
|
||||
* will become a background service when all playbacks are stopped. Apps targeting {@code SDK_INT >=
|
||||
* 28} must request the permission, {@link android.Manifest.permission#FOREGROUND_SERVICE}, in order
|
||||
* to make the service foreground.
|
||||
*
|
||||
* <p>The service will be destroyed when all sessions are closed, or no controller is binding to the
|
||||
* service while the service is in the background.
|
||||
@ -110,18 +109,18 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
* <h2 id="MultipleSessions">Supporting Multiple Sessions</h2>
|
||||
*
|
||||
* <p>Generally, multiple sessions aren't necessary for most media apps. One exception is if your
|
||||
* app can play multiple media content at the same time, but only for the playback of video-only
|
||||
* media or remote playback, since the <a
|
||||
* app can play multiple media contents at the same time, but only for playback of video-only media
|
||||
* or remote playback, since the <a
|
||||
* href="https://developer.android.com/guide/topics/media-apps/audio-focus">audio focus policy</a>
|
||||
* recommends not playing multiple audio content at the same time. Also, keep in mind that multiple
|
||||
* media sessions would make Android Auto and Bluetooth device with display to show your apps
|
||||
* recommends not playing multiple audio contents at the same time. Also, keep in mind that multiple
|
||||
* media sessions would make Android Auto and Bluetooth devices with a display to show your apps
|
||||
* multiple times, because they list up media sessions, not media apps.
|
||||
*
|
||||
* <p>However, if you're capable of handling multiple playbacks and want to keep their sessions
|
||||
* while the app is in the background, create multiple sessions and add them to this service with
|
||||
* {@link #addSession(MediaSession)}.
|
||||
*
|
||||
* <p>Note that {@link MediaController} can be created with {@link SessionToken} to connect to a
|
||||
* <p>Note that a {@link MediaController} can be created with {@link SessionToken} to connect to a
|
||||
* session in this service. In that case, {@link #onGetSession(ControllerInfo)} will be called to
|
||||
* decide which session to handle the connection request. Pick the best session among the added
|
||||
* sessions, or create a new session and return it from {@link #onGetSession(ControllerInfo)}.
|
||||
@ -144,8 +143,13 @@ public abstract class MediaSessionService extends Service {
|
||||
private MediaSessionServiceStub stub;
|
||||
|
||||
@GuardedBy("lock")
|
||||
@Nullable
|
||||
private MediaNotificationManager notificationHandler;
|
||||
private @MonotonicNonNull MediaNotificationManager mediaNotificationManager;
|
||||
|
||||
@GuardedBy("lock")
|
||||
private MediaNotification.@MonotonicNonNull Provider mediaNotificationProvider;
|
||||
|
||||
@GuardedBy("lock")
|
||||
private @MonotonicNonNull DefaultActionFactory actionFactory;
|
||||
|
||||
/** Creates a service. */
|
||||
public MediaSessionService() {
|
||||
@ -165,7 +169,6 @@ public abstract class MediaSessionService extends Service {
|
||||
super.onCreate();
|
||||
synchronized (lock) {
|
||||
stub = new MediaSessionServiceStub(this);
|
||||
notificationHandler = new MediaNotificationManager(this);
|
||||
}
|
||||
}
|
||||
|
||||
@ -179,7 +182,7 @@ public abstract class MediaSessionService extends Service {
|
||||
* session is closed. You don't need to manually call {@link #addSession(MediaSession)} nor {@link
|
||||
* #removeSession(MediaSession)}.
|
||||
*
|
||||
* <p>There are two special cases where the {@link ControllerInfo#getPackageName()} returns
|
||||
* <p>There are two special cases where the {@link ControllerInfo#getPackageName()} returns a
|
||||
* non-existent package name:
|
||||
*
|
||||
* <ul>
|
||||
@ -209,7 +212,7 @@ public abstract class MediaSessionService extends Service {
|
||||
* Adds a {@link MediaSession} to this service. This is not necessary for most media apps. See <a
|
||||
* href="#MultipleSessions">Supporting Multiple Sessions</a> for details.
|
||||
*
|
||||
* <p>Added session will be removed automatically when it's closed.
|
||||
* <p>The added session will be removed automatically when it's closed.
|
||||
*
|
||||
* @param session A session to be added.
|
||||
* @see #removeSession(MediaSession)
|
||||
@ -227,11 +230,8 @@ public abstract class MediaSessionService extends Service {
|
||||
if (old == null) {
|
||||
// Session has returned for the first time. Register callbacks.
|
||||
// TODO(b/191644474): Check whether the session is registered to multiple services.
|
||||
MediaNotificationManager handler;
|
||||
synchronized (lock) {
|
||||
handler = checkStateNotNull(notificationHandler);
|
||||
}
|
||||
postOrRun(mainHandler, () -> handler.addSession(session));
|
||||
MediaNotificationManager notificationManager = getMediaNotificationManager();
|
||||
postOrRun(mainHandler, () -> notificationManager.addSession(session));
|
||||
}
|
||||
}
|
||||
|
||||
@ -245,39 +245,12 @@ public abstract class MediaSessionService extends Service {
|
||||
*/
|
||||
public final void removeSession(MediaSession session) {
|
||||
checkNotNull(session, "session must not be null");
|
||||
MediaNotificationManager handler;
|
||||
synchronized (lock) {
|
||||
checkArgument(sessions.containsKey(session.getId()), "session not found");
|
||||
sessions.remove(session.getId());
|
||||
handler = checkStateNotNull(notificationHandler);
|
||||
}
|
||||
postOrRun(mainHandler, () -> handler.removeSession(session));
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when {@link MediaNotification} needs to be updated. Override this method to show or
|
||||
* cancel your own notification.
|
||||
*
|
||||
* <p>This will be called on the application thread of the underlying {@link Player} of {@link
|
||||
* MediaSession}.
|
||||
*
|
||||
* <p>With the notification returned by this method, the service becomes a <a
|
||||
* href="https://developer.android.com/guide/components/foreground-services">foreground
|
||||
* service</a> when the playback is started. Apps targeting {@code SDK_INT >= 28} must request the
|
||||
* permission, {@link android.Manifest.permission#FOREGROUND_SERVICE}. It becomes a background
|
||||
* service after the playback is stopped.
|
||||
*
|
||||
* @param session A session that needs notification update.
|
||||
* @return A {@link MediaNotification}, or {@code null} if you don't want the automatic
|
||||
* foreground/background transitions.
|
||||
*/
|
||||
@Nullable
|
||||
public MediaNotification onUpdateNotification(MediaSession session) {
|
||||
checkNotNull(session, "session must not be null");
|
||||
MediaNotificationManager handler;
|
||||
synchronized (lock) {
|
||||
handler = checkStateNotNull(notificationHandler, "Service hasn't created");
|
||||
}
|
||||
return handler.onUpdateNotification(session);
|
||||
MediaNotificationManager notificationManager = getMediaNotificationManager();
|
||||
postOrRun(mainHandler, () -> notificationManager.removeSession(session));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -347,9 +320,15 @@ public abstract class MediaSessionService extends Service {
|
||||
if (intent == null) {
|
||||
return START_STICKY;
|
||||
}
|
||||
if (Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction())) {
|
||||
|
||||
DefaultActionFactory actionFactory;
|
||||
synchronized (lock) {
|
||||
actionFactory = checkStateNotNull(this.actionFactory);
|
||||
}
|
||||
|
||||
@Nullable Uri uri = intent.getData();
|
||||
@Nullable MediaSession session = uri != null ? MediaSession.getSession(uri) : null;
|
||||
if (actionFactory.isMediaAction(intent)) {
|
||||
if (session == null) {
|
||||
ControllerInfo controllerInfo = ControllerInfo.createLegacyControllerInfo();
|
||||
session = onGetSession(controllerInfo);
|
||||
@ -358,10 +337,16 @@ public abstract class MediaSessionService extends Service {
|
||||
}
|
||||
addSession(session);
|
||||
}
|
||||
KeyEvent keyEvent = intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT);
|
||||
@Nullable KeyEvent keyEvent = actionFactory.getKeyEvent(intent);
|
||||
if (keyEvent != null) {
|
||||
session.getSessionCompat().getController().dispatchMediaButtonEvent(keyEvent);
|
||||
}
|
||||
} else if (actionFactory.isCustomAction(intent)) {
|
||||
@Nullable String customAction = actionFactory.getCustomAction(intent);
|
||||
if (session != null && customAction != null) {
|
||||
Bundle customExtras = actionFactory.getCustomActionExtras(intent);
|
||||
getMediaNotificationManager().onCustomAction(session, customAction, customExtras);
|
||||
}
|
||||
}
|
||||
return START_STICKY;
|
||||
}
|
||||
@ -383,35 +368,40 @@ public abstract class MediaSessionService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the {@link MediaNotification.Provider} to customize notifications.
|
||||
*
|
||||
* <p>This should be called before any session is attached to this service via {@link
|
||||
* #onGetSession(ControllerInfo)} or {@link #addSession(MediaSession)}. Otherwise a default UX
|
||||
* will be shown with {@link DefaultMediaNotificationProvider}.
|
||||
*/
|
||||
@UnstableApi
|
||||
protected final void setMediaNotificationProvider(
|
||||
MediaNotification.Provider mediaNotificationProvider) {
|
||||
checkNotNull(mediaNotificationProvider);
|
||||
synchronized (lock) {
|
||||
this.mediaNotificationProvider = mediaNotificationProvider;
|
||||
}
|
||||
}
|
||||
|
||||
/* package */ IBinder getServiceBinder() {
|
||||
synchronized (lock) {
|
||||
return checkStateNotNull(stub).asBinder();
|
||||
}
|
||||
}
|
||||
|
||||
/** A notification for media playback returned by {@link #onUpdateNotification(MediaSession)}. */
|
||||
public static final class MediaNotification {
|
||||
|
||||
/** 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} to make the {@link MediaSessionService} <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);
|
||||
private MediaNotificationManager getMediaNotificationManager() {
|
||||
synchronized (lock) {
|
||||
if (mediaNotificationManager == null) {
|
||||
if (mediaNotificationProvider == null) {
|
||||
mediaNotificationProvider = new DefaultMediaNotificationProvider(getApplicationContext());
|
||||
}
|
||||
actionFactory = new DefaultActionFactory(getApplicationContext());
|
||||
mediaNotificationManager =
|
||||
new MediaNotificationManager(
|
||||
/* mediaSessionService= */ this, mediaNotificationProvider, actionFactory);
|
||||
}
|
||||
return mediaNotificationManager;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,64 @@
|
||||
/*
|
||||
* 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();
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user