diff options
| author | 2020-09-15 01:22:39 +0000 | |
|---|---|---|
| committer | 2020-09-15 01:22:39 +0000 | |
| commit | e9897deb31553c96fcce8e7d20500afc65a41766 (patch) | |
| tree | 771d79c961b8bc58bb4b905de10f74878c0467c2 | |
| parent | 2618b23c8903504b5727ba5b6ece9e690dabf7f8 (diff) | |
| parent | 6d31848fb2441947f9708e131014cfecb21d9f65 (diff) | |
Merge changes Icb5a1f24,Idbfc558b,I3086a404,Ida509ed4,I9eb6e3b0
* changes:
Add a receiver to launch Output Switcher dialog
Add MediaOutputDialogFactory to create MediaOutputDialog
Add Media Output Dialog for Output Switcher
Add MediaOutputAdapter for Media device list
Add controller for Media operation
18 files changed, 2225 insertions, 1 deletions
diff --git a/packages/SystemUI/AndroidManifest.xml b/packages/SystemUI/AndroidManifest.xml index 06a97b17a73c..57c15e3211e1 100644 --- a/packages/SystemUI/AndroidManifest.xml +++ b/packages/SystemUI/AndroidManifest.xml @@ -772,5 +772,12 @@ </intent-filter> </receiver> + <receiver android:name=".media.dialog.MediaOutDialogReceiver" + android:exported="true"> + <intent-filter> + <action android:name="com.android.systemui.action.LAUNCH_MEDIA_OUTPUT_DIALOG" /> + </intent-filter> + </receiver> + </application> </manifest> diff --git a/packages/SystemUI/res/drawable/media_output_dialog_background.xml b/packages/SystemUI/res/drawable/media_output_dialog_background.xml new file mode 100644 index 000000000000..3ceb0f6ac06a --- /dev/null +++ b/packages/SystemUI/res/drawable/media_output_dialog_background.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ 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. + --> + +<inset xmlns:android="http://schemas.android.com/apk/res/android"> + <shape android:shape="rectangle"> + <corners android:radius="8dp" /> + <solid android:color="?android:attr/colorBackground" /> + </shape> +</inset> diff --git a/packages/SystemUI/res/layout/media_output_dialog.xml b/packages/SystemUI/res/layout/media_output_dialog.xml new file mode 100644 index 000000000000..0229e6e9d4dd --- /dev/null +++ b/packages/SystemUI/res/layout/media_output_dialog.xml @@ -0,0 +1,136 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ 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. + --> + +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/media_output_dialog" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical"> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="94dp" + android:gravity="start|center_vertical" + android:paddingStart="16dp" + android:orientation="horizontal"> + <ImageView + android:id="@+id/header_icon" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:paddingEnd="16dp"/> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginEnd="16dp" + android:orientation="vertical"> + <TextView + android:id="@+id/header_title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:ellipsize="end" + android:maxLines="1" + android:textColor="?android:attr/textColorPrimary" + android:fontFamily="@*android:string/config_headlineFontFamilyMedium" + android:textSize="20sp"/> + + <TextView + android:id="@+id/header_subtitle" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:ellipsize="end" + android:maxLines="1" + android:fontFamily="roboto-regular" + android:textSize="14sp"/> + + </LinearLayout> + </LinearLayout> + + <View + android:layout_width="match_parent" + android:layout_height="1dp" + android:background="?android:attr/listDivider"/> + + <LinearLayout + android:id="@+id/device_list" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:gravity="start|center_vertical" + android:orientation="vertical"> + + <View + android:layout_width="match_parent" + android:layout_height="12dp"/> + + <include + layout="@layout/media_output_list_item" + android:id="@+id/group_item_controller" + android:visibility="gone"/> + + <View + android:id="@+id/group_item_divider" + android:layout_width="match_parent" + android:layout_height="1dp" + android:background="?android:attr/listDivider" + android:visibility="gone"/> + + <androidx.recyclerview.widget.RecyclerView + android:id="@+id/list_result" + android:scrollbars="vertical" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:overScrollMode="never"/> + + <View + android:id="@+id/list_bottom_padding" + android:layout_width="match_parent" + android:layout_height="12dp"/> + </LinearLayout> + + <View + android:layout_width="match_parent" + android:layout_height="1dp" + android:background="?android:attr/listDivider"/> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal"> + + <Button + android:id="@+id/stop" + style="@*android:style/Widget.DeviceDefault.Button.Borderless.Colored" + android:layout_width="wrap_content" + android:layout_height="64dp" + android:text="@string/keyboard_key_media_stop" + android:visibility="gone"/> + + <Space + android:layout_weight="1" + android:layout_width="0dp" + android:layout_height="match_parent"/> + + <Button + android:id="@+id/done" + style="@*android:style/Widget.DeviceDefault.Button.Borderless.Colored" + android:layout_width="wrap_content" + android:layout_height="64dp" + android:layout_marginEnd="0dp" + android:text="@string/inline_done_button"/> + </LinearLayout> +</LinearLayout>
\ No newline at end of file diff --git a/packages/SystemUI/res/layout/media_output_list_item.xml b/packages/SystemUI/res/layout/media_output_list_item.xml new file mode 100644 index 000000000000..92d0858a1a31 --- /dev/null +++ b/packages/SystemUI/res/layout/media_output_list_item.xml @@ -0,0 +1,112 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ 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. + --> + +<FrameLayout + android:id="@+id/device_container" + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="64dp"> + + <FrameLayout + android:layout_width="36dp" + android:layout_height="36dp" + android:layout_gravity="center_vertical" + android:layout_marginStart="16dp"> + <ImageView + android:id="@+id/title_icon" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center"/> + </FrameLayout> + + <TextView + android:id="@+id/title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_vertical" + android:layout_marginStart="68dp" + android:ellipsize="end" + android:maxLines="1" + android:textColor="?android:attr/textColorPrimary" + android:textSize="14sp"/> + + <RelativeLayout + android:id="@+id/two_line_layout" + android:layout_width="wrap_content" + android:layout_height="48dp" + android:layout_marginStart="52dp" + android:layout_marginEnd="69dp" + android:layout_marginTop="10dp"> + <TextView + android:id="@+id/two_line_title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="16dp" + android:layout_marginEnd="15dp" + android:ellipsize="end" + android:maxLines="1" + android:textColor="?android:attr/textColorPrimary" + android:textSize="14sp"/> + <TextView + android:id="@+id/subtitle" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="16dp" + android:layout_marginEnd="15dp" + android:layout_marginBottom="7dp" + android:layout_alignParentBottom="true" + android:ellipsize="end" + android:maxLines="1" + android:textColor="?android:attr/textColorSecondary" + android:textSize="12sp" + android:fontFamily="roboto-regular" + android:visibility="gone"/> + <ProgressBar + android:id="@+id/volume_indeterminate_progress" + style="@*android:style/Widget.Material.ProgressBar.Horizontal" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginStart="16dp" + android:layout_marginEnd="15dp" + android:layout_marginBottom="1dp" + android:layout_alignParentBottom="true" + android:indeterminate="true" + android:indeterminateOnly="true" + android:visibility="gone"/> + <SeekBar + android:id="@+id/volume_seekbar" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_alignParentBottom="true"/> + </RelativeLayout> + + <View + android:layout_width="1dp" + android:layout_height="36dp" + android:layout_marginEnd="68dp" + android:layout_gravity="right|center_vertical" + android:background="?android:attr/listDivider" + android:visibility="gone"/> + + <ImageView + android:id="@+id/end_icon" + android:layout_width="24dp" + android:layout_height="24dp" + android:layout_gravity="right|center_vertical" + android:layout_marginEnd="24dp" + android:visibility="gone"/> +</FrameLayout>
\ No newline at end of file diff --git a/packages/SystemUI/res/values/dimens.xml b/packages/SystemUI/res/values/dimens.xml index 875fe1471b1c..98e8cde40275 100644 --- a/packages/SystemUI/res/values/dimens.xml +++ b/packages/SystemUI/res/values/dimens.xml @@ -1366,4 +1366,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 e577b967a85e..a6a39034c5eb 100644 --- a/packages/SystemUI/res/values/strings.xml +++ b/packages/SystemUI/res/values/strings.xml @@ -2799,4 +2799,19 @@ <string name="udfps_hbm_enable_command" translatable="false"></string> <!-- Device-specific payload for disabling the high-brightness mode --> <string name="udfps_hbm_disable_command" translatable="false"></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/res/values/styles.xml b/packages/SystemUI/res/values/styles.xml index 58563f49dce4..2b0a963bff18 100644 --- a/packages/SystemUI/res/values/styles.xml +++ b/packages/SystemUI/res/values/styles.xml @@ -388,6 +388,10 @@ <item name="android:windowIsFloating">true</item> </style> + <style name="Theme.SystemUI.Dialog.MediaOutput"> + <item name="android:windowBackground">@drawable/media_output_dialog_background</item> + </style> + <style name="QSBorderlessButton"> <item name="android:padding">12dp</item> <item name="android:background">@drawable/qs_btn_borderless_rect</item> @@ -735,5 +739,4 @@ * Title: headline, medium 20sp * Message: body, 16 sp --> <style name="Theme.ControlsRequestDialog" parent="@*android:style/Theme.DeviceDefault.Dialog.Alert"/> - </resources> diff --git a/packages/SystemUI/src/com/android/systemui/dagger/DefaultBroadcastReceiverBinder.java b/packages/SystemUI/src/com/android/systemui/dagger/DefaultBroadcastReceiverBinder.java index 6e8d63b2c516..307362fe790e 100644 --- a/packages/SystemUI/src/com/android/systemui/dagger/DefaultBroadcastReceiverBinder.java +++ b/packages/SystemUI/src/com/android/systemui/dagger/DefaultBroadcastReceiverBinder.java @@ -18,6 +18,7 @@ package com.android.systemui.dagger; import android.content.BroadcastReceiver; +import com.android.systemui.media.dialog.MediaOutDialogReceiver; import com.android.systemui.screenshot.ActionProxyReceiver; import com.android.systemui.screenshot.DeleteScreenshotReceiver; import com.android.systemui.screenshot.SmartActionsReceiver; @@ -59,4 +60,13 @@ public abstract class DefaultBroadcastReceiverBinder { public abstract BroadcastReceiver bindSmartActionsReceiver( SmartActionsReceiver broadcastReceiver); + /** + * + */ + @Binds + @IntoMap + @ClassKey(MediaOutDialogReceiver.class) + public abstract BroadcastReceiver bindMediaOutDialogReceiver( + MediaOutDialogReceiver broadcastReceiver); + } diff --git a/packages/SystemUI/src/com/android/systemui/media/dialog/MediaOutDialogReceiver.kt b/packages/SystemUI/src/com/android/systemui/media/dialog/MediaOutDialogReceiver.kt new file mode 100644 index 000000000000..d60771394ded --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/media/dialog/MediaOutDialogReceiver.kt @@ -0,0 +1,39 @@ +/* + * 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.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.text.TextUtils +import com.android.settingslib.media.MediaOutputSliceConstants +import javax.inject.Inject + +/** + * BroadcastReceiver for handling media output intent + */ +class MediaOutDialogReceiver @Inject constructor( + private var mediaOutputDialogFactory: MediaOutputDialogFactory +) : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (TextUtils.equals(MediaOutputSliceConstants.ACTION_LAUNCH_MEDIA_OUTPUT_DIALOG, + intent.action)) { + mediaOutputDialogFactory.create( + intent.getStringExtra(MediaOutputSliceConstants.EXTRA_PACKAGE_NAME), false) + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/media/dialog/MediaOutputAdapter.java b/packages/SystemUI/src/com/android/systemui/media/dialog/MediaOutputAdapter.java new file mode 100644 index 000000000000..9fc64d51cdf7 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/media/dialog/MediaOutputAdapter.java @@ -0,0 +1,163 @@ +/* + * 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 android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE; + +import android.graphics.PorterDuff; +import android.graphics.PorterDuffColorFilter; +import android.graphics.drawable.Drawable; +import android.text.SpannableString; +import android.text.style.ForegroundColorSpan; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; + +import com.android.settingslib.Utils; +import com.android.settingslib.media.LocalMediaManager.MediaDeviceState; +import com.android.settingslib.media.MediaDevice; +import com.android.systemui.R; + +import java.util.List; + +/** + * Adapter for media output dialog. + */ +public class MediaOutputAdapter extends MediaOutputBaseAdapter { + + private static final String TAG = "MediaOutputAdapter"; + private static final int PAIR_NEW = 1; + + public MediaOutputAdapter(MediaOutputController controller) { + super(controller); + } + + @Override + public MediaDeviceBaseViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, + int viewType) { + super.onCreateViewHolder(viewGroup, viewType); + + return new MediaDeviceViewHolder(mHolderView); + } + + @Override + public void onBindViewHolder(@NonNull MediaDeviceBaseViewHolder viewHolder, int position) { + if (mController.isZeroMode() && position == (mController.getMediaDevices().size())) { + viewHolder.onBind(PAIR_NEW); + } else if (position < (mController.getMediaDevices().size())) { + viewHolder.onBind(((List<MediaDevice>) (mController.getMediaDevices())).get(position)); + } else { + Log.d(TAG, "Incorrect position: " + position); + } + } + + @Override + public int getItemCount() { + if (mController.isZeroMode()) { + // Add extra one for "pair new" + return mController.getMediaDevices().size() + 1; + } + return mController.getMediaDevices().size(); + } + + void onItemClick(MediaDevice device) { + mController.connectDevice(device); + device.setState(MediaDeviceState.STATE_CONNECTING); + notifyDataSetChanged(); + } + + void onItemClick(int customizedItem) { + if (customizedItem == PAIR_NEW) { + mController.launchBluetoothPairing(); + } + } + + @Override + CharSequence getItemTitle(MediaDevice device) { + if (device.getDeviceType() == MediaDevice.MediaDeviceType.TYPE_BLUETOOTH_DEVICE + && !device.isConnected()) { + final CharSequence deviceName = device.getName(); + // Append status to title only for the disconnected Bluetooth device. + final SpannableString spannableTitle = new SpannableString( + mContext.getString(R.string.media_output_dialog_disconnected, deviceName)); + spannableTitle.setSpan(new ForegroundColorSpan( + Utils.getColorAttrDefaultColor(mContext, android.R.attr.textColorSecondary)), + deviceName.length(), + spannableTitle.length(), SPAN_EXCLUSIVE_EXCLUSIVE); + return spannableTitle; + } + return super.getItemTitle(device); + } + + class MediaDeviceViewHolder extends MediaDeviceBaseViewHolder { + + MediaDeviceViewHolder(View view) { + super(view); + } + + @Override + void onBind(MediaDevice device) { + super.onBind(device); + if (mController.isTransferring()) { + if (device.getState() == MediaDeviceState.STATE_CONNECTING + && !mController.hasAdjustVolumeUserRestriction()) { + setTwoLineLayout(device, true); + mProgressBar.setVisibility(View.VISIBLE); + mSeekBar.setVisibility(View.GONE); + mSubTitleText.setVisibility(View.GONE); + } else { + setSingleLineLayout(getItemTitle(device), false); + } + } else { + // Set different layout for each device + if (device.getState() == MediaDeviceState.STATE_CONNECTING_FAILED) { + setTwoLineLayout(device, false); + mSubTitleText.setVisibility(View.VISIBLE); + mSeekBar.setVisibility(View.GONE); + mProgressBar.setVisibility(View.GONE); + mSubTitleText.setText(R.string.media_output_dialog_connect_failed); + mFrameLayout.setOnClickListener(v -> onItemClick(device)); + } else if (!mController.hasAdjustVolumeUserRestriction() + && isCurrentConnected(device)) { + setTwoLineLayout(device, true); + mSeekBar.setVisibility(View.VISIBLE); + mProgressBar.setVisibility(View.GONE); + mSubTitleText.setVisibility(View.GONE); + initSeekbar(device); + } else { + setSingleLineLayout(getItemTitle(device), false); + mFrameLayout.setOnClickListener(v -> onItemClick(device)); + } + } + } + + @Override + void onBind(int customizedItem) { + if (customizedItem == PAIR_NEW) { + setSingleLineLayout(mContext.getText(R.string.media_output_dialog_pairing_new), + false); + final Drawable d = mContext.getDrawable(R.drawable.ic_add); + d.setColorFilter(new PorterDuffColorFilter( + Utils.getColorAccentDefaultColor(mContext), PorterDuff.Mode.SRC_IN)); + mTitleIcon.setImageDrawable(d); + mFrameLayout.setOnClickListener(v -> onItemClick(PAIR_NEW)); + } + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/media/dialog/MediaOutputBaseAdapter.java b/packages/SystemUI/src/com/android/systemui/media/dialog/MediaOutputBaseAdapter.java new file mode 100644 index 000000000000..7579c25b030a --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/media/dialog/MediaOutputBaseAdapter.java @@ -0,0 +1,165 @@ +/* + * 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.graphics.Typeface; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.ProgressBar; +import android.widget.RelativeLayout; +import android.widget.SeekBar; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.android.settingslib.media.MediaDevice; +import com.android.systemui.R; + +/** + * Base adapter for media output dialog. + */ +public abstract class MediaOutputBaseAdapter extends + RecyclerView.Adapter<MediaOutputBaseAdapter.MediaDeviceBaseViewHolder> { + + private static final String FONT_SELECTED_TITLE = "sans-serif-medium"; + private static final String FONT_TITLE = "sans-serif"; + + final MediaOutputController mController; + + private boolean mIsDragging; + + Context mContext; + View mHolderView; + + public MediaOutputBaseAdapter(MediaOutputController controller) { + mController = controller; + mIsDragging = false; + } + + @Override + public MediaDeviceBaseViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, + int viewType) { + mContext = viewGroup.getContext(); + mHolderView = LayoutInflater.from(mContext).inflate(R.layout.media_output_list_item, + viewGroup, false); + + return null; + } + + CharSequence getItemTitle(MediaDevice device) { + return device.getName(); + } + + boolean isCurrentConnected(MediaDevice device) { + return TextUtils.equals(device.getId(), + mController.getCurrentConnectedMediaDevice().getId()); + } + + boolean isDragging() { + return mIsDragging; + } + + /** + * ViewHolder for binding device view. + */ + abstract class MediaDeviceBaseViewHolder extends RecyclerView.ViewHolder { + final FrameLayout mFrameLayout; + final TextView mTitleText; + final TextView mTwoLineTitleText; + final TextView mSubTitleText; + final ImageView mTitleIcon; + final ImageView mEndIcon; + final ProgressBar mProgressBar; + final SeekBar mSeekBar; + final RelativeLayout mTwoLineLayout; + + MediaDeviceBaseViewHolder(View view) { + super(view); + mFrameLayout = view.requireViewById(R.id.device_container); + mTitleText = view.requireViewById(R.id.title); + mSubTitleText = view.requireViewById(R.id.subtitle); + mTwoLineLayout = view.requireViewById(R.id.two_line_layout); + mTwoLineTitleText = view.requireViewById(R.id.two_line_title); + mTitleIcon = view.requireViewById(R.id.title_icon); + mEndIcon = view.requireViewById(R.id.end_icon); + mProgressBar = view.requireViewById(R.id.volume_indeterminate_progress); + mSeekBar = view.requireViewById(R.id.volume_seekbar); + } + + void onBind(MediaDevice device) { + mTitleIcon.setImageIcon(mController.getDeviceIconCompat(device).toIcon(mContext)); + } + + void onBind(int customizedItem) { } + + void setSingleLineLayout(CharSequence title, boolean bFocused) { + mTitleText.setVisibility(View.VISIBLE); + mTwoLineLayout.setVisibility(View.GONE); + mTitleText.setText(title); + if (bFocused) { + mTitleText.setTypeface(Typeface.create(FONT_SELECTED_TITLE, Typeface.NORMAL)); + } else { + mTitleText.setTypeface(Typeface.create(FONT_TITLE, Typeface.NORMAL)); + } + } + + void setTwoLineLayout(MediaDevice device, boolean bFocused) { + mTitleText.setVisibility(View.GONE); + mTwoLineLayout.setVisibility(View.VISIBLE); + mTwoLineTitleText.setText(getItemTitle(device)); + if (bFocused) { + mTwoLineTitleText.setTypeface(Typeface.create(FONT_SELECTED_TITLE, + Typeface.NORMAL)); + } else { + mTwoLineTitleText.setTypeface(Typeface.create(FONT_TITLE, Typeface.NORMAL)); + } + } + + void initSeekbar(MediaDevice device) { + mSeekBar.setMax(device.getMaxVolume()); + mSeekBar.setMin(0); + if (mSeekBar.getProgress() != device.getCurrentVolume()) { + mSeekBar.setProgress(device.getCurrentVolume()); + } + mSeekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + if (device == null || !fromUser) { + return; + } + mController.adjustVolume(device, progress); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + mIsDragging = true; + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + mIsDragging = false; + } + }); + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/media/dialog/MediaOutputBaseDialog.java b/packages/SystemUI/src/com/android/systemui/media/dialog/MediaOutputBaseDialog.java new file mode 100644 index 000000000000..781bf8d74d88 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/media/dialog/MediaOutputBaseDialog.java @@ -0,0 +1,220 @@ +/* + * 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 android.view.WindowInsets.Type.navigationBars; +import static android.view.WindowInsets.Type.statusBars; + +import android.content.Context; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.text.TextUtils; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.view.Window; +import android.view.WindowInsets; +import android.view.WindowManager; +import android.widget.Button; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.annotation.VisibleForTesting; +import androidx.core.graphics.drawable.IconCompat; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.android.settingslib.R; +import com.android.systemui.statusbar.phone.SystemUIDialog; + +/** + * Base dialog for media output UI + */ +public abstract class MediaOutputBaseDialog extends SystemUIDialog implements + MediaOutputController.Callback { + + private static final String TAG = "MediaOutputDialog"; + + private final Handler mMainThreadHandler = new Handler(Looper.getMainLooper()); + private final RecyclerView.LayoutManager mLayoutManager; + + final Context mContext; + final MediaOutputController mMediaOutputController; + + @VisibleForTesting + View mDialogView; + private TextView mHeaderTitle; + private TextView mHeaderSubtitle; + private ImageView mHeaderIcon; + private RecyclerView mDevicesRecyclerView; + private LinearLayout mDeviceListLayout; + private Button mDoneButton; + private Button mStopButton; + private View mListBottomPadding; + private int mListMaxHeight; + + MediaOutputBaseAdapter mAdapter; + FrameLayout mGroupItemController; + View mGroupDivider; + + private final ViewTreeObserver.OnGlobalLayoutListener mDeviceListLayoutListener = () -> { + // Set max height for list + if (mDeviceListLayout.getHeight() > mListMaxHeight) { + ViewGroup.LayoutParams params = mDeviceListLayout.getLayoutParams(); + params.height = mListMaxHeight; + mDeviceListLayout.setLayoutParams(params); + } + }; + + public MediaOutputBaseDialog(Context context, MediaOutputController mediaOutputController) { + super(context, R.style.Theme_SystemUI_Dialog_MediaOutput); + mContext = context; + mMediaOutputController = mediaOutputController; + mLayoutManager = new LinearLayoutManager(mContext); + mListMaxHeight = context.getResources().getDimensionPixelSize( + R.dimen.media_output_dialog_list_max_height); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + mDialogView = LayoutInflater.from(mContext).inflate(R.layout.media_output_dialog, null); + final Window window = getWindow(); + final WindowManager.LayoutParams lp = window.getAttributes(); + lp.gravity = Gravity.BOTTOM; + // Config insets to make sure the layout is above the navigation bar + lp.setFitInsetsTypes(statusBars() | navigationBars()); + lp.setFitInsetsSides(WindowInsets.Side.all()); + lp.setFitInsetsIgnoringVisibility(true); + window.setAttributes(lp); + window.setContentView(mDialogView); + window.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + + mHeaderTitle = mDialogView.requireViewById(R.id.header_title); + mHeaderSubtitle = mDialogView.requireViewById(R.id.header_subtitle); + mHeaderIcon = mDialogView.requireViewById(R.id.header_icon); + mDevicesRecyclerView = mDialogView.requireViewById(R.id.list_result); + mGroupItemController = mDialogView.requireViewById(R.id.group_item_controller); + mGroupDivider = mDialogView.requireViewById(R.id.group_item_divider); + mDeviceListLayout = mDialogView.requireViewById(R.id.device_list); + mDoneButton = mDialogView.requireViewById(R.id.done); + mStopButton = mDialogView.requireViewById(R.id.stop); + mListBottomPadding = mDialogView.requireViewById(R.id.list_bottom_padding); + + mDeviceListLayout.getViewTreeObserver().addOnGlobalLayoutListener( + mDeviceListLayoutListener); + // Init device list + mDevicesRecyclerView.setLayoutManager(mLayoutManager); + mDevicesRecyclerView.setAdapter(mAdapter); + // Init bottom buttons + mDoneButton.setOnClickListener(v -> dismiss()); + mStopButton.setOnClickListener(v -> { + mMediaOutputController.releaseSession(); + dismiss(); + }); + } + + @Override + public void onStart() { + super.onStart(); + mMediaOutputController.start(this); + } + + @Override + public void onStop() { + super.onStop(); + mMediaOutputController.stop(); + } + + @VisibleForTesting + void refresh() { + // Update header icon + final int iconRes = getHeaderIconRes(); + final IconCompat iconCompat = getHeaderIcon(); + if (iconRes != 0) { + mHeaderIcon.setVisibility(View.VISIBLE); + mHeaderIcon.setImageResource(iconRes); + } else if (iconCompat != null) { + mHeaderIcon.setVisibility(View.VISIBLE); + mHeaderIcon.setImageIcon(iconCompat.toIcon(mContext)); + } else { + mHeaderIcon.setVisibility(View.GONE); + } + if (mHeaderIcon.getVisibility() == View.VISIBLE) { + final int size = getHeaderIconSize(); + mHeaderIcon.setLayoutParams(new LinearLayout.LayoutParams(size, size)); + } + // Update title and subtitle + mHeaderTitle.setText(getHeaderText()); + final CharSequence subTitle = getHeaderSubtitle(); + if (TextUtils.isEmpty(subTitle)) { + mHeaderSubtitle.setVisibility(View.GONE); + mHeaderTitle.setGravity(Gravity.START | Gravity.CENTER_VERTICAL); + } else { + mHeaderSubtitle.setVisibility(View.VISIBLE); + mHeaderSubtitle.setText(subTitle); + mHeaderTitle.setGravity(Gravity.NO_GRAVITY); + } + if (!mAdapter.isDragging()) { + mAdapter.notifyDataSetChanged(); + } + // Add extra padding when device amount is less than 6 + if (mMediaOutputController.getMediaDevices().size() < 6) { + mListBottomPadding.setVisibility(View.VISIBLE); + } else { + mListBottomPadding.setVisibility(View.GONE); + } + } + + abstract int getHeaderIconRes(); + + abstract IconCompat getHeaderIcon(); + + abstract int getHeaderIconSize(); + + abstract CharSequence getHeaderText(); + + abstract CharSequence getHeaderSubtitle(); + + @Override + public void onMediaChanged() { + mMainThreadHandler.post(() -> refresh()); + } + + @Override + public void onMediaStoppedOrPaused() { + if (isShowing()) { + dismiss(); + } + } + + @Override + public void onRouteChanged() { + mMainThreadHandler.post(() -> refresh()); + } + + @Override + public void dismissDialog() { + dismiss(); + } +} 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 000000000000..64d20a273931 --- /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/src/com/android/systemui/media/dialog/MediaOutputDialog.java b/packages/SystemUI/src/com/android/systemui/media/dialog/MediaOutputDialog.java new file mode 100644 index 000000000000..ac9d8ce52d88 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/media/dialog/MediaOutputDialog.java @@ -0,0 +1,77 @@ +/* + * 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.os.Bundle; +import android.view.View; +import android.view.WindowManager; + +import androidx.core.graphics.drawable.IconCompat; + +import com.android.systemui.R; +import com.android.systemui.dagger.SysUISingleton; + +/** + * Dialog for media output transferring. + */ +@SysUISingleton +public class MediaOutputDialog extends MediaOutputBaseDialog { + + MediaOutputDialog(Context context, boolean aboveStatusbar, MediaOutputController + mediaOutputController) { + super(context, mediaOutputController); + mAdapter = new MediaOutputAdapter(mMediaOutputController); + if (!aboveStatusbar) { + getWindow().setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY); + } + show(); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mGroupItemController.setVisibility(View.GONE); + mGroupDivider.setVisibility(View.GONE); + } + + @Override + int getHeaderIconRes() { + return 0; + } + + @Override + IconCompat getHeaderIcon() { + return mMediaOutputController.getHeaderIcon(); + } + + @Override + int getHeaderIconSize() { + return mContext.getResources().getDimensionPixelSize( + R.dimen.media_output_dialog_header_album_icon_size); + } + + @Override + CharSequence getHeaderText() { + return mMediaOutputController.getHeaderTitle(); + } + + @Override + CharSequence getHeaderSubtitle() { + return mMediaOutputController.getHeaderSubTitle(); + } +} diff --git a/packages/SystemUI/src/com/android/systemui/media/dialog/MediaOutputDialogFactory.kt b/packages/SystemUI/src/com/android/systemui/media/dialog/MediaOutputDialogFactory.kt new file mode 100644 index 000000000000..bc1dca58990d --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/media/dialog/MediaOutputDialogFactory.kt @@ -0,0 +1,42 @@ +/* + * 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.media.session.MediaSessionManager +import com.android.settingslib.bluetooth.LocalBluetoothManager +import com.android.systemui.plugins.ActivityStarter +import com.android.systemui.statusbar.phone.ShadeController +import javax.inject.Inject + +/** + * Factory to create [MediaOutputDialog] objects. + */ +class MediaOutputDialogFactory @Inject constructor( + private val context: Context, + private val mediaSessionManager: MediaSessionManager, + private val lbm: LocalBluetoothManager?, + private val shadeController: ShadeController, + private val starter: ActivityStarter +) { + /** Creates a [MediaOutputDialog] for the given package. */ + fun create(packageName: String, aboveStatusBar: Boolean) { + MediaOutputController(context, packageName, mediaSessionManager, lbm, shadeController, + starter).run { + MediaOutputDialog(context, aboveStatusBar, this) } + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/dialog/MediaOutputAdapterTest.java b/packages/SystemUI/tests/src/com/android/systemui/media/dialog/MediaOutputAdapterTest.java new file mode 100644 index 000000000000..0e376bd356a2 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/media/dialog/MediaOutputAdapterTest.java @@ -0,0 +1,200 @@ +/* + * 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.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.graphics.drawable.Icon; +import android.testing.AndroidTestingRunner; +import android.view.View; +import android.widget.FrameLayout; + +import androidx.core.graphics.drawable.IconCompat; +import androidx.test.filters.SmallTest; + +import com.android.settingslib.media.LocalMediaManager; +import com.android.settingslib.media.MediaDevice; +import com.android.systemui.R; +import com.android.systemui.SysuiTestCase; + +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 MediaOutputAdapterTest extends SysuiTestCase { + + private static final String TEST_DEVICE_NAME_1 = "test_device_name_1"; + private static final String TEST_DEVICE_NAME_2 = "test_device_name_2"; + private static final String TEST_DEVICE_ID_1 = "test_device_id_1"; + private static final String TEST_DEVICE_ID_2 = "test_device_id_2"; + + // Mock + private MediaOutputController mMediaOutputController = mock(MediaOutputController.class); + private MediaDevice mMediaDevice1 = mock(MediaDevice.class); + private MediaDevice mMediaDevice2 = mock(MediaDevice.class); + private Icon mIcon = mock(Icon.class); + private IconCompat mIconCompat = mock(IconCompat.class); + + private MediaOutputAdapter mMediaOutputAdapter; + private MediaOutputAdapter.MediaDeviceViewHolder mViewHolder; + private List<MediaDevice> mMediaDevices = new ArrayList<>(); + + @Before + public void setUp() { + mMediaOutputAdapter = new MediaOutputAdapter(mMediaOutputController); + mViewHolder = (MediaOutputAdapter.MediaDeviceViewHolder) mMediaOutputAdapter + .onCreateViewHolder(new FrameLayout(mContext), 0); + + when(mMediaOutputController.getMediaDevices()).thenReturn(mMediaDevices); + when(mMediaOutputController.hasAdjustVolumeUserRestriction()).thenReturn(false); + when(mMediaOutputController.isZeroMode()).thenReturn(false); + when(mMediaOutputController.isTransferring()).thenReturn(false); + when(mMediaOutputController.getDeviceIconCompat(mMediaDevice1)).thenReturn(mIconCompat); + when(mMediaOutputController.getDeviceIconCompat(mMediaDevice2)).thenReturn(mIconCompat); + when(mMediaOutputController.getCurrentConnectedMediaDevice()).thenReturn(mMediaDevice1); + when(mIconCompat.toIcon(mContext)).thenReturn(mIcon); + when(mMediaDevice1.getName()).thenReturn(TEST_DEVICE_NAME_1); + when(mMediaDevice1.getId()).thenReturn(TEST_DEVICE_ID_1); + when(mMediaDevice2.getName()).thenReturn(TEST_DEVICE_NAME_2); + when(mMediaDevice2.getId()).thenReturn(TEST_DEVICE_ID_2); + when(mMediaDevice1.getState()).thenReturn( + LocalMediaManager.MediaDeviceState.STATE_CONNECTED); + when(mMediaDevice2.getState()).thenReturn( + LocalMediaManager.MediaDeviceState.STATE_DISCONNECTED); + mMediaDevices.add(mMediaDevice1); + mMediaDevices.add(mMediaDevice2); + } + + @Test + public void getItemCount_nonZeroMode_isDeviceSize() { + assertThat(mMediaOutputAdapter.getItemCount()).isEqualTo(mMediaDevices.size()); + } + + @Test + public void getItemCount_zeroMode_containExtraOneForPairNew() { + when(mMediaOutputController.isZeroMode()).thenReturn(true); + + assertThat(mMediaOutputAdapter.getItemCount()).isEqualTo(mMediaDevices.size() + 1); + } + + @Test + public void onBindViewHolder_zeroMode_bindPairNew_verifyView() { + when(mMediaOutputController.isZeroMode()).thenReturn(true); + mMediaOutputAdapter.onBindViewHolder(mViewHolder, 2); + + assertThat(mViewHolder.mTitleText.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(mViewHolder.mTwoLineLayout.getVisibility()).isEqualTo(View.GONE); + assertThat(mViewHolder.mTitleText.getText()).isEqualTo(mContext.getText( + R.string.media_output_dialog_pairing_new)); + } + + @Test + public void onBindViewHolder_bindConnectedDevice_verifyView() { + mMediaOutputAdapter.onBindViewHolder(mViewHolder, 0); + + assertThat(mViewHolder.mTitleText.getVisibility()).isEqualTo(View.GONE); + assertThat(mViewHolder.mSubTitleText.getVisibility()).isEqualTo(View.GONE); + assertThat(mViewHolder.mProgressBar.getVisibility()).isEqualTo(View.GONE); + assertThat(mViewHolder.mTwoLineTitleText.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(mViewHolder.mSeekBar.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(mViewHolder.mTwoLineTitleText.getText()).isEqualTo(TEST_DEVICE_NAME_1); + } + + @Test + public void onBindViewHolder_bindDisconnectedBluetoothDevice_verifyView() { + when(mMediaDevice2.getDeviceType()).thenReturn( + MediaDevice.MediaDeviceType.TYPE_BLUETOOTH_DEVICE); + when(mMediaDevice2.isConnected()).thenReturn(false); + mMediaOutputAdapter.onBindViewHolder(mViewHolder, 1); + + assertThat(mViewHolder.mTwoLineLayout.getVisibility()).isEqualTo(View.GONE); + assertThat(mViewHolder.mTitleText.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(mViewHolder.mTitleText.getText().toString()).isEqualTo( + mContext.getString(R.string.media_output_dialog_disconnected, TEST_DEVICE_NAME_2)); + } + + @Test + public void onBindViewHolder_bindFailedStateDevice_verifyView() { + when(mMediaDevice2.getState()).thenReturn( + LocalMediaManager.MediaDeviceState.STATE_CONNECTING_FAILED); + mMediaOutputAdapter.onBindViewHolder(mViewHolder, 1); + + assertThat(mViewHolder.mTitleText.getVisibility()).isEqualTo(View.GONE); + assertThat(mViewHolder.mSeekBar.getVisibility()).isEqualTo(View.GONE); + assertThat(mViewHolder.mProgressBar.getVisibility()).isEqualTo(View.GONE); + assertThat(mViewHolder.mSubTitleText.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(mViewHolder.mTwoLineTitleText.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(mViewHolder.mSubTitleText.getText()).isEqualTo(mContext.getText( + R.string.media_output_dialog_connect_failed)); + assertThat(mViewHolder.mTwoLineTitleText.getText()).isEqualTo(TEST_DEVICE_NAME_2); + } + + @Test + public void onBindViewHolder_inTransferring_bindTransferringDevice_verifyView() { + when(mMediaOutputController.isTransferring()).thenReturn(true); + when(mMediaDevice1.getState()).thenReturn( + LocalMediaManager.MediaDeviceState.STATE_CONNECTING); + mMediaOutputAdapter.onBindViewHolder(mViewHolder, 0); + + assertThat(mViewHolder.mTitleText.getVisibility()).isEqualTo(View.GONE); + assertThat(mViewHolder.mSeekBar.getVisibility()).isEqualTo(View.GONE); + assertThat(mViewHolder.mSubTitleText.getVisibility()).isEqualTo(View.GONE); + assertThat(mViewHolder.mProgressBar.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(mViewHolder.mTwoLineTitleText.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(mViewHolder.mTwoLineTitleText.getText()).isEqualTo(TEST_DEVICE_NAME_1); + } + + @Test + public void onBindViewHolder_inTransferring_bindNonTransferringDevice_verifyView() { + when(mMediaOutputController.isTransferring()).thenReturn(true); + when(mMediaDevice2.getState()).thenReturn( + LocalMediaManager.MediaDeviceState.STATE_CONNECTING); + mMediaOutputAdapter.onBindViewHolder(mViewHolder, 0); + + assertThat(mViewHolder.mTitleText.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(mViewHolder.mTwoLineLayout.getVisibility()).isEqualTo(View.GONE); + assertThat(mViewHolder.mTitleText.getText()).isEqualTo(TEST_DEVICE_NAME_1); + } + + @Test + public void onItemClick_clickPairNew_verifyLaunchBluetoothPairing() { + when(mMediaOutputController.isZeroMode()).thenReturn(true); + mMediaOutputAdapter.onBindViewHolder(mViewHolder, 2); + mViewHolder.mFrameLayout.performClick(); + + verify(mMediaOutputController).launchBluetoothPairing(); + } + + @Test + public void onItemClick_clickDevice_verifyConnectDevice() { + assertThat(mMediaDevice2.getState()).isEqualTo( + LocalMediaManager.MediaDeviceState.STATE_DISCONNECTED); + mMediaOutputAdapter.onBindViewHolder(mViewHolder, 1); + mViewHolder.mFrameLayout.performClick(); + + verify(mMediaOutputController).connectDevice(mMediaDevice2); + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/dialog/MediaOutputBaseDialogTest.java b/packages/SystemUI/tests/src/com/android/systemui/media/dialog/MediaOutputBaseDialogTest.java new file mode 100644 index 000000000000..42b21c61510a --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/media/dialog/MediaOutputBaseDialogTest.java @@ -0,0 +1,212 @@ +/* + * 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.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.content.Context; +import android.media.session.MediaSessionManager; +import android.os.Bundle; +import android.testing.AndroidTestingRunner; +import android.testing.TestableLooper; +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.core.graphics.drawable.IconCompat; +import androidx.test.filters.SmallTest; + +import com.android.settingslib.bluetooth.LocalBluetoothManager; +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; + +@SmallTest +@RunWith(AndroidTestingRunner.class) +@TestableLooper.RunWithLooper +public class MediaOutputBaseDialogTest extends SysuiTestCase { + + private static final String TEST_PACKAGE = "test_package"; + + // Mock + private MediaOutputBaseAdapter mMediaOutputBaseAdapter = mock(MediaOutputBaseAdapter.class); + + private MediaSessionManager mMediaSessionManager = mock(MediaSessionManager.class); + private LocalBluetoothManager mLocalBluetoothManager = mock(LocalBluetoothManager.class); + private ShadeController mShadeController = mock(ShadeController.class); + private ActivityStarter mStarter = mock(ActivityStarter.class); + + private MediaOutputBaseDialogImpl mMediaOutputBaseDialogImpl; + private MediaOutputController mMediaOutputController; + private int mHeaderIconRes; + private IconCompat mIconCompat; + private CharSequence mHeaderTitle; + private CharSequence mHeaderSubtitle; + + @Before + public void setUp() { + mMediaOutputController = new MediaOutputController(mContext, TEST_PACKAGE, + mMediaSessionManager, mLocalBluetoothManager, mShadeController, mStarter); + mMediaOutputBaseDialogImpl = new MediaOutputBaseDialogImpl(mContext, + mMediaOutputController); + mMediaOutputBaseDialogImpl.onCreate(new Bundle()); + } + + @Test + public void refresh_withIconRes_iconIsVisible() { + mHeaderIconRes = 1; + mMediaOutputBaseDialogImpl.refresh(); + final ImageView view = mMediaOutputBaseDialogImpl.mDialogView.requireViewById( + R.id.header_icon); + + assertThat(view.getVisibility()).isEqualTo(View.VISIBLE); + } + + @Test + public void refresh_withIconCompat_iconIsVisible() { + mIconCompat = mock(IconCompat.class); + mMediaOutputBaseDialogImpl.refresh(); + final ImageView view = mMediaOutputBaseDialogImpl.mDialogView.requireViewById( + R.id.header_icon); + + assertThat(view.getVisibility()).isEqualTo(View.VISIBLE); + } + + @Test + public void refresh_noIcon_iconLayoutNotVisible() { + mHeaderIconRes = 0; + mIconCompat = null; + mMediaOutputBaseDialogImpl.refresh(); + final ImageView view = mMediaOutputBaseDialogImpl.mDialogView.requireViewById( + R.id.header_icon); + + assertThat(view.getVisibility()).isEqualTo(View.GONE); + } + + @Test + public void refresh_checkTitle() { + mHeaderTitle = "test_string"; + + mMediaOutputBaseDialogImpl.refresh(); + final TextView titleView = mMediaOutputBaseDialogImpl.mDialogView.requireViewById( + R.id.header_title); + + assertThat(titleView.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(titleView.getText()).isEqualTo(mHeaderTitle); + } + + @Test + public void refresh_withSubtitle_checkSubtitle() { + mHeaderSubtitle = "test_string"; + + mMediaOutputBaseDialogImpl.refresh(); + final TextView subtitleView = mMediaOutputBaseDialogImpl.mDialogView.requireViewById( + R.id.header_subtitle); + + assertThat(subtitleView.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(subtitleView.getText()).isEqualTo(mHeaderSubtitle); + } + + @Test + public void refresh_noSubtitle_checkSubtitle() { + mMediaOutputBaseDialogImpl.refresh(); + final TextView subtitleView = mMediaOutputBaseDialogImpl.mDialogView.requireViewById( + R.id.header_subtitle); + + assertThat(subtitleView.getVisibility()).isEqualTo(View.GONE); + } + + @Test + public void refresh_inDragging_notUpdateAdapter() { + when(mMediaOutputBaseAdapter.isDragging()).thenReturn(true); + mMediaOutputBaseDialogImpl.refresh(); + + verify(mMediaOutputBaseAdapter, never()).notifyDataSetChanged(); + } + + @Test + public void refresh_notInDragging_verifyUpdateAdapter() { + when(mMediaOutputBaseAdapter.isDragging()).thenReturn(false); + mMediaOutputBaseDialogImpl.refresh(); + + verify(mMediaOutputBaseAdapter).notifyDataSetChanged(); + } + + @Test + public void refresh_with6Devices_checkBottomPaddingVisibility() { + for (int i = 0; i < 6; i++) { + mMediaOutputController.mMediaDevices.add(mock(MediaDevice.class)); + } + mMediaOutputBaseDialogImpl.refresh(); + final View view = mMediaOutputBaseDialogImpl.mDialogView.requireViewById( + R.id.list_bottom_padding); + + assertThat(view.getVisibility()).isEqualTo(View.GONE); + } + + @Test + public void refresh_with5Devices_checkBottomPaddingVisibility() { + for (int i = 0; i < 5; i++) { + mMediaOutputController.mMediaDevices.add(mock(MediaDevice.class)); + } + mMediaOutputBaseDialogImpl.refresh(); + final View view = mMediaOutputBaseDialogImpl.mDialogView.requireViewById( + R.id.list_bottom_padding); + + assertThat(view.getVisibility()).isEqualTo(View.VISIBLE); + } + + class MediaOutputBaseDialogImpl extends MediaOutputBaseDialog { + + MediaOutputBaseDialogImpl(Context context, MediaOutputController mediaOutputController) { + super(context, mediaOutputController); + + mAdapter = mMediaOutputBaseAdapter; + } + + int getHeaderIconRes() { + return mHeaderIconRes; + } + + IconCompat getHeaderIcon() { + return mIconCompat; + } + + int getHeaderIconSize() { + return 10; + } + + CharSequence getHeaderText() { + return mHeaderTitle; + } + + CharSequence getHeaderSubtitle() { + return mHeaderSubtitle; + } + } +} 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 000000000000..0dcdecfdaadb --- /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(); + } +} |