diff options
author | Arnaud Berry <arnaudberry@google.com> | 2023-06-20 10:26:06 -0700 |
---|---|---|
committer | Arnaud Berry <arnaudberry@google.com> | 2023-07-06 11:30:10 -0700 |
commit | 7c34a65c65894010c234ae6c68fce1329bfff7d0 (patch) | |
tree | 0a94e4fdc0ed3697dfd517fe89b32fae864c21c9 | |
parent | f04f5a69e6ee729305385ec347386d65266f617c (diff) | |
download | Media-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.xml | 3 | ||||
-rw-r--r-- | src/com/android/car/media/service/MediaConnectorService.java | 203 | ||||
-rw-r--r-- | tests/unittests/src/com/android/car/media/MediaConnectorServiceTests.java | 154 |
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(); + } +} |