Add controller for Media operation

-Access LocalMediaManger to display avilable output devices information
-Access LocalMediaManger to do media operation, such as volume adjustment,
switching output device, grouping
-Access MediaController to show media content information
-Add MediaOutputControllerTest for unit test

Bug: 155822415
Test: atest MediaOutputControllerTest
Merged-In: I9eb6e3b0a6e584637aecb4132dbc2b138c6d1530
Change-Id: I9eb6e3b0a6e584637aecb4132dbc2b138c6d1530
diff --git a/packages/SystemUI/res/values/dimens.xml b/packages/SystemUI/res/values/dimens.xml
index f07627a..4cd8d19 100644
--- a/packages/SystemUI/res/values/dimens.xml
+++ b/packages/SystemUI/res/values/dimens.xml
@@ -1402,4 +1402,11 @@
     <dimen name="config_rounded_mask_size">@*android:dimen/rounded_corner_radius</dimen>
     <dimen name="config_rounded_mask_size_top">@*android:dimen/rounded_corner_radius_top</dimen>
     <dimen name="config_rounded_mask_size_bottom">@*android:dimen/rounded_corner_radius_bottom</dimen>
+
+    <!-- Output switcher panel related dimensions -->
+    <dimen name="media_output_dialog_padding_top">11dp</dimen>
+    <dimen name="media_output_dialog_list_max_height">364dp</dimen>
+    <dimen name="media_output_dialog_header_album_icon_size">52dp</dimen>
+    <dimen name="media_output_dialog_header_back_icon_size">36dp</dimen>
+    <dimen name="media_output_dialog_icon_corner_radius">16dp</dimen>
 </resources>
diff --git a/packages/SystemUI/res/values/strings.xml b/packages/SystemUI/res/values/strings.xml
index 95de486..824521e 100644
--- a/packages/SystemUI/res/values/strings.xml
+++ b/packages/SystemUI/res/values/strings.xml
@@ -2855,4 +2855,19 @@
     <string name="controls_menu_add">Add controls</string>
     <!-- Controls menu, edit [CHAR_LIMIT=30] -->
     <string name="controls_menu_edit">Edit controls</string>
+
+    <!-- Title for the media output group dialog with media related devices [CHAR LIMIT=50] -->
+    <string name="media_output_dialog_add_output">Add outputs</string>
+    <!-- Title for the media output slice with group devices [CHAR LIMIT=50] -->
+    <string name="media_output_dialog_group">Group</string>
+    <!-- Summary for media output group with only one device which is active [CHAR LIMIT=NONE] -->
+    <string name="media_output_dialog_single_device">1 device selected</string>
+    <!-- Summary for media output group with the active device count [CHAR LIMIT=NONE] -->
+    <string name="media_output_dialog_multiple_devices"><xliff:g id="count" example="2">%1$d</xliff:g> devices selected</string>
+    <!-- Summary for disconnected status [CHAR LIMIT=50] -->
+    <string name="media_output_dialog_disconnected"><xliff:g id="device_name" example="My device">%1$s</xliff:g> (disconnected)</string>
+    <!-- Summary for connecting error message [CHAR LIMIT=NONE] -->
+    <string name="media_output_dialog_connect_failed">Couldn\'t connect. Try again.</string>
+    <!-- Title for pairing item [CHAR LIMIT=60] -->
+    <string name="media_output_dialog_pairing_new">Pair new device</string>
 </resources>
