summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorArnaud Berry <arnaudberry@google.com>2023-06-20 10:26:06 -0700
committerArnaud Berry <arnaudberry@google.com>2023-07-06 11:30:10 -0700
commit7c34a65c65894010c234ae6c68fce1329bfff7d0 (patch)
tree0a94e4fdc0ed3697dfd517fe89b32fae864c21c9
parentf04f5a69e6ee729305385ec347386d65266f617c (diff)
downloadMedia-7c34a65c65894010c234ae6c68fce1329bfff7d0.tar.gz
Ensure the correct media source is started by MediaConnectorService
Support optional component in the intent that starts MediaConnectorService to prevent a (rare) mismatch when switching quickly between sources. Bug: 281786403 Test: MediaConnectorServiceTests Change-Id: I80ca9e3c2afa0920c583c461ddff1802ac516611
-rw-r--r--AndroidManifest.xml3
-rw-r--r--src/com/android/car/media/service/MediaConnectorService.java203
-rw-r--r--tests/unittests/src/com/android/car/media/MediaConnectorServiceTests.java154
3 files changed, 324 insertions, 36 deletions
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 13276796..67057814 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -70,9 +70,10 @@
</intent-filter>
</activity>
+ <!-- Only the system should call this service => exported=false. -->
<service
android:name=".service.MediaConnectorService"
- android:exported="true">
+ android:exported="false">
<intent-filter>
<action android:name="com.android.car.media.MEDIA_CONNECTION"/>
</intent-filter>
diff --git a/src/com/android/car/media/service/MediaConnectorService.java b/src/com/android/car/media/service/MediaConnectorService.java
index 25fbcbff..3ac1ab5f 100644
--- a/src/com/android/car/media/service/MediaConnectorService.java
+++ b/src/com/android/car/media/service/MediaConnectorService.java
@@ -16,41 +16,92 @@
package com.android.car.media.service;
+import static android.car.media.CarMediaIntents.EXTRA_MEDIA_COMPONENT;
import static android.car.media.CarMediaManager.MEDIA_SOURCE_MODE_PLAYBACK;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
+import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
-import android.os.IBinder;
+import android.support.v4.media.session.MediaControllerCompat;
import android.support.v4.media.session.PlaybackStateCompat;
+import android.text.TextUtils;
import android.util.Log;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
import androidx.core.app.NotificationCompat;
import androidx.lifecycle.LifecycleService;
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.Observer;
import com.android.car.media.R;
import com.android.car.media.common.playback.PlaybackViewModel;
+import com.android.car.media.common.playback.PlaybackViewModel.PlaybackStateWrapper;
+
+import java.util.Objects;
/**
* This service is started by CarMediaService when a new user is unlocked. It connects to the
* media source provided by CarMediaService and calls prepare() on the active MediaSession.
* Additionally, CarMediaService can instruct this service to autoplay, in which case this service
* will attempt to play the source before stopping.
- *
- * TODO(b/139497602): merge this class into CarMediaService, so it doesn't depend on Media Center
*/
public class MediaConnectorService extends LifecycleService {
- private static int FOREGROUND_NOTIFICATION_ID = 1;
+ private static final String TAG = "MediaConnectorSvc";
+
+ private static final int FOREGROUND_NOTIFICATION_ID = 1;
private static final String NOTIFICATION_CHANNEL_ID = "com.android.car.media.service";
private static final String NOTIFICATION_CHANNEL_NAME = "MediaConnectorService";
private static final String EXTRA_AUTOPLAY = "com.android.car.media.autoplay";
+
+ private static class TaskInfo {
+ private final int mStartId;
+ private final ComponentName mMediaComp;
+ private final boolean mAutoPlay;
+
+ TaskInfo(int startId, ComponentName mediaComp, boolean autoPlay) {
+ mStartId = startId;
+ mMediaComp = mediaComp;
+ mAutoPlay = autoPlay;
+ }
+ }
+
+ private LiveData<PlaybackStateWrapper> mPlaybackLiveData;
+ private TaskInfo mCurrentTask;
+
+ @SuppressWarnings("unused")
+ public MediaConnectorService() {
+ mPlaybackLiveData = null;
+ }
+
+ @VisibleForTesting
+ public MediaConnectorService(LiveData<PlaybackStateWrapper> playbackLiveData) {
+ mPlaybackLiveData = playbackLiveData;
+ }
+
+ @VisibleForTesting
+ @Override
+ public void attachBaseContext(Context base) {
+ super.attachBaseContext(base);
+ }
+
@Override
public void onCreate() {
super.onCreate();
+
+ if (mPlaybackLiveData == null) {
+ mPlaybackLiveData = PlaybackViewModel.get(getApplication(), MEDIA_SOURCE_MODE_PLAYBACK)
+ .getPlaybackStateWrapper();
+ }
+
+ // A single observer simplifies the service (less risk to end up with multiple ones).
+ mPlaybackLiveData.observe(this, mPlaybackStateListener);
+
NotificationChannel channel = new NotificationChannel(NOTIFICATION_CHANNEL_ID,
NOTIFICATION_CHANNEL_NAME, NotificationManager.IMPORTANCE_NONE);
NotificationManager manager =
@@ -58,43 +109,113 @@ public class MediaConnectorService extends LifecycleService {
manager.createNotificationChannel(channel);
}
- @Override
- public IBinder onBind(Intent intent) {
- return super.onBind(intent);
+ private final Observer<PlaybackStateWrapper> mPlaybackStateListener =
+ new Observer<PlaybackStateWrapper>() {
+ @Override
+ public void onChanged(PlaybackStateWrapper state) {
+ if (mCurrentTask == null) {
+ if (Log.isLoggable(TAG, Log.INFO)) {
+ Log.i(TAG, "No task, ignoring playback state");
+ }
+ return;
+ }
+
+ if (state == null) {
+ if (Log.isLoggable(TAG, Log.INFO)) {
+ Log.i(TAG, "null state");
+ }
+ return;
+ }
+
+ // If the source to play was specified in the intent ignore others.
+ ComponentName intentComp = mCurrentTask.mMediaComp;
+ ComponentName stateComp = state.getMediaSource().getBrowseServiceComponentName();
+ if (intentComp != null && !Objects.equals(stateComp, intentComp)) {
+ if (Log.isLoggable(TAG, Log.INFO)) {
+ Log.i(TAG, "media sources don't match! stateComp: " + stateComp
+ + " intent intentComp: " + intentComp);
+ }
+ return;
+ }
+
+ // Listen to playback state updates to determine which actions are supported;
+ // relevant actions here are prepare() and play()
+ // If we should autoplay the source, we wait until play() is available before we
+ // stop the service, otherwise just calling prepare() is sufficient.
+ int startId = mCurrentTask.mStartId;
+ boolean autoPlay = mCurrentTask.mAutoPlay;
+ if (state.isPlaying()) {
+ if (Log.isLoggable(TAG, Log.INFO)) {
+ Log.i(TAG, "Already playing");
+ }
+ stopTask();
+ return;
+ }
+ MediaControllerCompat controller = state.getMediaController();
+ if (controller == null) {
+ Log.w(TAG, "controller is null");
+ return;
+ }
+ MediaControllerCompat.TransportControls controls = controller.getTransportControls();
+ if (controls == null) {
+ Log.w(TAG, "controls is null");
+ return;
+ }
+
+ long actions = state.getSupportedActions();
+ if ((actions & PlaybackStateCompat.ACTION_PREPARE) != 0) {
+ if (Log.isLoggable(TAG, Log.INFO)) {
+ Log.i(TAG, "prepare startId: " + startId + " AutoPlay: " + autoPlay);
+ }
+ controls.prepare();
+ if (!autoPlay) {
+ stopTask();
+ }
+ }
+ if (autoPlay && (actions & PlaybackStateCompat.ACTION_PLAY) != 0) {
+ if (Log.isLoggable(TAG, Log.INFO)) {
+ Log.i(TAG, "play startId: " + startId);
+ }
+ controls.play();
+ stopTask();
+ }
+ }
+ };
+
+ @Nullable
+ private ComponentName getMediaComponentFromIntent(Intent intent) {
+ String componentNameExtra = intent.getStringExtra(EXTRA_MEDIA_COMPONENT);
+ if (TextUtils.isEmpty(componentNameExtra)) {
+ Log.w(TAG, "EXTRA_MEDIA_COMPONENT not specified");
+ return null;
+ }
+
+ ComponentName componentName = ComponentName.unflattenFromString(componentNameExtra);
+ if (componentName == null) {
+ Log.w(TAG, "Failed to un flatten: " + componentNameExtra);
+ return null;
+ }
+
+ return componentName;
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
super.onStartCommand(intent, flags, startId);
- final boolean autoplay = intent.getBooleanExtra(EXTRA_AUTOPLAY, false);
- PlaybackViewModel playbackViewModel = PlaybackViewModel.get(getApplication(),
- MEDIA_SOURCE_MODE_PLAYBACK);
- // Listen to playback state updates to determine which actions are supported;
- // relevant actions here are prepare() and play()
- // If we should autoplay the source, we wait until play() is available before we
- // stop the service, otherwise just calling prepare() is sufficient.
- playbackViewModel.getPlaybackStateWrapper().observe(this,
- playbackStateWrapper -> {
- if (playbackStateWrapper != null) {
- if (playbackStateWrapper.isPlaying()) {
- stopSelf(startId);
- return;
- }
- if ((playbackStateWrapper.getSupportedActions()
- & PlaybackStateCompat.ACTION_PREPARE) != 0) {
- playbackViewModel.getPlaybackController().getValue().prepare();
- if (!autoplay) {
- stopSelf(startId);
- }
- }
- if (autoplay && (playbackStateWrapper.getSupportedActions()
- & PlaybackStateCompat.ACTION_PLAY) != 0) {
- playbackViewModel.getPlaybackController().getValue().play();
- stopSelf(startId);
- }
- }
- });
+ boolean autoPlay = intent.getBooleanExtra(EXTRA_AUTOPLAY, false);
+ ComponentName mediaComp = getMediaComponentFromIntent(intent);
+
+ if (Log.isLoggable(TAG, Log.INFO)) {
+ Log.i(TAG, "onStartCommand startId: " + startId + " autoPlay: " + autoPlay
+ + " mediaComp: " + mediaComp);
+ }
+
+ // Ignore the old value of mCurrentTask. When it is non null, MediaConnectorService
+ // is still trying to execute an old onStartCommand. However a newer call to onStartCommand
+ // must take precedence, so just create and assign a new TaskInfo.
+ mCurrentTask = new TaskInfo(startId, mediaComp, autoPlay);
+ mPlaybackStateListener.onChanged(mPlaybackLiveData.getValue());
// Since this service is started from CarMediaService (which runs in background), we need
// to call startForeground to prevent the system from stopping this service and ANRing.
@@ -105,4 +226,16 @@ public class MediaConnectorService extends LifecycleService {
startForeground(FOREGROUND_NOTIFICATION_ID, notification);
return START_NOT_STICKY;
}
+
+ private void stopTask() {
+ if (mCurrentTask != null) {
+ if (Log.isLoggable(TAG, Log.INFO)) {
+ Log.i(TAG, "Stopping: " + mCurrentTask.mStartId);
+ }
+ stopSelf(mCurrentTask.mStartId);
+ mCurrentTask = null;
+ } else {
+ Log.w(TAG, "Already stopped.");
+ }
+ }
}
diff --git a/tests/unittests/src/com/android/car/media/MediaConnectorServiceTests.java b/tests/unittests/src/com/android/car/media/MediaConnectorServiceTests.java
new file mode 100644
index 00000000..81b08440
--- /dev/null
+++ b/tests/unittests/src/com/android/car/media/MediaConnectorServiceTests.java
@@ -0,0 +1,154 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.car.media;
+
+import static android.support.v4.media.session.PlaybackStateCompat.ACTION_PLAY;
+import static android.support.v4.media.session.PlaybackStateCompat.ACTION_PREPARE;
+
+import static com.android.car.apps.common.util.LiveDataFunctions.dataOf;
+
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.car.media.CarMediaIntents;
+import android.content.ComponentName;
+import android.content.Intent;
+import android.graphics.Path;
+import android.graphics.drawable.ColorDrawable;
+import android.graphics.drawable.Drawable;
+import android.support.v4.media.MediaMetadataCompat;
+import android.support.v4.media.session.MediaControllerCompat;
+import android.support.v4.media.session.PlaybackStateCompat;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.lifecycle.MutableLiveData;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import com.android.car.apps.common.IconCropper;
+import com.android.car.media.common.playback.PlaybackViewModel.PlaybackStateWrapper;
+import com.android.car.media.common.source.MediaSource;
+import com.android.car.media.service.MediaConnectorService;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+
+
+@RunWith(AndroidJUnit4.class)
+public class MediaConnectorServiceTests extends BaseMockitoTest {
+
+ private static final String EXTRA_AUTOPLAY = "com.android.car.media.autoplay";
+
+ private static final ComponentName COMP_1_1 = new ComponentName("package1", "class1");
+ private static final ComponentName COMP_1_2 = new ComponentName("package1", "class2");
+
+ private MutableLiveData<PlaybackStateWrapper> mPlaybackLiveData;
+ private MediaConnectorService mService;
+
+ @Mock private MediaControllerCompat mMediaController;
+ @Mock private MediaMetadataCompat mMetadata;
+ @Mock private PlaybackStateCompat mState;
+ @Mock MediaControllerCompat.TransportControls mControls;
+
+ @Before
+ public void setup() {
+ mPlaybackLiveData = dataOf(null);
+ mService = new MediaConnectorService(mPlaybackLiveData);
+ mService.attachBaseContext(InstrumentationRegistry.getInstrumentation().getTargetContext());
+ mService.onCreate();
+ mService.onBind(new Intent());
+
+ when(mMediaController.getTransportControls()).thenReturn(mControls);
+ }
+
+ public static MediaSource newFakeMediaSource(@NonNull ComponentName browseService) {
+ String displayName = browseService.getClassName();
+ Drawable icon = new ColorDrawable();
+ IconCropper iconCropper = new IconCropper(new Path());
+ return new MediaSource(browseService, displayName, icon, iconCropper);
+ }
+
+ private void sendCommand(@Nullable ComponentName source, boolean autoPlay) {
+ Intent intent = new Intent();
+ intent.putExtra(EXTRA_AUTOPLAY, autoPlay);
+ if (source != null) {
+ intent.putExtra(CarMediaIntents.EXTRA_MEDIA_COMPONENT, source.flattenToString());
+ }
+ mService.onStartCommand(intent, 0, 0);
+ }
+
+ private PlaybackStateWrapper newState(ComponentName comp) {
+ MediaSource source = newFakeMediaSource(comp);
+ return new PlaybackStateWrapper(source, mMediaController, mMetadata, mState);
+ }
+
+ @Test
+ public void prepareAndPlaySequence() {
+ when(mState.getActions()).thenReturn(0L, ACTION_PREPARE, ACTION_PLAY);
+ sendCommand(COMP_1_1, true);
+
+ PlaybackStateWrapper state = newState(COMP_1_1);
+
+ mPlaybackLiveData.setValue(state);
+ verify(mControls, times(0)).prepare();
+ verify(mControls, times(0)).play();
+
+ mPlaybackLiveData.setValue(state);
+ verify(mControls, times(1)).prepare();
+ verify(mControls, times(0)).play();
+
+ mPlaybackLiveData.setValue(state);
+ verify(mControls, times(1)).prepare();
+ verify(mControls, times(1)).play();
+ }
+
+ @Test
+ public void onlyPrepareWhenPlayNotEnabled() {
+ when(mState.getActions()).thenReturn(ACTION_PREPARE);
+ sendCommand(COMP_1_1, true);
+ mPlaybackLiveData.setValue(newState(COMP_1_1));
+ verify(mControls, times(1)).prepare();
+ verify(mControls, times(0)).play();
+ }
+
+ @Test
+ public void onlyPrepareWhenPlayNotRequested() {
+ when(mState.getActions()).thenReturn(ACTION_PREPARE | ACTION_PLAY);
+ sendCommand(COMP_1_1, false);
+ mPlaybackLiveData.setValue(newState(COMP_1_1));
+ verify(mControls, times(1)).prepare();
+ verify(mControls, times(0)).play();
+ }
+
+ @Test
+ public void waitForMatchingSource() {
+ when(mState.getActions()).thenReturn(ACTION_PREPARE | ACTION_PLAY);
+ sendCommand(COMP_1_2, true);
+
+ mPlaybackLiveData.setValue(newState(COMP_1_1));
+ verify(mControls, times(0)).prepare();
+ verify(mControls, times(0)).play();
+
+ mPlaybackLiveData.setValue(newState(COMP_1_2));
+ verify(mControls, times(1)).prepare();
+ verify(mControls, times(1)).play();
+ }
+}