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;
|
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.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.content.Intent;
|
||||||
import android.graphics.Bitmap;
|
import android.os.Bundle;
|
||||||
import android.graphics.BitmapFactory;
|
|
||||||
import android.os.Handler;
|
import android.os.Handler;
|
||||||
import android.os.Looper;
|
import android.os.Looper;
|
||||||
import android.support.v4.media.session.PlaybackStateCompat;
|
|
||||||
import android.view.KeyEvent;
|
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.core.app.NotificationCompat;
|
import androidx.core.app.NotificationManagerCompat;
|
||||||
import androidx.core.content.ContextCompat;
|
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.Player;
|
||||||
import androidx.media3.common.util.Util;
|
import androidx.media3.common.util.Util;
|
||||||
import com.google.common.util.concurrent.ListenableFuture;
|
import com.google.common.util.concurrent.ListenableFuture;
|
||||||
@ -52,63 +36,33 @@ import java.util.concurrent.TimeUnit;
|
|||||||
import java.util.concurrent.TimeoutException;
|
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.
|
* foreground/background according to the player state.
|
||||||
*/
|
*/
|
||||||
/* package */ final class MediaNotificationManager {
|
/* package */ final class MediaNotificationManager {
|
||||||
|
|
||||||
private static final int NOTIFICATION_ID = 1001;
|
private final MediaSessionService mediaSessionService;
|
||||||
private static final String NOTIFICATION_CHANNEL_ID = "default_channel_id";
|
private final MediaNotification.Provider mediaNotificationProvider;
|
||||||
|
private final MediaNotification.ActionFactory actionFactory;
|
||||||
private final MediaSessionService service;
|
private final NotificationManagerCompat notificationManagerCompat;
|
||||||
private final Executor mainExecutor;
|
private final Executor mainExecutor;
|
||||||
private final NotificationManager notificationManager;
|
|
||||||
private final String notificationChannelName;
|
|
||||||
|
|
||||||
private final Intent startSelfIntent;
|
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;
|
private final Map<MediaSession, ListenableFuture<MediaController>> controllerMap;
|
||||||
|
|
||||||
public MediaNotificationManager(MediaSessionService service) {
|
private int totalNotificationCount;
|
||||||
this.service = service;
|
|
||||||
|
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());
|
Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||||
mainExecutor = (runnable) -> Util.postOrRun(mainHandler, runnable);
|
mainExecutor = (runnable) -> Util.postOrRun(mainHandler, runnable);
|
||||||
startSelfIntent = new Intent(service, service.getClass());
|
startSelfIntent = new Intent(mediaSessionService, mediaSessionService.getClass());
|
||||||
controllerMap = new HashMap<>();
|
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) {
|
public void addSession(MediaSession session) {
|
||||||
@ -117,26 +71,22 @@ import java.util.concurrent.TimeoutException;
|
|||||||
}
|
}
|
||||||
MediaControllerListener listener = new MediaControllerListener(session);
|
MediaControllerListener listener = new MediaControllerListener(session);
|
||||||
ListenableFuture<MediaController> controllerFuture =
|
ListenableFuture<MediaController> controllerFuture =
|
||||||
new MediaController.Builder(service, session.getToken())
|
new MediaController.Builder(mediaSessionService, session.getToken())
|
||||||
.setListener(listener)
|
.setListener(listener)
|
||||||
.setApplicationLooper(Looper.getMainLooper())
|
.setApplicationLooper(Looper.getMainLooper())
|
||||||
.buildAsync();
|
.buildAsync();
|
||||||
controllerFuture.addListener(
|
controllerFuture.addListener(
|
||||||
() -> {
|
() -> {
|
||||||
MediaController controller;
|
|
||||||
try {
|
try {
|
||||||
controller = controllerFuture.get(/* time= */ 0, TimeUnit.MILLISECONDS);
|
MediaController controller = controllerFuture.get(/* time= */ 0, TimeUnit.MILLISECONDS);
|
||||||
|
listener.onConnected();
|
||||||
|
controller.addListener(listener);
|
||||||
} catch (CancellationException
|
} catch (CancellationException
|
||||||
| ExecutionException
|
| ExecutionException
|
||||||
| InterruptedException
|
| InterruptedException
|
||||||
| TimeoutException e) {
|
| TimeoutException e) {
|
||||||
// MediaSession or MediaController is released too early. Stop monitoring session.
|
// MediaSession or MediaController is released too early. Stop monitoring the session.
|
||||||
service.removeSession(session);
|
mediaSessionService.removeSession(session);
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (controller != null) {
|
|
||||||
listener.onConnected();
|
|
||||||
controller.addListener(listener);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mainExecutor);
|
mainExecutor);
|
||||||
@ -144,21 +94,59 @@ import java.util.concurrent.TimeoutException;
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void removeSession(MediaSession session) {
|
public void removeSession(MediaSession session) {
|
||||||
ListenableFuture<MediaController> controllerFuture = controllerMap.remove(session);
|
@Nullable ListenableFuture<MediaController> controllerFuture = controllerMap.remove(session);
|
||||||
if (controllerFuture != null) {
|
if (controllerFuture != null) {
|
||||||
MediaController.releaseFuture(controllerFuture);
|
MediaController.releaseFuture(controllerFuture);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void updateNotificationIfNeeded(MediaSession session) {
|
public void onCustomAction(MediaSession session, String action, Bundle extras) {
|
||||||
@Nullable
|
@Nullable ListenableFuture<MediaController> controllerFuture = controllerMap.get(session);
|
||||||
MediaSessionService.MediaNotification mediaNotification = service.onUpdateNotification(session);
|
if (controllerFuture == null) {
|
||||||
if (mediaNotification == null) {
|
return;
|
||||||
// The service implementation doesn't want to use the automatic start/stopForeground
|
}
|
||||||
// feature.
|
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;
|
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;
|
int id = mediaNotification.notificationId;
|
||||||
Notification notification = mediaNotification.notification;
|
Notification notification = mediaNotification.notification;
|
||||||
|
|
||||||
@ -172,16 +160,16 @@ import java.util.concurrent.TimeoutException;
|
|||||||
|
|
||||||
Player player = session.getPlayer();
|
Player player = session.getPlayer();
|
||||||
if (player.getPlayWhenReady()) {
|
if (player.getPlayWhenReady()) {
|
||||||
ContextCompat.startForegroundService(service, startSelfIntent);
|
ContextCompat.startForegroundService(mediaSessionService, startSelfIntent);
|
||||||
service.startForeground(id, notification);
|
mediaSessionService.startForeground(id, notification);
|
||||||
} else {
|
} else {
|
||||||
stopForegroundServiceIfNeeded();
|
stopForegroundServiceIfNeeded();
|
||||||
notificationManager.notify(id, notification);
|
notificationManagerCompat.notify(id, notification);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void stopForegroundServiceIfNeeded() {
|
private void stopForegroundServiceIfNeeded() {
|
||||||
List<MediaSession> sessions = service.getSessions();
|
List<MediaSession> sessions = mediaSessionService.getSessions();
|
||||||
for (int i = 0; i < sessions.size(); i++) {
|
for (int i = 0; i < sessions.size(); i++) {
|
||||||
Player player = sessions.get(i).getPlayer();
|
Player player = sessions.get(i).getPlayer();
|
||||||
if (player.getPlayWhenReady()) {
|
if (player.getPlayWhenReady()) {
|
||||||
@ -191,104 +179,7 @@ import java.util.concurrent.TimeoutException;
|
|||||||
// Calling stopForeground(true) is a workaround for pre-L devices which prevents
|
// Calling stopForeground(true) is a workaround for pre-L devices which prevents
|
||||||
// the media notification from being undismissable.
|
// the media notification from being undismissable.
|
||||||
boolean shouldRemoveNotification = Util.SDK_INT < 21;
|
boolean shouldRemoveNotification = Util.SDK_INT < 21;
|
||||||
service.stopForeground(shouldRemoveNotification);
|
mediaSessionService.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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private final class MediaControllerListener implements MediaController.Listener, Player.Listener {
|
private final class MediaControllerListener implements MediaController.Listener, Player.Listener {
|
||||||
@ -299,20 +190,20 @@ import java.util.concurrent.TimeoutException;
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void onConnected() {
|
public void onConnected() {
|
||||||
updateNotificationIfNeeded(session);
|
updateNotification(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onEvents(Player player, Player.Events events) {
|
public void onEvents(Player player, Player.Events events) {
|
||||||
if (events.containsAny(
|
if (events.containsAny(
|
||||||
Player.EVENT_PLAY_WHEN_READY_CHANGED, Player.EVENT_MEDIA_METADATA_CHANGED)) {
|
Player.EVENT_PLAY_WHEN_READY_CHANGED, Player.EVENT_MEDIA_METADATA_CHANGED)) {
|
||||||
updateNotificationIfNeeded(session);
|
updateNotification(session);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onDisconnected(MediaController controller) {
|
public void onDisconnected(MediaController controller) {
|
||||||
service.removeSession(session);
|
mediaSessionService.removeSession(session);
|
||||||
stopForegroundServiceIfNeeded();
|
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.Assertions.checkStateNotNull;
|
||||||
import static androidx.media3.common.util.Util.postOrRun;
|
import static androidx.media3.common.util.Util.postOrRun;
|
||||||
|
|
||||||
import android.app.Notification;
|
|
||||||
import android.app.NotificationManager;
|
|
||||||
import android.app.Service;
|
import android.app.Service;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
@ -35,13 +33,12 @@ import android.os.RemoteException;
|
|||||||
import android.view.KeyEvent;
|
import android.view.KeyEvent;
|
||||||
import androidx.annotation.CallSuper;
|
import androidx.annotation.CallSuper;
|
||||||
import androidx.annotation.GuardedBy;
|
import androidx.annotation.GuardedBy;
|
||||||
import androidx.annotation.IntRange;
|
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.collection.ArrayMap;
|
import androidx.collection.ArrayMap;
|
||||||
import androidx.media.MediaBrowserServiceCompat;
|
import androidx.media.MediaBrowserServiceCompat;
|
||||||
import androidx.media.MediaSessionManager;
|
import androidx.media.MediaSessionManager;
|
||||||
import androidx.media3.common.Player;
|
|
||||||
import androidx.media3.common.util.Log;
|
import androidx.media3.common.util.Log;
|
||||||
|
import androidx.media3.common.util.UnstableApi;
|
||||||
import androidx.media3.session.MediaSession.ControllerInfo;
|
import androidx.media3.session.MediaSession.ControllerInfo;
|
||||||
import java.lang.ref.WeakReference;
|
import java.lang.ref.WeakReference;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@ -50,16 +47,17 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Superclass to be extended by services hosting {@link MediaSession media sessions}.
|
* 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
|
* <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}
|
* 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.
|
* 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
|
* <pre>{@code
|
||||||
* <service android:name="NameOfYourService">
|
* <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
|
* <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
|
* 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
|
* 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
|
* controller. If it's accepted, the controller will be available and keep the binding. If it's
|
||||||
* rejected, the controller will unbind.
|
* rejected, the controller will unbind.
|
||||||
*
|
*
|
||||||
* <p>When a playback is started on the service, {@link #onUpdateNotification(MediaSession)} is
|
* <p>When a playback is started on the service, the service will obtain a {@link MediaNotification}
|
||||||
* called and the service will become a <a
|
* from the {@link MediaNotification.Provider} that's set with {@link #setMediaNotificationProvider}
|
||||||
* href="https://developer.android.com/guide/components/foreground-services">foreground service</a>.
|
* (or {@link DefaultMediaNotificationProvider}, if no provider is set), and the service will become
|
||||||
* It's required to keep the playback after the controller is destroyed. The service will become a
|
* a <a href="https://developer.android.com/guide/components/foreground-services">foreground
|
||||||
* background service when all playbacks are stopped. Apps targeting {@code SDK_INT >= 28} must
|
* service</a>. It's required to keep the playback after the controller is destroyed. The service
|
||||||
* request the permission, {@link android.Manifest.permission#FOREGROUND_SERVICE}, in order to make
|
* will become a background service when all playbacks are stopped. Apps targeting {@code SDK_INT >=
|
||||||
* the service foreground.
|
* 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
|
* <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.
|
* service while the service is in the background.
|
||||||
@ -110,18 +109,18 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||||||
* <h2 id="MultipleSessions">Supporting Multiple Sessions</h2>
|
* <h2 id="MultipleSessions">Supporting Multiple Sessions</h2>
|
||||||
*
|
*
|
||||||
* <p>Generally, multiple sessions aren't necessary for most media apps. One exception is if your
|
* <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
|
* app can play multiple media contents at the same time, but only for playback of video-only media
|
||||||
* media or remote playback, since the <a
|
* or remote playback, since the <a
|
||||||
* href="https://developer.android.com/guide/topics/media-apps/audio-focus">audio focus policy</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
|
* recommends not playing multiple audio contents 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
|
* 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.
|
* 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
|
* <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
|
* while the app is in the background, create multiple sessions and add them to this service with
|
||||||
* {@link #addSession(MediaSession)}.
|
* {@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
|
* 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
|
* 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)}.
|
* 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;
|
private MediaSessionServiceStub stub;
|
||||||
|
|
||||||
@GuardedBy("lock")
|
@GuardedBy("lock")
|
||||||
@Nullable
|
private @MonotonicNonNull MediaNotificationManager mediaNotificationManager;
|
||||||
private MediaNotificationManager notificationHandler;
|
|
||||||
|
@GuardedBy("lock")
|
||||||
|
private MediaNotification.@MonotonicNonNull Provider mediaNotificationProvider;
|
||||||
|
|
||||||
|
@GuardedBy("lock")
|
||||||
|
private @MonotonicNonNull DefaultActionFactory actionFactory;
|
||||||
|
|
||||||
/** Creates a service. */
|
/** Creates a service. */
|
||||||
public MediaSessionService() {
|
public MediaSessionService() {
|
||||||
@ -165,7 +169,6 @@ public abstract class MediaSessionService extends Service {
|
|||||||
super.onCreate();
|
super.onCreate();
|
||||||
synchronized (lock) {
|
synchronized (lock) {
|
||||||
stub = new MediaSessionServiceStub(this);
|
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
|
* session is closed. You don't need to manually call {@link #addSession(MediaSession)} nor {@link
|
||||||
* #removeSession(MediaSession)}.
|
* #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:
|
* non-existent package name:
|
||||||
*
|
*
|
||||||
* <ul>
|
* <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
|
* 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.
|
* 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.
|
* @param session A session to be added.
|
||||||
* @see #removeSession(MediaSession)
|
* @see #removeSession(MediaSession)
|
||||||
@ -227,11 +230,8 @@ public abstract class MediaSessionService extends Service {
|
|||||||
if (old == null) {
|
if (old == null) {
|
||||||
// Session has returned for the first time. Register callbacks.
|
// Session has returned for the first time. Register callbacks.
|
||||||
// TODO(b/191644474): Check whether the session is registered to multiple services.
|
// TODO(b/191644474): Check whether the session is registered to multiple services.
|
||||||
MediaNotificationManager handler;
|
MediaNotificationManager notificationManager = getMediaNotificationManager();
|
||||||
synchronized (lock) {
|
postOrRun(mainHandler, () -> notificationManager.addSession(session));
|
||||||
handler = checkStateNotNull(notificationHandler);
|
|
||||||
}
|
|
||||||
postOrRun(mainHandler, () -> handler.addSession(session));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -245,39 +245,12 @@ public abstract class MediaSessionService extends Service {
|
|||||||
*/
|
*/
|
||||||
public final void removeSession(MediaSession session) {
|
public final void removeSession(MediaSession session) {
|
||||||
checkNotNull(session, "session must not be null");
|
checkNotNull(session, "session must not be null");
|
||||||
MediaNotificationManager handler;
|
|
||||||
synchronized (lock) {
|
synchronized (lock) {
|
||||||
|
checkArgument(sessions.containsKey(session.getId()), "session not found");
|
||||||
sessions.remove(session.getId());
|
sessions.remove(session.getId());
|
||||||
handler = checkStateNotNull(notificationHandler);
|
|
||||||
}
|
}
|
||||||
postOrRun(mainHandler, () -> handler.removeSession(session));
|
MediaNotificationManager notificationManager = getMediaNotificationManager();
|
||||||
}
|
postOrRun(mainHandler, () -> notificationManager.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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -347,9 +320,15 @@ public abstract class MediaSessionService extends Service {
|
|||||||
if (intent == null) {
|
if (intent == null) {
|
||||||
return START_STICKY;
|
return START_STICKY;
|
||||||
}
|
}
|
||||||
if (Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction())) {
|
|
||||||
@Nullable Uri uri = intent.getData();
|
DefaultActionFactory actionFactory;
|
||||||
@Nullable MediaSession session = uri != null ? MediaSession.getSession(uri) : null;
|
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) {
|
if (session == null) {
|
||||||
ControllerInfo controllerInfo = ControllerInfo.createLegacyControllerInfo();
|
ControllerInfo controllerInfo = ControllerInfo.createLegacyControllerInfo();
|
||||||
session = onGetSession(controllerInfo);
|
session = onGetSession(controllerInfo);
|
||||||
@ -358,10 +337,16 @@ public abstract class MediaSessionService extends Service {
|
|||||||
}
|
}
|
||||||
addSession(session);
|
addSession(session);
|
||||||
}
|
}
|
||||||
KeyEvent keyEvent = intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT);
|
@Nullable KeyEvent keyEvent = actionFactory.getKeyEvent(intent);
|
||||||
if (keyEvent != null) {
|
if (keyEvent != null) {
|
||||||
session.getSessionCompat().getController().dispatchMediaButtonEvent(keyEvent);
|
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;
|
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() {
|
/* package */ IBinder getServiceBinder() {
|
||||||
synchronized (lock) {
|
synchronized (lock) {
|
||||||
return checkStateNotNull(stub).asBinder();
|
return checkStateNotNull(stub).asBinder();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A notification for media playback returned by {@link #onUpdateNotification(MediaSession)}. */
|
private MediaNotificationManager getMediaNotificationManager() {
|
||||||
public static final class MediaNotification {
|
synchronized (lock) {
|
||||||
|
if (mediaNotificationManager == null) {
|
||||||
/** The notification id. */
|
if (mediaNotificationProvider == null) {
|
||||||
@IntRange(from = 1)
|
mediaNotificationProvider = new DefaultMediaNotificationProvider(getApplicationContext());
|
||||||
public final int notificationId;
|
}
|
||||||
|
actionFactory = new DefaultActionFactory(getApplicationContext());
|
||||||
/** The {@link Notification}. */
|
mediaNotificationManager =
|
||||||
public final Notification notification;
|
new MediaNotificationManager(
|
||||||
|
/* mediaSessionService= */ this, mediaNotificationProvider, actionFactory);
|
||||||
/**
|
}
|
||||||
* Creates an instance.
|
return mediaNotificationManager;
|
||||||
*
|
|
||||||
* @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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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