diff --git a/packages/SystemUI/src/com/android/systemui/media/dialog/MediaOutputController.java b/packages/SystemUI/src/com/android/systemui/media/dialog/MediaOutputController.java
new file mode 100644
index 0000000..64d20a27
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/dialog/MediaOutputController.java
@@ -0,0 +1,445 @@
+/*
+ * Copyright (C) 2020 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.systemui.media.dialog;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import android.media.MediaMetadata;
+import android.media.RoutingSessionInfo;
+import android.media.session.MediaController;
+import android.media.session.MediaSessionManager;
+import android.media.session.PlaybackState;
+import android.os.UserHandle;
+import android.os.UserManager;
+import android.text.TextUtils;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.VisibleForTesting;
+import androidx.core.graphics.drawable.IconCompat;
+
+import com.android.settingslib.RestrictedLockUtilsInternal;
+import com.android.settingslib.Utils;
+import com.android.settingslib.bluetooth.BluetoothUtils;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+import com.android.settingslib.media.InfoMediaManager;
+import com.android.settingslib.media.LocalMediaManager;
+import com.android.settingslib.media.MediaDevice;
+import com.android.settingslib.media.MediaOutputSliceConstants;
+import com.android.settingslib.utils.ThreadUtils;
+import com.android.systemui.R;
+import com.android.systemui.plugins.ActivityStarter;
+import com.android.systemui.statusbar.phone.ShadeController;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import javax.inject.Inject;
+
+/**
+ * Controller for media output dialog
+ */
+public class MediaOutputController implements LocalMediaManager.DeviceCallback{
+
+    private static final String TAG = "MediaOutputController";
+    private static final boolean DEBUG = false;
+
+    private final String mPackageName;
+    private final Context mContext;
+    private final MediaSessionManager mMediaSessionManager;
+    private final ShadeController mShadeController;
+    private final ActivityStarter mActivityStarter;
+    @VisibleForTesting
+    final List<MediaDevice> mMediaDevices = new CopyOnWriteArrayList<>();
+
+    private MediaController mMediaController;
+    @VisibleForTesting
+    Callback mCallback;
+    @VisibleForTesting
+    LocalMediaManager mLocalMediaManager;
+
+    @Inject
+    public MediaOutputController(@NonNull Context context, String packageName,
+            MediaSessionManager mediaSessionManager, LocalBluetoothManager
+            lbm, ShadeController shadeController, ActivityStarter starter) {
+        mContext = context;
+        mPackageName = packageName;
+        mMediaSessionManager = mediaSessionManager;
+        mShadeController = shadeController;
+        mActivityStarter = starter;
+        InfoMediaManager imm = new InfoMediaManager(mContext, packageName, null, lbm);
+        mLocalMediaManager = new LocalMediaManager(mContext, lbm, imm, packageName);
+    }
+
+    void start(@NonNull Callback cb) {
+        mMediaDevices.clear();
+        if (!TextUtils.isEmpty(mPackageName)) {
+            for (MediaController controller : mMediaSessionManager.getActiveSessions(null)) {
+                if (TextUtils.equals(controller.getPackageName(), mPackageName)) {
+                    mMediaController = controller;
+                    mMediaController.unregisterCallback(mCb);
+                    mMediaController.registerCallback(mCb);
+                    break;
+                }
+            }
+        }
+        if (mMediaController == null) {
+            if (DEBUG) {
+                Log.d(TAG, "No media controller for " + mPackageName);
+            }
+        }
+        if (mLocalMediaManager == null) {
+            if (DEBUG) {
+                Log.d(TAG, "No local media manager " + mPackageName);
+            }
+            return;
+        }
+        mCallback = cb;
+        mLocalMediaManager.unregisterCallback(this);
+        mLocalMediaManager.stopScan();
+        mLocalMediaManager.registerCallback(this);
+        mLocalMediaManager.startScan();
+    }
+
+    void stop() {
+        if (mMediaController != null) {
+            mMediaController.unregisterCallback(mCb);
+        }
+        if (mLocalMediaManager != null) {
+            mLocalMediaManager.unregisterCallback(this);
+            mLocalMediaManager.stopScan();
+        }
+        mMediaDevices.clear();
+    }
+
+    @Override
+    public void onDeviceListUpdate(List<MediaDevice> devices) {
+        buildMediaDevices(devices);
+        mCallback.onRouteChanged();
+    }
+
+    @Override
+    public void onSelectedDeviceStateChanged(MediaDevice device,
+            @LocalMediaManager.MediaDeviceState int state) {
+        mCallback.onRouteChanged();
+    }
+
+    @Override
+    public void onDeviceAttributesChanged() {
+        mCallback.onRouteChanged();
+    }
+
+    @Override
+    public void onRequestFailed(int reason) {
+        mCallback.onRouteChanged();
+    }
+
+    CharSequence getHeaderTitle() {
+        if (mMediaController != null) {
+            final MediaMetadata metadata = mMediaController.getMetadata();
+            if (metadata != null) {
+                return metadata.getDescription().getTitle();
+            }
+        }
+        return mContext.getText(R.string.controls_media_title);
+    }
+
+    CharSequence getHeaderSubTitle() {
+        if (mMediaController == null) {
+            return null;
+        }
+        final MediaMetadata metadata = mMediaController.getMetadata();
+        if (metadata == null) {
+            return null;
+        }
+        return metadata.getDescription().getSubtitle();
+    }
+
+    IconCompat getHeaderIcon() {
+        if (mMediaController == null) {
+            return null;
+        }
+        final MediaMetadata metadata = mMediaController.getMetadata();
+        if (metadata != null) {
+            final Bitmap bitmap = metadata.getDescription().getIconBitmap();
+            if (bitmap != null) {
+                final Bitmap roundBitmap = Utils.convertCornerRadiusBitmap(mContext, bitmap,
+                        (float) mContext.getResources().getDimensionPixelSize(
+                                R.dimen.media_output_dialog_icon_corner_radius));
+                return IconCompat.createWithBitmap(roundBitmap);
+            }
+        }
+        if (DEBUG) {
+            Log.d(TAG, "Media meta data does not contain icon information");
+        }
+        return getPackageIcon();
+    }
+
+    IconCompat getDeviceIconCompat(MediaDevice device) {
+        Drawable drawable = device.getIcon();
+        if (drawable == null) {
+            if (DEBUG) {
+                Log.d(TAG, "getDeviceIconCompat() device : " + device.getName()
+                        + ", drawable is null");
+            }
+            // Use default Bluetooth device icon to handle getIcon() is null case.
+            drawable = mContext.getDrawable(com.android.internal.R.drawable.ic_bt_headphones_a2dp);
+        }
+        return BluetoothUtils.createIconWithDrawable(drawable);
+    }
+
+    private IconCompat getPackageIcon() {
+        if (TextUtils.isEmpty(mPackageName)) {
+            return null;
+        }
+        try {
+            final Drawable drawable = mContext.getPackageManager().getApplicationIcon(mPackageName);
+            if (drawable instanceof BitmapDrawable) {
+                return IconCompat.createWithBitmap(((BitmapDrawable) drawable).getBitmap());
+            }
+            final Bitmap bitmap = Bitmap.createBitmap(drawable.getIntrinsicWidth(),
+                    drawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888);
+            final Canvas canvas = new Canvas(bitmap);
+            drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
+            drawable.draw(canvas);
+            return IconCompat.createWithBitmap(bitmap);
+        } catch (PackageManager.NameNotFoundException e) {
+            if (DEBUG) {
+                Log.e(TAG, "Package is not found. Unable to get package icon.");
+            }
+        }
+        return null;
+    }
+
+    private void buildMediaDevices(List<MediaDevice> devices) {
+        // For the first time building list, to make sure the top device is the connected device.
+        if (mMediaDevices.isEmpty()) {
+            final MediaDevice connectedMediaDevice = getCurrentConnectedMediaDevice();
+            if (connectedMediaDevice == null) {
+                if (DEBUG) {
+                    Log.d(TAG, "No connected media device.");
+                }
+                mMediaDevices.addAll(devices);
+                return;
+            }
+            for (MediaDevice device : devices) {
+                if (TextUtils.equals(device.getId(), connectedMediaDevice.getId())) {
+                    mMediaDevices.add(0, device);
+                } else {
+                    mMediaDevices.add(device);
+                }
+            }
+            return;
+        }
+        // To keep the same list order
+        final Collection<MediaDevice> targetMediaDevices = new ArrayList<>();
+        for (MediaDevice originalDevice : mMediaDevices) {
+            for (MediaDevice newDevice : devices) {
+                if (TextUtils.equals(originalDevice.getId(), newDevice.getId())) {
+                    targetMediaDevices.add(newDevice);
+                    break;
+                }
+            }
+        }
+        if (targetMediaDevices.size() != devices.size()) {
+            devices.removeAll(targetMediaDevices);
+            targetMediaDevices.addAll(devices);
+        }
+        mMediaDevices.clear();
+        mMediaDevices.addAll(targetMediaDevices);
+    }
+
+    void connectDevice(MediaDevice device) {
+        ThreadUtils.postOnBackgroundThread(() -> {
+            mLocalMediaManager.connectDevice(device);
+        });
+    }
+
+    Collection<MediaDevice> getMediaDevices() {
+        return mMediaDevices;
+    }
+
+    MediaDevice getCurrentConnectedMediaDevice() {
+        return mLocalMediaManager.getCurrentConnectedDevice();
+    }
+
+    private MediaDevice getMediaDeviceById(String id) {
+        return mLocalMediaManager.getMediaDeviceById(new ArrayList<>(mMediaDevices), id);
+    }
+
+    boolean addDeviceToPlayMedia(MediaDevice device) {
+        return mLocalMediaManager.addDeviceToPlayMedia(device);
+    }
+
+    boolean removeDeviceFromPlayMedia(MediaDevice device) {
+        return mLocalMediaManager.removeDeviceFromPlayMedia(device);
+    }
+
+    List<MediaDevice> getSelectableMediaDevice() {
+        return mLocalMediaManager.getSelectableMediaDevice();
+    }
+
+    List<MediaDevice> getSelectedMediaDevice() {
+        return mLocalMediaManager.getSelectedMediaDevice();
+    }
+
+    List<MediaDevice> getDeselectableMediaDevice() {
+        return mLocalMediaManager.getDeselectableMediaDevice();
+    }
+
+    boolean isDeviceIncluded(Collection<MediaDevice> deviceCollection, MediaDevice targetDevice) {
+        for (MediaDevice device : deviceCollection) {
+            if (TextUtils.equals(device.getId(), targetDevice.getId())) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    void adjustSessionVolume(String sessionId, int volume) {
+        mLocalMediaManager.adjustSessionVolume(sessionId, volume);
+    }
+
+    void adjustSessionVolume(int volume) {
+        mLocalMediaManager.adjustSessionVolume(volume);
+    }
+
+    int getSessionVolumeMax() {
+        return mLocalMediaManager.getSessionVolumeMax();
+    }
+
+    int getSessionVolume() {
+        return mLocalMediaManager.getSessionVolume();
+    }
+
+    CharSequence getSessionName() {
+        return mLocalMediaManager.getSessionName();
+    }
+
+    void releaseSession() {
+        mLocalMediaManager.releaseSession();
+    }
+
+    List<RoutingSessionInfo> getActiveRemoteMediaDevices() {
+        final List<RoutingSessionInfo> sessionInfos = new ArrayList<>();
+        for (RoutingSessionInfo info : mLocalMediaManager.getActiveMediaSession()) {
+            if (!info.isSystemSession()) {
+                sessionInfos.add(info);
+            }
+        }
+        return sessionInfos;
+    }
+
+    void adjustVolume(MediaDevice device, int volume) {
+        ThreadUtils.postOnBackgroundThread(() -> {
+            device.requestSetVolume(volume);
+        });
+    }
+
+    String getPackageName() {
+        return mPackageName;
+    }
+
+    boolean hasAdjustVolumeUserRestriction() {
+        if (RestrictedLockUtilsInternal.checkIfRestrictionEnforced(
+                mContext, UserManager.DISALLOW_ADJUST_VOLUME, UserHandle.myUserId()) != null) {
+            return true;
+        }
+        final UserManager um = mContext.getSystemService(UserManager.class);
+        return um.hasBaseUserRestriction(UserManager.DISALLOW_ADJUST_VOLUME,
+                UserHandle.of(UserHandle.myUserId()));
+    }
+
+    boolean isTransferring() {
+        for (MediaDevice device : mMediaDevices) {
+            if (device.getState() == LocalMediaManager.MediaDeviceState.STATE_CONNECTING) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    boolean isZeroMode() {
+        if (mMediaDevices.size() == 1) {
+            final MediaDevice device = mMediaDevices.iterator().next();
+            // Add "pair new" only when local output device exists
+            final int type = device.getDeviceType();
+            if (type == MediaDevice.MediaDeviceType.TYPE_PHONE_DEVICE
+                    || type == MediaDevice.MediaDeviceType.TYPE_3POINT5_MM_AUDIO_DEVICE
+                    || type == MediaDevice.MediaDeviceType.TYPE_USB_C_AUDIO_DEVICE) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    void launchBluetoothPairing() {
+        mCallback.dismissDialog();
+        final ActivityStarter.OnDismissAction postKeyguardAction = () -> {
+            mContext.sendBroadcast(new Intent()
+                    .setAction(MediaOutputSliceConstants.ACTION_LAUNCH_BLUETOOTH_PAIRING)
+                    .setPackage(MediaOutputSliceConstants.SETTINGS_PACKAGE_NAME));
+            mShadeController.animateCollapsePanels();
+            return true;
+        };
+        mActivityStarter.dismissKeyguardThenExecute(postKeyguardAction, null, true);
+    }
+
+    private final MediaController.Callback mCb = new MediaController.Callback() {
+        @Override
+        public void onMetadataChanged(MediaMetadata metadata) {
+            mCallback.onMediaChanged();
+        }
+
+        @Override
+        public void onPlaybackStateChanged(PlaybackState playbackState) {
+            final int state = playbackState.getState();
+            if (state == PlaybackState.STATE_STOPPED || state == PlaybackState.STATE_PAUSED) {
+                mCallback.onMediaStoppedOrPaused();
+            }
+        }
+    };
+
+    interface Callback {
+        /**
+         * Override to handle the media content updating.
+         */
+        void onMediaChanged();
+
+        /**
+         * Override to handle the media state updating.
+         */
+        void onMediaStoppedOrPaused();
+
+        /**
+         * Override to handle the device updating.
+         */
+        void onRouteChanged();
+
+        /**
+         * Override to dismiss dialog.
+         */
+        void dismissDialog();
+    }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/dialog/MediaOutputControllerTest.java b/packages/SystemUI/tests/src/com/android/systemui/media/dialog/MediaOutputControllerTest.java
new file mode 100644
index 0000000..0dcdecf
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/media/dialog/MediaOutputControllerTest.java
@@ -0,0 +1,348 @@
+/*
+ * Copyright (C) 2020 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.systemui.media.dialog;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.reset;
+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.MediaDescription;
+import android.media.MediaMetadata;
+import android.media.RoutingSessionInfo;
+import android.media.session.MediaController;
+import android.media.session.MediaSessionManager;
+import android.testing.AndroidTestingRunner;
+
+import androidx.test.filters.SmallTest;
+
+import com.android.settingslib.bluetooth.CachedBluetoothDeviceManager;
+import com.android.settingslib.bluetooth.LocalBluetoothManager;
+import com.android.settingslib.media.LocalMediaManager;
+import com.android.settingslib.media.MediaDevice;
+import com.android.systemui.R;
+import com.android.systemui.SysuiTestCase;
+import com.android.systemui.plugins.ActivityStarter;
+import com.android.systemui.statusbar.phone.ShadeController;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@SmallTest
+@RunWith(AndroidTestingRunner.class)
+public class MediaOutputControllerTest extends SysuiTestCase {
+
+    private static final String TEST_PACKAGE_NAME = "com.test.package.name";
+    private static final String TEST_DEVICE_1_ID = "test_device_1_id";
+    private static final String TEST_DEVICE_2_ID = "test_device_2_id";
+    private static final String TEST_ARTIST = "test_artist";
+    private static final String TEST_SONG = "test_song";
+    private static final String TEST_SESSION_ID = "test_session_id";
+    private static final String TEST_SESSION_NAME = "test_session_name";
+    // Mock
+    private MediaController mMediaController = mock(MediaController.class);
+    private MediaSessionManager mMediaSessionManager = mock(MediaSessionManager.class);
+    private CachedBluetoothDeviceManager mCachedBluetoothDeviceManager =
+            mock(CachedBluetoothDeviceManager.class);
+    private LocalBluetoothManager mLocalBluetoothManager = mock(LocalBluetoothManager.class);
+    private MediaOutputController.Callback mCb = mock(MediaOutputController.Callback.class);
+    private MediaDevice mMediaDevice1 = mock(MediaDevice.class);
+    private MediaDevice mMediaDevice2 = mock(MediaDevice.class);
+    private MediaMetadata mMediaMetadata = mock(MediaMetadata.class);
+    private RoutingSessionInfo mRemoteSessionInfo = mock(RoutingSessionInfo.class);
+    private ShadeController mShadeController = mock(ShadeController.class);
+    private ActivityStarter mStarter = mock(ActivityStarter.class);
+
+    private Context mSpyContext;
+    private MediaOutputController mMediaOutputController;
+    private LocalMediaManager mLocalMediaManager;
+    private List<MediaController> mMediaControllers = new ArrayList<>();
+    private List<MediaDevice> mMediaDevices = new ArrayList<>();
+    private MediaDescription mMediaDescription;
+    private List<RoutingSessionInfo> mRoutingSessionInfos = new ArrayList<>();
+
+    @Before
+    public void setUp() {
+        mSpyContext = spy(mContext);
+        when(mMediaController.getPackageName()).thenReturn(TEST_PACKAGE_NAME);
+        mMediaControllers.add(mMediaController);
+        when(mMediaSessionManager.getActiveSessions(any())).thenReturn(mMediaControllers);
+        doReturn(mMediaSessionManager).when(mSpyContext).getSystemService(
+                MediaSessionManager.class);
+        when(mLocalBluetoothManager.getCachedDeviceManager()).thenReturn(
+                mCachedBluetoothDeviceManager);
+        mMediaOutputController = new MediaOutputController(mSpyContext, TEST_PACKAGE_NAME,
+                mMediaSessionManager, mLocalBluetoothManager, mShadeController, mStarter);
+        mLocalMediaManager = spy(mMediaOutputController.mLocalMediaManager);
+        mMediaOutputController.mLocalMediaManager = mLocalMediaManager;
+        MediaDescription.Builder builder = new MediaDescription.Builder();
+        builder.setTitle(TEST_SONG);
+        builder.setSubtitle(TEST_ARTIST);
+        mMediaDescription = builder.build();
+        when(mMediaMetadata.getDescription()).thenReturn(mMediaDescription);
+        when(mMediaDevice1.getId()).thenReturn(TEST_DEVICE_1_ID);
+        when(mMediaDevice2.getId()).thenReturn(TEST_DEVICE_2_ID);
+        mMediaDevices.add(mMediaDevice1);
+        mMediaDevices.add(mMediaDevice2);
+    }
+
+    @Test
+    public void start_verifyLocalMediaManagerInit() {
+        mMediaOutputController.start(mCb);
+
+        verify(mLocalMediaManager).registerCallback(mMediaOutputController);
+        verify(mLocalMediaManager).startScan();
+    }
+
+    @Test
+    public void stop_verifyLocalMediaManagerDeinit() {
+        mMediaOutputController.start(mCb);
+        reset(mLocalMediaManager);
+
+        mMediaOutputController.stop();
+
+        verify(mLocalMediaManager).unregisterCallback(mMediaOutputController);
+        verify(mLocalMediaManager).stopScan();
+    }
+
+    @Test
+    public void start_withPackageName_verifyMediaControllerInit() {
+        mMediaOutputController.start(mCb);
+
+        verify(mMediaController).registerCallback(any());
+    }
+
+    @Test
+    public void start_withoutPackageName_verifyMediaControllerInit() {
+        mMediaOutputController = new MediaOutputController(mSpyContext, null, mMediaSessionManager,
+                mLocalBluetoothManager, mShadeController, mStarter);
+
+        mMediaOutputController.start(mCb);
+
+        verify(mMediaController, never()).registerCallback(any());
+    }
+
+    @Test
+    public void stop_withPackageName_verifyMediaControllerDeinit() {
+        mMediaOutputController.start(mCb);
+        reset(mMediaController);
+
+        mMediaOutputController.stop();
+
+        verify(mMediaController).unregisterCallback(any());
+    }
+
+    @Test
+    public void stop_withoutPackageName_verifyMediaControllerDeinit() {
+        mMediaOutputController = new MediaOutputController(mSpyContext, null, mMediaSessionManager,
+                mLocalBluetoothManager, mShadeController, mStarter);
+        mMediaOutputController.start(mCb);
+
+        mMediaOutputController.stop();
+
+        verify(mMediaController, never()).unregisterCallback(any());
+    }
+
+    @Test
+    public void onDeviceListUpdate_verifyDeviceListCallback() {
+        mMediaOutputController.start(mCb);
+        reset(mCb);
+
+        mMediaOutputController.onDeviceListUpdate(mMediaDevices);
+        final List<MediaDevice> devices = new ArrayList<>(mMediaOutputController.getMediaDevices());
+
+        assertThat(devices.containsAll(mMediaDevices)).isTrue();
+        assertThat(devices.size()).isEqualTo(mMediaDevices.size());
+        verify(mCb).onRouteChanged();
+    }
+
+    @Test
+    public void onSelectedDeviceStateChanged_verifyCallback() {
+        mMediaOutputController.start(mCb);
+        reset(mCb);
+
+        mMediaOutputController.onSelectedDeviceStateChanged(mMediaDevice1,
+                LocalMediaManager.MediaDeviceState.STATE_CONNECTED);
+
+        verify(mCb).onRouteChanged();
+    }
+
+    @Test
+    public void onDeviceAttributesChanged_verifyCallback() {
+        mMediaOutputController.start(mCb);
+        reset(mCb);
+
+        mMediaOutputController.onDeviceAttributesChanged();
+
+        verify(mCb).onRouteChanged();
+    }
+
+    @Test
+    public void onRequestFailed_verifyCallback() {
+        mMediaOutputController.start(mCb);
+        reset(mCb);
+
+        mMediaOutputController.onRequestFailed(0 /* reason */);
+
+        verify(mCb).onRouteChanged();
+    }
+
+    @Test
+    public void getHeaderTitle_withoutMetadata_returnDefaultString() {
+        when(mMediaController.getMetadata()).thenReturn(null);
+
+        mMediaOutputController.start(mCb);
+
+        assertThat(mMediaOutputController.getHeaderTitle()).isEqualTo(
+                mContext.getText(R.string.controls_media_title));
+    }
+
+    @Test
+    public void getHeaderTitle_withMetadata_returnSongName() {
+        when(mMediaController.getMetadata()).thenReturn(mMediaMetadata);
+
+        mMediaOutputController.start(mCb);
+
+        assertThat(mMediaOutputController.getHeaderTitle()).isEqualTo(TEST_SONG);
+    }
+
+    @Test
+    public void getHeaderSubTitle_withoutMetadata_returnNull() {
+        when(mMediaController.getMetadata()).thenReturn(null);
+
+        mMediaOutputController.start(mCb);
+
+        assertThat(mMediaOutputController.getHeaderSubTitle()).isNull();
+    }
+
+    @Test
+    public void getHeaderSubTitle_withMetadata_returnArtistName() {
+        when(mMediaController.getMetadata()).thenReturn(mMediaMetadata);
+
+        mMediaOutputController.start(mCb);
+
+        assertThat(mMediaOutputController.getHeaderSubTitle()).isEqualTo(TEST_ARTIST);
+    }
+
+    @Test
+    public void connectDevice_verifyConnect() {
+        mMediaOutputController.connectDevice(mMediaDevice1);
+
+        // Wait for background thread execution
+        try {
+            Thread.sleep(100);
+        } catch (InterruptedException e) {
+            e.printStackTrace();
+        }
+
+        verify(mLocalMediaManager).connectDevice(mMediaDevice1);
+    }
+
+    @Test
+    public void getActiveRemoteMediaDevice_isSystemSession_returnSession() {
+        when(mRemoteSessionInfo.getId()).thenReturn(TEST_SESSION_ID);
+        when(mRemoteSessionInfo.getName()).thenReturn(TEST_SESSION_NAME);
+        when(mRemoteSessionInfo.getVolumeMax()).thenReturn(100);
+        when(mRemoteSessionInfo.getVolume()).thenReturn(10);
+        when(mRemoteSessionInfo.isSystemSession()).thenReturn(false);
+        mRoutingSessionInfos.add(mRemoteSessionInfo);
+        when(mLocalMediaManager.getActiveMediaSession()).thenReturn(mRoutingSessionInfos);
+
+        assertThat(mMediaOutputController.getActiveRemoteMediaDevices()).containsExactly(
+                mRemoteSessionInfo);
+    }
+
+    @Test
+    public void getActiveRemoteMediaDevice_notSystemSession_returnEmpty() {
+        when(mRemoteSessionInfo.getId()).thenReturn(TEST_SESSION_ID);
+        when(mRemoteSessionInfo.getName()).thenReturn(TEST_SESSION_NAME);
+        when(mRemoteSessionInfo.getVolumeMax()).thenReturn(100);
+        when(mRemoteSessionInfo.getVolume()).thenReturn(10);
+        when(mRemoteSessionInfo.isSystemSession()).thenReturn(true);
+        mRoutingSessionInfos.add(mRemoteSessionInfo);
+        when(mLocalMediaManager.getActiveMediaSession()).thenReturn(mRoutingSessionInfos);
+
+        assertThat(mMediaOutputController.getActiveRemoteMediaDevices()).isEmpty();
+    }
+
+    @Test
+    public void isZeroMode_onlyFromPhoneOutput_returnTrue() {
+        // Multiple available devices
+        assertThat(mMediaOutputController.isZeroMode()).isFalse();
+        when(mMediaDevice1.getDeviceType()).thenReturn(
+                MediaDevice.MediaDeviceType.TYPE_PHONE_DEVICE);
+        mMediaDevices.clear();
+        mMediaDevices.add(mMediaDevice1);
+        mMediaOutputController.start(mCb);
+        mMediaOutputController.onDeviceListUpdate(mMediaDevices);
+
+        assertThat(mMediaOutputController.isZeroMode()).isTrue();
+
+        when(mMediaDevice1.getDeviceType()).thenReturn(
+                MediaDevice.MediaDeviceType.TYPE_3POINT5_MM_AUDIO_DEVICE);
+
+        assertThat(mMediaOutputController.isZeroMode()).isTrue();
+
+        when(mMediaDevice1.getDeviceType()).thenReturn(
+                MediaDevice.MediaDeviceType.TYPE_USB_C_AUDIO_DEVICE);
+
+        assertThat(mMediaOutputController.isZeroMode()).isTrue();
+    }
+
+    @Test
+    public void isZeroMode_notFromPhoneOutput_returnFalse() {
+        when(mMediaDevice1.getDeviceType()).thenReturn(
+                MediaDevice.MediaDeviceType.TYPE_UNKNOWN);
+        mMediaDevices.clear();
+        mMediaDevices.add(mMediaDevice1);
+        mMediaOutputController.start(mCb);
+        mMediaOutputController.onDeviceListUpdate(mMediaDevices);
+
+        assertThat(mMediaOutputController.isZeroMode()).isFalse();
+
+        when(mMediaDevice1.getDeviceType()).thenReturn(
+                MediaDevice.MediaDeviceType.TYPE_FAST_PAIR_BLUETOOTH_DEVICE);
+
+        assertThat(mMediaOutputController.isZeroMode()).isFalse();
+
+        when(mMediaDevice1.getDeviceType()).thenReturn(
+                MediaDevice.MediaDeviceType.TYPE_BLUETOOTH_DEVICE);
+
+        assertThat(mMediaOutputController.isZeroMode()).isFalse();
+
+        when(mMediaDevice1.getDeviceType()).thenReturn(
+                MediaDevice.MediaDeviceType.TYPE_CAST_DEVICE);
+
+        assertThat(mMediaOutputController.isZeroMode()).isFalse();
+
+        when(mMediaDevice1.getDeviceType()).thenReturn(
+                MediaDevice.MediaDeviceType.TYPE_CAST_GROUP_DEVICE);
+
+        assertThat(mMediaOutputController.isZeroMode()).isFalse();
+    }
+}