Commit 8d03fdfe by bachinger Committed by Ian Baker

Support custom actions with DefaultMediaNotificationProvider

Refactors the DefaultMediaNotificationProvider by separating the
selection of actions and building the notification with it.

The custom commands of the custom layout of the session are turned
into notification actions and when received from the notification
converted back to custom session commands that are sent to the
session.

PiperOrigin-RevId: 450404350
parent 0fa07359
...@@ -29,6 +29,8 @@ import static androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT; ...@@ -29,6 +29,8 @@ import static androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT;
import static androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM; import static androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM;
import static androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS; import static androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS;
import static androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM; import static androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM;
import static androidx.media3.common.util.Assertions.checkArgument;
import static androidx.media3.common.util.Assertions.checkNotNull;
import android.app.PendingIntent; import android.app.PendingIntent;
import android.app.Service; import android.app.Service;
...@@ -64,7 +66,7 @@ import androidx.media3.common.util.Util; ...@@ -64,7 +66,7 @@ import androidx.media3.common.util.Util;
@Override @Override
public NotificationCompat.Action createMediaAction( public NotificationCompat.Action createMediaAction(
IconCompat icon, CharSequence title, @Player.Command long command) { IconCompat icon, CharSequence title, @Player.Command int command) {
return new NotificationCompat.Action(icon, title, createMediaActionPendingIntent(command)); return new NotificationCompat.Action(icon, title, createMediaActionPendingIntent(command));
} }
...@@ -76,6 +78,20 @@ import androidx.media3.common.util.Util; ...@@ -76,6 +78,20 @@ import androidx.media3.common.util.Util;
} }
@Override @Override
public NotificationCompat.Action createCustomActionFromCustomCommandButton(
CommandButton customCommandButton) {
checkArgument(
customCommandButton.sessionCommand != null
&& customCommandButton.sessionCommand.commandCode
== SessionCommand.COMMAND_CODE_CUSTOM);
SessionCommand customCommand = checkNotNull(customCommandButton.sessionCommand);
return new NotificationCompat.Action(
IconCompat.createWithResource(service, customCommandButton.iconResId),
customCommandButton.displayName,
createCustomActionPendingIntent(customCommand.customAction, customCommand.customExtras));
}
@Override
public PendingIntent createMediaActionPendingIntent(@Player.Command long command) { public PendingIntent createMediaActionPendingIntent(@Player.Command long command) {
int keyCode = toKeyCode(command); int keyCode = toKeyCode(command);
Intent intent = new Intent(Intent.ACTION_MEDIA_BUTTON); Intent intent = new Intent(Intent.ACTION_MEDIA_BUTTON);
...@@ -120,7 +136,8 @@ import androidx.media3.common.util.Util; ...@@ -120,7 +136,8 @@ import androidx.media3.common.util.Util;
service, service,
/* requestCode= */ ++customActionPendingIntentRequestCode, /* requestCode= */ ++customActionPendingIntentRequestCode,
intent, intent,
Util.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0); PendingIntent.FLAG_UPDATE_CURRENT
| (Util.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0));
} }
/** Returns whether {@code intent} was part of a {@link #createMediaAction media action}. */ /** Returns whether {@code intent} was part of a {@link #createMediaAction media action}. */
......
...@@ -1077,7 +1077,7 @@ import org.checkerframework.checker.nullness.qual.NonNull; ...@@ -1077,7 +1077,7 @@ import org.checkerframework.checker.nullness.qual.NonNull;
/* timelineChangeReason= */ TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, /* timelineChangeReason= */ TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED,
/* ignored */ PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, /* ignored */ PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST,
/* positionDiscontinuity= */ false, /* positionDiscontinuity= */ false,
/* ignored= */ DISCONTINUITY_REASON_INTERNAL, /* ignored */ DISCONTINUITY_REASON_INTERNAL,
/* mediaItemTransition= */ oldTimeline.isEmpty(), /* mediaItemTransition= */ oldTimeline.isEmpty(),
MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED);
} }
...@@ -1987,7 +1987,7 @@ import org.checkerframework.checker.nullness.qual.NonNull; ...@@ -1987,7 +1987,7 @@ import org.checkerframework.checker.nullness.qual.NonNull;
/* timelineChangeReason= */ TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, /* timelineChangeReason= */ TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED,
/* ignored */ PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, /* ignored */ PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST,
/* positionDiscontinuity= */ false, /* positionDiscontinuity= */ false,
/* ignored= */ DISCONTINUITY_REASON_INTERNAL, /* ignored */ DISCONTINUITY_REASON_INTERNAL,
/* mediaItemTransition= */ false, /* mediaItemTransition= */ false,
/* ignored */ MEDIA_ITEM_TRANSITION_REASON_REPEAT); /* ignored */ MEDIA_ITEM_TRANSITION_REASON_REPEAT);
} }
...@@ -2262,7 +2262,7 @@ import org.checkerframework.checker.nullness.qual.NonNull; ...@@ -2262,7 +2262,7 @@ import org.checkerframework.checker.nullness.qual.NonNull;
IMediaSession getSessionInterfaceWithSessionCommandIfAble(SessionCommand command) { IMediaSession getSessionInterfaceWithSessionCommandIfAble(SessionCommand command) {
checkArgument(command.commandCode == COMMAND_CODE_CUSTOM); checkArgument(command.commandCode == COMMAND_CODE_CUSTOM);
if (!sessionCommands.contains(command)) { if (!sessionCommands.contains(command)) {
Log.w(TAG, "Controller isn't allowed to call session command:" + command); Log.w(TAG, "Controller isn't allowed to call custom session command:" + command.customAction);
return null; return null;
} }
return iSession; return iSession;
...@@ -2578,11 +2578,21 @@ import org.checkerframework.checker.nullness.qual.NonNull; ...@@ -2578,11 +2578,21 @@ import org.checkerframework.checker.nullness.qual.NonNull;
if (!isConnected()) { if (!isConnected()) {
return; return;
} }
List<CommandButton> validatedCustomLayout = new ArrayList<>();
for (int i = 0; i < layout.size(); i++) {
CommandButton button = layout.get(i);
if (intersectedPlayerCommands.contains(button.playerCommand)
|| (button.sessionCommand != null && sessionCommands.contains(button.sessionCommand))
|| (button.playerCommand != Player.COMMAND_INVALID
&& sessionCommands.contains(button.playerCommand))) {
validatedCustomLayout.add(button);
}
}
instance.notifyControllerListener( instance.notifyControllerListener(
listener -> { listener -> {
ListenableFuture<SessionResult> future = ListenableFuture<SessionResult> future =
checkNotNull( checkNotNull(
listener.onSetCustomLayout(instance, layout), listener.onSetCustomLayout(instance, validatedCustomLayout),
"MediaController.Listener#onSetCustomLayout() must not return null"); "MediaController.Listener#onSetCustomLayout() must not return null");
sendControllerResultWhenReady(seq, future); sendControllerResultWhenReady(seq, future);
}); });
......
...@@ -26,6 +26,8 @@ import androidx.core.app.NotificationCompat; ...@@ -26,6 +26,8 @@ import androidx.core.app.NotificationCompat;
import androidx.core.graphics.drawable.IconCompat; import androidx.core.graphics.drawable.IconCompat;
import androidx.media3.common.Player; import androidx.media3.common.Player;
import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.UnstableApi;
import com.google.common.collect.ImmutableList;
import java.util.List;
/** A notification for media playbacks. */ /** A notification for media playbacks. */
public final class MediaNotification { public final class MediaNotification {
...@@ -46,24 +48,41 @@ public final class MediaNotification { ...@@ -46,24 +48,41 @@ public final class MediaNotification {
* @param command A command to send when users trigger this action. * @param command A command to send when users trigger this action.
*/ */
NotificationCompat.Action createMediaAction( NotificationCompat.Action createMediaAction(
IconCompat icon, CharSequence title, @Player.Command long command); IconCompat icon, CharSequence title, @Player.Command int command);
/** /**
* Creates a {@link NotificationCompat.Action} for a notification with a custom action. Actions * 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 * created with this method are not expected to be handled by the library and will be forwarded
* to the {@linkplain MediaNotification.Provider#handleCustomAction notification provider} that * to the {@linkplain MediaNotification.Provider#handleCustomCommand notification provider} that
* provided them. * provided them.
* *
* @param icon The icon to show for this action. * @param icon The icon to show for this action.
* @param title The title of the action. * @param title The title of the action.
* @param customAction The custom action set. * @param customAction The custom action set.
* @param extras Extras to be included in the action. * @param extras Extras to be included in the action.
* @see MediaNotification.Provider#handleCustomAction * @see MediaNotification.Provider#handleCustomCommand
*/ */
NotificationCompat.Action createCustomAction( NotificationCompat.Action createCustomAction(
IconCompat icon, CharSequence title, String customAction, Bundle extras); IconCompat icon, CharSequence title, String customAction, Bundle extras);
/** /**
* Creates a {@link NotificationCompat.Action} for a notification from a custom command button.
* Actions created with this method are not expected to be handled by the library and will be
* forwarded to the {@linkplain MediaNotification.Provider#handleCustomCommand notification
* provider} that provided them.
*
* <p>The returned {@link NotificationCompat.Action} will have a {@link PendingIntent} with the
* extras from {@link SessionCommand#customExtras}. Accordingly the {@linkplain
* SessionCommand#customExtras command's extras} will be passed to {@link
* Provider#handleCustomCommand(MediaController, String, Bundle)} when the action is executed.
*
* @param customCommandButton A {@linkplain CommandButton custom command button}.
* @see MediaNotification.Provider#handleCustomCommand
*/
NotificationCompat.Action createCustomActionFromCustomCommandButton(
CommandButton customCommandButton);
/**
* Creates a {@link PendingIntent} for a media action that will be handled by the library. * Creates a {@link PendingIntent} for a media action that will be handled by the library.
* *
* @param command The intent's command. * @param command The intent's command.
...@@ -100,24 +119,28 @@ public final class MediaNotification { ...@@ -100,24 +119,28 @@ public final class MediaNotification {
* @param mediaController The controller of the session. * @param mediaController The controller of the session.
* @param actionFactory The {@link ActionFactory} for creating notification {@linkplain * @param actionFactory The {@link ActionFactory} for creating notification {@linkplain
* NotificationCompat.Action actions}. * NotificationCompat.Action actions}.
* @param customLayout The custom layout {@linkplain MediaSession#setCustomLayout(List) set by
* the session}.
* @param onNotificationChangedCallback A callback that the provider needs to notify when the * @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 * notification has changed and needs to be posted again, for example after a bitmap has
* been loaded asynchronously. * been loaded asynchronously.
*/ */
MediaNotification createNotification( MediaNotification createNotification(
MediaController mediaController, MediaController mediaController,
ImmutableList<CommandButton> customLayout,
ActionFactory actionFactory, ActionFactory actionFactory,
Callback onNotificationChangedCallback); Callback onNotificationChangedCallback);
/** /**
* Handles a notification's custom action. * Handles a notification's custom command.
* *
* @param mediaController The controller of the session. * @param mediaController The controller of the session.
* @param action The custom action. * @param action The custom command action.
* @param extras Extras set in the custom action, otherwise {@link Bundle#EMPTY}. * @param extras A bundle {@linkplain SessionCommand#customExtras set in the custom command},
* otherwise {@link Bundle#EMPTY}.
* @see ActionFactory#createCustomAction * @see ActionFactory#createCustomAction
*/ */
void handleCustomAction(MediaController mediaController, String action, Bundle extras); void handleCustomCommand(MediaController mediaController, String action, Bundle extras);
} }
/** The notification id. */ /** The notification id. */
......
...@@ -31,6 +31,7 @@ import androidx.core.content.ContextCompat; ...@@ -31,6 +31,7 @@ import androidx.core.content.ContextCompat;
import androidx.media3.common.Player; import androidx.media3.common.Player;
import androidx.media3.common.util.Log; import androidx.media3.common.util.Log;
import androidx.media3.common.util.Util; import androidx.media3.common.util.Util;
import com.google.common.collect.ImmutableList;
import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListenableFuture;
import java.util.HashMap; import java.util.HashMap;
...@@ -57,6 +58,7 @@ import java.util.concurrent.TimeoutException; ...@@ -57,6 +58,7 @@ import java.util.concurrent.TimeoutException;
private final Executor mainExecutor; private final Executor mainExecutor;
private final Intent startSelfIntent; private final Intent startSelfIntent;
private final Map<MediaSession, ListenableFuture<MediaController>> controllerMap; private final Map<MediaSession, ListenableFuture<MediaController>> controllerMap;
private final Map<MediaSession, ImmutableList<CommandButton>> customLayoutMap;
private int totalNotificationCount; private int totalNotificationCount;
@Nullable private MediaNotification mediaNotification; @Nullable private MediaNotification mediaNotification;
...@@ -73,13 +75,16 @@ import java.util.concurrent.TimeoutException; ...@@ -73,13 +75,16 @@ import java.util.concurrent.TimeoutException;
mainExecutor = (runnable) -> Util.postOrRun(mainHandler, runnable); mainExecutor = (runnable) -> Util.postOrRun(mainHandler, runnable);
startSelfIntent = new Intent(mediaSessionService, mediaSessionService.getClass()); startSelfIntent = new Intent(mediaSessionService, mediaSessionService.getClass());
controllerMap = new HashMap<>(); controllerMap = new HashMap<>();
customLayoutMap = new HashMap<>();
} }
public void addSession(MediaSession session) { public void addSession(MediaSession session) {
if (controllerMap.containsKey(session)) { if (controllerMap.containsKey(session)) {
return; return;
} }
MediaControllerListener listener = new MediaControllerListener(mediaSessionService, session); customLayoutMap.put(session, ImmutableList.of());
MediaControllerListener listener =
new MediaControllerListener(mediaSessionService, session, customLayoutMap);
ListenableFuture<MediaController> controllerFuture = ListenableFuture<MediaController> controllerFuture =
new MediaController.Builder(mediaSessionService, session.getToken()) new MediaController.Builder(mediaSessionService, session.getToken())
.setListener(listener) .setListener(listener)
...@@ -104,6 +109,7 @@ import java.util.concurrent.TimeoutException; ...@@ -104,6 +109,7 @@ import java.util.concurrent.TimeoutException;
} }
public void removeSession(MediaSession session) { public void removeSession(MediaSession session) {
customLayoutMap.remove(session);
@Nullable 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);
...@@ -117,7 +123,7 @@ import java.util.concurrent.TimeoutException; ...@@ -117,7 +123,7 @@ import java.util.concurrent.TimeoutException;
} }
try { try {
MediaController mediaController = controllerFuture.get(0, TimeUnit.MILLISECONDS); MediaController mediaController = controllerFuture.get(0, TimeUnit.MILLISECONDS);
mediaNotificationProvider.handleCustomAction(mediaController, action, extras); mediaNotificationProvider.handleCustomCommand(mediaController, action, extras);
} catch (InterruptedException | ExecutionException | TimeoutException e) { } catch (InterruptedException | ExecutionException | TimeoutException e) {
// We should never reach this. // We should never reach this.
throw new IllegalStateException(e); throw new IllegalStateException(e);
...@@ -150,7 +156,11 @@ import java.util.concurrent.TimeoutException; ...@@ -150,7 +156,11 @@ import java.util.concurrent.TimeoutException;
() -> onNotificationUpdated(notificationSequence, session, notification)); () -> onNotificationUpdated(notificationSequence, session, notification));
MediaNotification mediaNotification = MediaNotification mediaNotification =
this.mediaNotificationProvider.createNotification(mediaController, actionFactory, callback); this.mediaNotificationProvider.createNotification(
mediaController,
checkStateNotNull(customLayoutMap.get(session)),
actionFactory,
callback);
updateNotificationInternal(session, mediaNotification); updateNotificationInternal(session, mediaNotification);
} }
...@@ -229,10 +239,15 @@ import java.util.concurrent.TimeoutException; ...@@ -229,10 +239,15 @@ import java.util.concurrent.TimeoutException;
implements MediaController.Listener, Player.Listener { implements MediaController.Listener, Player.Listener {
private final MediaSessionService mediaSessionService; private final MediaSessionService mediaSessionService;
private final MediaSession session; private final MediaSession session;
private final Map<MediaSession, ImmutableList<CommandButton>> customLayoutMap;
public MediaControllerListener(MediaSessionService mediaSessionService, MediaSession session) { public MediaControllerListener(
MediaSessionService mediaSessionService,
MediaSession session,
Map<MediaSession, ImmutableList<CommandButton>> customLayoutMap) {
this.mediaSessionService = mediaSessionService; this.mediaSessionService = mediaSessionService;
this.session = session; this.session = session;
this.customLayoutMap = customLayoutMap;
} }
public void onConnected() { public void onConnected() {
...@@ -243,6 +258,14 @@ import java.util.concurrent.TimeoutException; ...@@ -243,6 +258,14 @@ import java.util.concurrent.TimeoutException;
} }
@Override @Override
public ListenableFuture<SessionResult> onSetCustomLayout(
MediaController controller, List<CommandButton> layout) {
customLayoutMap.put(session, ImmutableList.copyOf(layout));
mediaSessionService.onUpdateNotification(session);
return Futures.immediateFuture(new SessionResult(SessionResult.RESULT_SUCCESS));
}
@Override
public void onEvents(Player player, Player.Events events) { public void onEvents(Player player, Player.Events events) {
// We must limit the frequency of notification updates, otherwise the system may suppress // We must limit the frequency of notification updates, otherwise the system may suppress
// them. // them.
......
...@@ -631,9 +631,19 @@ public class MediaSession { ...@@ -631,9 +631,19 @@ public class MediaSession {
} }
/** /**
* Sets the custom layout and broadcasts it to all connected controllers including the legacy * Broadcasts the custom layout to all connected Media3 controllers and converts the buttons to
* custom actions in the legacy media session playback state (see {@code
* PlaybackStateCompat.Builder#addCustomAction(PlaybackStateCompat.CustomAction)}) for legacy
* controllers. * controllers.
* *
* <p>When converting, the {@link SessionCommand#customExtras custom extras of the session
* command} is used for the extras of the legacy custom action.
*
* <p>Media3 controllers that connect after calling this method will not receive the broadcast.
* You need to call {@link #setCustomLayout(ControllerInfo, List)} in {@link
* MediaSession.Callback#onPostConnect(MediaSession, ControllerInfo)} to make these controllers
* aware of the custom layout.
*
* @param layout The ordered list of {@link CommandButton}. * @param layout The ordered list of {@link CommandButton}.
*/ */
public void setCustomLayout(List<CommandButton> layout) { public void setCustomLayout(List<CommandButton> layout) {
......
...@@ -191,13 +191,14 @@ import org.checkerframework.checker.initialization.qual.Initialized; ...@@ -191,13 +191,14 @@ import org.checkerframework.checker.initialization.qual.Initialized;
} }
@Override @Override
public void onCustomAction(String action, @Nullable Bundle extras) { public void onCustomAction(String action, @Nullable Bundle args) {
Bundle args = extras == null ? Bundle.EMPTY : extras; SessionCommand command = new SessionCommand(action, /* extras= */ Bundle.EMPTY);
SessionCommand command = new SessionCommand(action, args);
dispatchSessionTaskWithSessionCommand( dispatchSessionTaskWithSessionCommand(
command, command,
controller -> controller ->
ignoreFuture(sessionImpl.onCustomCommandOnHandler(controller, command, args))); ignoreFuture(
sessionImpl.onCustomCommandOnHandler(
controller, command, args != null ? args : Bundle.EMPTY)));
} }
@Override @Override
......
...@@ -20,9 +20,12 @@ import static org.robolectric.Shadows.shadowOf; ...@@ -20,9 +20,12 @@ import static org.robolectric.Shadows.shadowOf;
import android.app.PendingIntent; import android.app.PendingIntent;
import android.content.Intent; import android.content.Intent;
import android.os.Bundle;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat;
import androidx.media3.common.Player; import androidx.media3.common.Player;
import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Assert;
import org.junit.Test; import org.junit.Test;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
import org.robolectric.Robolectric; import org.robolectric.Robolectric;
...@@ -64,6 +67,51 @@ public class DefaultActionFactoryTest { ...@@ -64,6 +67,51 @@ public class DefaultActionFactoryTest {
assertThat(actionFactory.isCustomAction(intent)).isFalse(); assertThat(actionFactory.isCustomAction(intent)).isFalse();
} }
@Test
public void createCustomActionFromCustomCommandButton() {
DefaultActionFactory actionFactory =
new DefaultActionFactory(Robolectric.setupService(TestService.class));
Bundle commandBundle = new Bundle();
commandBundle.putString("command-key", "command-value");
Bundle buttonBundle = new Bundle();
buttonBundle.putString("button-key", "button-value");
CommandButton customSessionCommand =
new CommandButton.Builder()
.setSessionCommand(new SessionCommand("a", commandBundle))
.setExtras(buttonBundle)
.setIconResId(R.drawable.media3_notification_pause)
.setDisplayName("name")
.build();
NotificationCompat.Action notificationAction =
actionFactory.createCustomActionFromCustomCommandButton(customSessionCommand);
assertThat(String.valueOf(notificationAction.title)).isEqualTo("name");
assertThat(notificationAction.getIconCompat().getResId())
.isEqualTo(R.drawable.media3_notification_pause);
assertThat(notificationAction.getExtras().size()).isEqualTo(0);
assertThat(notificationAction.getActionIntent()).isNotNull();
}
@Test
public void
createCustomActionFromCustomCommandButton_notACustomAction_throwsIllegalArgumentException() {
DefaultActionFactory actionFactory =
new DefaultActionFactory(Robolectric.setupService(TestService.class));
CommandButton customSessionCommand =
new CommandButton.Builder()
.setPlayerCommand(Player.COMMAND_PLAY_PAUSE)
.setIconResId(R.drawable.media3_notification_pause)
.setDisplayName("name")
.build();
Assert.assertThrows(
IllegalArgumentException.class,
() -> actionFactory.createCustomActionFromCustomCommandButton(customSessionCommand));
}
/** A test service for unit tests. */ /** A test service for unit tests. */
public static final class TestService extends MediaLibraryService { public static final class TestService extends MediaLibraryService {
@Nullable @Nullable
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment