diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 29f69b8e743..48b1333b3cb 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -100,6 +100,7 @@ + Media volume + + Cast volume + Call volume diff --git a/res/xml/sound_settings.xml b/res/xml/sound_settings.xml index ee8613d5fca..81a04538430 100644 --- a/res/xml/sound_settings.xml +++ b/res/xml/sound_settings.xml @@ -22,6 +22,15 @@ settings:keywords="@string/keywords_sounds" settings:initialExpandedChildrenCount="9"> + + + controllers = mMediaSessionManager.getActiveSessions(null); + for (MediaController mediaController : controllers) { + final MediaController.PlaybackInfo pi = mediaController.getPlaybackInfo(); + if (isRemote(pi)) { + updateToken(mediaController.getSessionToken()); + return AVAILABLE; + } + } + + // No active remote media at this point + return CONDITIONALLY_UNAVAILABLE; + } + + @Override + public void displayPreference(PreferenceScreen screen) { + super.displayPreference(screen); + if (mMediaController != null) { + updatePreference(mPreference, mActiveToken, mMediaController.getPlaybackInfo()); + } + } + + @OnLifecycleEvent(Lifecycle.Event.ON_RESUME) + public void onResume() { + super.onResume(); + //TODO(b/126199571): register callback once b/126890783 is fixed + } + + @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE) + public void onPause() { + super.onPause(); + //TODO(b/126199571): unregister callback once b/126890783 is fixed + } + + @Override + public int getSliderPosition() { + if (mPreference != null) { + return mPreference.getProgress(); + } + if (mMediaController == null) { + return 0; + } + final MediaController.PlaybackInfo playbackInfo = mMediaController.getPlaybackInfo(); + return playbackInfo != null ? playbackInfo.getCurrentVolume() : 0; + } + + @Override + public boolean setSliderPosition(int position) { + if (mPreference != null) { + mPreference.setProgress(position); + } + if (mMediaController == null) { + return false; + } + mMediaController.setVolumeTo(position, 0); + return true; + } + + @Override + public int getMaxSteps() { + if (mPreference != null) { + return mPreference.getMax(); + } + if (mMediaController == null) { + return 0; + } + final MediaController.PlaybackInfo playbackInfo = mMediaController.getPlaybackInfo(); + return playbackInfo != null ? playbackInfo.getMaxVolume() : 0; + } + + @Override + public boolean isSliceable() { + return TextUtils.equals(getPreferenceKey(), KEY_REMOTE_VOLUME); + } + + @Override + public String getPreferenceKey() { + return KEY_REMOTE_VOLUME; + } + + @Override + public int getAudioStream() { + // This can be anything because remote volume controller doesn't rely on it. + return REMOTE_VOLUME; + } + + @Override + public int getMuteIcon() { + return R.drawable.ic_volume_remote_mute; + } + + public static boolean isRemote(MediaController.PlaybackInfo pi) { + return pi != null + && pi.getPlaybackType() == MediaController.PlaybackInfo.PLAYBACK_TYPE_REMOTE; + } + + @Override + public Class getBackgroundWorkerClass() { + //TODO(b/126199571): return RemoteVolumeSliceWorker once b/126890783 is fixed + return null; + } + + private void updatePreference(VolumeSeekBarPreference seekBarPreference, + MediaSession.Token token, MediaController.PlaybackInfo playbackInfo) { + if (seekBarPreference == null || token == null || playbackInfo == null) { + return; + } + + seekBarPreference.setMax(playbackInfo.getMaxVolume()); + seekBarPreference.setVisible(true); + setSliderPosition(playbackInfo.getCurrentVolume()); + } + + private void updateToken(MediaSession.Token token) { + mActiveToken = token; + if (token != null) { + mMediaController = new MediaController(mContext, mActiveToken); + } else { + mMediaController = null; + } + } + + /** + * Listener for background change to remote volume, which listens callback + * from {@code MediaSessions} + */ + public static class RemoteVolumeSliceWorker extends SliceBackgroundWorker implements + MediaSessions.Callbacks { + + private MediaSessions mMediaSessions; + + public RemoteVolumeSliceWorker(Context context, Uri uri) { + super(context, uri); + mMediaSessions = new MediaSessions(context, Looper.getMainLooper(), this); + } + + @Override + protected void onSlicePinned() { + mMediaSessions.init(); + } + + @Override + protected void onSliceUnpinned() { + mMediaSessions.destroy(); + } + + @Override + public void close() throws IOException { + mMediaSessions = null; + } + + @Override + public void onRemoteUpdate(MediaSession.Token token, String name, + MediaController.PlaybackInfo pi) { + notifySliceChange(); + } + + @Override + public void onRemoteRemoved(MediaSession.Token t) { + notifySliceChange(); + } + + @Override + public void onRemoteVolumeChanged(MediaSession.Token token, int flags) { + notifySliceChange(); + } + } +} diff --git a/src/com/android/settings/notification/RemoteVolumeSeekBarPreference.java b/src/com/android/settings/notification/RemoteVolumeSeekBarPreference.java new file mode 100644 index 00000000000..e99af6a42cb --- /dev/null +++ b/src/com/android/settings/notification/RemoteVolumeSeekBarPreference.java @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2019 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.settings.notification; + +import android.content.Context; +import android.util.AttributeSet; + +/** + * A slider preference that controls remote volume, which doesn't go through + * {@link android.media.AudioManager} + **/ +public class RemoteVolumeSeekBarPreference extends VolumeSeekBarPreference { + + public RemoteVolumeSeekBarPreference(Context context, AttributeSet attrs, int defStyleAttr, + int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + public RemoteVolumeSeekBarPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public RemoteVolumeSeekBarPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public RemoteVolumeSeekBarPreference(Context context) { + super(context); + } + + @Override + public void setStream(int stream) { + // Do nothing here, volume is not controlled by AudioManager + } + + @Override + protected void init() { + if (mSeekBar == null) return; + updateIconView(); + updateSuppressionText(); + } +} diff --git a/src/com/android/settings/notification/SoundSettings.java b/src/com/android/settings/notification/SoundSettings.java index e8b7ee0d782..b26b9219cd4 100644 --- a/src/com/android/settings/notification/SoundSettings.java +++ b/src/com/android/settings/notification/SoundSettings.java @@ -184,6 +184,7 @@ public class SoundSettings extends DashboardFragment implements OnActivityResult volumeControllers.add(use(RingVolumePreferenceController.class)); volumeControllers.add(use(NotificationVolumePreferenceController.class)); volumeControllers.add(use(CallVolumePreferenceController.class)); + volumeControllers.add(use(RemoteVolumePreferenceController.class)); use(MediaOutputPreferenceController.class).setCallback(listPreference -> onPreferenceDataChanged(listPreference)); diff --git a/src/com/android/settings/notification/VolumeSeekBarPreference.java b/src/com/android/settings/notification/VolumeSeekBarPreference.java index 13f630004d1..7f36791e069 100644 --- a/src/com/android/settings/notification/VolumeSeekBarPreference.java +++ b/src/com/android/settings/notification/VolumeSeekBarPreference.java @@ -40,8 +40,8 @@ import java.util.Objects; public class VolumeSeekBarPreference extends SeekBarPreference { private static final String TAG = "VolumeSeekBarPreference"; + protected SeekBar mSeekBar; private int mStream; - private SeekBar mSeekBar; private SeekBarVolumizer mVolumizer; private Callback mCallback; private ImageView mIconView; @@ -121,7 +121,7 @@ public class VolumeSeekBarPreference extends SeekBarPreference { init(); } - private void init() { + protected void init() { if (mSeekBar == null) return; final SeekBarVolumizer.Callback sbvc = new SeekBarVolumizer.Callback() { @Override @@ -158,7 +158,7 @@ public class VolumeSeekBarPreference extends SeekBarPreference { } } - private void updateIconView() { + protected void updateIconView() { if (mIconView == null) return; if (mIconResId != 0) { mIconView.setImageResource(mIconResId); @@ -195,7 +195,7 @@ public class VolumeSeekBarPreference extends SeekBarPreference { updateSuppressionText(); } - private void updateSuppressionText() { + protected void updateSuppressionText() { if (mSuppressionTextView != null && mSeekBar != null) { mSuppressionTextView.setText(mSuppressionText); final boolean showSuppression = !TextUtils.isEmpty(mSuppressionText); diff --git a/src/com/android/settings/panel/VolumePanel.java b/src/com/android/settings/panel/VolumePanel.java index 20c2272a2a6..62eca53898b 100644 --- a/src/com/android/settings/panel/VolumePanel.java +++ b/src/com/android/settings/panel/VolumePanel.java @@ -19,6 +19,7 @@ package com.android.settings.panel; import static com.android.settings.slices.CustomSliceRegistry.VOLUME_ALARM_URI; import static com.android.settings.slices.CustomSliceRegistry.VOLUME_CALL_URI; import static com.android.settings.slices.CustomSliceRegistry.VOLUME_MEDIA_URI; +import static com.android.settings.slices.CustomSliceRegistry.VOLUME_REMOTE_MEDIA_URI; import static com.android.settings.slices.CustomSliceRegistry.VOLUME_RINGER_URI; import android.app.settings.SettingsEnums; @@ -52,6 +53,7 @@ public class VolumePanel implements PanelContent { @Override public List getSlices() { final List uris = new ArrayList<>(); + uris.add(VOLUME_REMOTE_MEDIA_URI); uris.add(VOLUME_MEDIA_URI); uris.add(VOLUME_CALL_URI); uris.add(VOLUME_RINGER_URI); diff --git a/src/com/android/settings/slices/CustomSliceRegistry.java b/src/com/android/settings/slices/CustomSliceRegistry.java index ab1b2484828..3a1db69c00a 100644 --- a/src/com/android/settings/slices/CustomSliceRegistry.java +++ b/src/com/android/settings/slices/CustomSliceRegistry.java @@ -219,6 +219,17 @@ public class CustomSliceRegistry { .appendPath(SettingsSlicesContract.PATH_SETTING_ACTION) .appendPath("media_volume") .build(); + + /** + * Full {@link Uri} for the Remote Media Volume Slice. + */ + public static final Uri VOLUME_REMOTE_MEDIA_URI = new Uri.Builder() + .scheme(ContentResolver.SCHEME_CONTENT) + .authority(SettingsSliceProvider.SLICE_AUTHORITY) + .appendPath(SettingsSlicesContract.PATH_SETTING_ACTION) + .appendPath("remote_volume") + .build(); + /** * Full {@link Uri} for the Ringer volume Slice. */ diff --git a/tests/robotests/src/com/android/settings/notification/RemoteVolumePreferenceControllerTest.java b/tests/robotests/src/com/android/settings/notification/RemoteVolumePreferenceControllerTest.java new file mode 100644 index 00000000000..1bf2fd81bb6 --- /dev/null +++ b/tests/robotests/src/com/android/settings/notification/RemoteVolumePreferenceControllerTest.java @@ -0,0 +1,197 @@ +/* + * Copyright (C) 2019 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.settings.notification; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.content.Context; +import android.media.session.ControllerLink; +import android.media.session.MediaController; +import android.media.session.MediaSession; +import android.media.session.MediaSessionManager; + +import com.android.settings.R; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +import java.util.ArrayList; +import java.util.List; + +@RunWith(RobolectricTestRunner.class) +public class RemoteVolumePreferenceControllerTest { + private static final int CURRENT_POS = 5; + private static final int MAX_POS = 10; + + @Mock + private MediaSessionManager mMediaSessionManager; + @Mock + private MediaController mMediaController; + @Mock + private ControllerLink.ControllerStub mStub; + @Mock + private ControllerLink.ControllerStub mStub2; + private MediaSession.Token mToken; + private MediaSession.Token mToken2; + private RemoteVolumePreferenceController mController; + private Context mContext; + private List mActiveSessions; + private MediaController.PlaybackInfo mPlaybackInfo; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + + mContext = spy(RuntimeEnvironment.application); + when(mContext.getSystemService(MediaSessionManager.class)).thenReturn(mMediaSessionManager); + mActiveSessions = new ArrayList<>(); + mActiveSessions.add(mMediaController); + when(mMediaSessionManager.getActiveSessions(null)).thenReturn( + mActiveSessions); + mToken = new MediaSession.Token(new ControllerLink(mStub)); + mToken2 = new MediaSession.Token(new ControllerLink(mStub2)); + + mController = new RemoteVolumePreferenceController(mContext); + mPlaybackInfo = new MediaController.PlaybackInfo( + MediaController.PlaybackInfo.PLAYBACK_TYPE_REMOTE, 0, MAX_POS, CURRENT_POS, null); + when(mMediaController.getPlaybackInfo()).thenReturn(mPlaybackInfo); + } + + @Test + public void isAvailable_containRemoteMedia_returnTrue() { + when(mMediaController.getPlaybackInfo()).thenReturn( + new MediaController.PlaybackInfo(MediaController.PlaybackInfo.PLAYBACK_TYPE_REMOTE, + 0, 0, 0, null)); + assertThat(mController.isAvailable()).isTrue(); + } + + @Test + public void isAvailable_noRemoteMedia_returnFalse() { + when(mMediaController.getPlaybackInfo()).thenReturn( + new MediaController.PlaybackInfo(MediaController.PlaybackInfo.PLAYBACK_TYPE_LOCAL, + 0, 0, 0, null)); + assertThat(mController.isAvailable()).isFalse(); + } + + @Test + public void getMuteIcon_returnMuteIcon() { + assertThat(mController.getMuteIcon()).isEqualTo(R.drawable.ic_volume_remote_mute); + } + + @Test + public void getAudioStream_returnRemoteVolume() { + assertThat(mController.getAudioStream()).isEqualTo( + RemoteVolumePreferenceController.REMOTE_VOLUME); + } + + @Test + public void getSliderPosition_controllerNull_returnZero() { + mController.mMediaController = null; + + assertThat(mController.getSliderPosition()).isEqualTo(0); + } + + @Test + public void getSliderPosition_controllerExists_returnValue() { + mController.mMediaController = mMediaController; + + assertThat(mController.getSliderPosition()).isEqualTo(CURRENT_POS); + } + + @Test + public void getMaxSteps_controllerNull_returnZero() { + mController.mMediaController = null; + + assertThat(mController.getMaxSteps()).isEqualTo(0); + } + + @Test + public void getMaxSteps_controllerExists_returnValue() { + mController.mMediaController = mMediaController; + + assertThat(mController.getMaxSteps()).isEqualTo(MAX_POS); + } + + @Test + public void setSliderPosition_controllerNull_returnFalse() { + mController.mMediaController = null; + + assertThat(mController.setSliderPosition(CURRENT_POS)).isFalse(); + } + + @Test + public void setSliderPosition_controllerExists_returnTrue() { + mController.mMediaController = mMediaController; + + assertThat(mController.setSliderPosition(CURRENT_POS)).isTrue(); + verify(mMediaController).setVolumeTo(CURRENT_POS, 0 /* flags */); + } + + @Test + public void onRemoteUpdate_firstToken_updateTokenAndPreference() { + mController.mPreference = new VolumeSeekBarPreference(mContext); + mController.mActiveToken = null; + + mController.mCallbacks.onRemoteUpdate(mToken, "token", mPlaybackInfo); + + assertThat(mController.mActiveToken).isEqualTo(mToken); + assertThat(mController.mPreference.isVisible()).isTrue(); + assertThat(mController.mPreference.getMax()).isEqualTo(MAX_POS); + assertThat(mController.mPreference.getProgress()).isEqualTo(CURRENT_POS); + } + + @Test + public void onRemoteUpdate_differentToken_doNothing() { + mController.mActiveToken = mToken; + + mController.mCallbacks.onRemoteUpdate(mToken2, "token2", mPlaybackInfo); + + assertThat(mController.mActiveToken).isEqualTo(mToken); + } + + @Test + public void onRemoteRemoved_tokenRemoved_setInvisible() { + mController.mPreference = new VolumeSeekBarPreference(mContext); + mController.mActiveToken = mToken; + + mController.mCallbacks.onRemoteRemoved(mToken); + + assertThat(mController.mActiveToken).isNull(); + assertThat(mController.mPreference.isVisible()).isFalse(); + } + + @Test + public void onRemoteVolumeChanged_volumeChanged_updateIt() { + mController.mPreference = new VolumeSeekBarPreference(mContext); + mController.mPreference.setMax(MAX_POS); + mController.mActiveToken = mToken; + mController.mMediaController = mMediaController; + + mController.mCallbacks.onRemoteVolumeChanged(mToken, 0 /* flags */); + + assertThat(mController.mPreference.getProgress()).isEqualTo(CURRENT_POS); + } +}