diff --git a/packages/SettingsLib/src/com/android/settingslib/media/DeviceIconUtil.java b/packages/SettingsLib/src/com/android/settingslib/media/DeviceIconUtil.java new file mode 100644 index 0000000000000..6c0eab3fb0168 --- /dev/null +++ b/packages/SettingsLib/src/com/android/settingslib/media/DeviceIconUtil.java @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2022 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.settingslib.media; + +import android.annotation.DrawableRes; +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.media.AudioDeviceInfo; +import android.media.MediaRoute2Info; + +import com.android.settingslib.R; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** A util class to get the appropriate icon for different device types. */ +public class DeviceIconUtil { + // A map from a @AudioDeviceInfo.AudioDeviceType to full device information. + private final Map mAudioDeviceTypeToIconMap = new HashMap<>(); + // A map from a @MediaRoute2Info.Type to full device information. + private final Map mMediaRouteTypeToIconMap = new HashMap<>(); + // A default icon to use if the type is not present in the map. + @DrawableRes private static final int DEFAULT_ICON = R.drawable.ic_smartphone; + + public DeviceIconUtil() { + List deviceList = Arrays.asList( + new Device( + AudioDeviceInfo.TYPE_USB_DEVICE, + MediaRoute2Info.TYPE_USB_DEVICE, + R.drawable.ic_headphone), + new Device( + AudioDeviceInfo.TYPE_USB_HEADSET, + MediaRoute2Info.TYPE_USB_HEADSET, + R.drawable.ic_headphone), + new Device( + AudioDeviceInfo.TYPE_USB_ACCESSORY, + MediaRoute2Info.TYPE_USB_ACCESSORY, + R.drawable.ic_headphone), + new Device( + AudioDeviceInfo.TYPE_DOCK, + MediaRoute2Info.TYPE_DOCK, + R.drawable.ic_headphone), + new Device( + AudioDeviceInfo.TYPE_HDMI, + MediaRoute2Info.TYPE_HDMI, + R.drawable.ic_headphone), + new Device( + AudioDeviceInfo.TYPE_WIRED_HEADSET, + MediaRoute2Info.TYPE_WIRED_HEADSET, + R.drawable.ic_headphone), + new Device( + AudioDeviceInfo.TYPE_WIRED_HEADPHONES, + MediaRoute2Info.TYPE_WIRED_HEADPHONES, + R.drawable.ic_headphone), + new Device( + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER, + MediaRoute2Info.TYPE_BUILTIN_SPEAKER, + R.drawable.ic_smartphone)); + for (int i = 0; i < deviceList.size(); i++) { + Device device = deviceList.get(i); + mAudioDeviceTypeToIconMap.put(device.mAudioDeviceType, device); + mMediaRouteTypeToIconMap.put(device.mMediaRouteType, device); + } + } + + /** Returns a drawable for an icon representing the given audioDeviceType. */ + public Drawable getIconFromAudioDeviceType( + @AudioDeviceInfo.AudioDeviceType int audioDeviceType, Context context) { + return context.getDrawable(getIconResIdFromAudioDeviceType(audioDeviceType)); + } + + /** Returns a drawable res ID for an icon representing the given audioDeviceType. */ + @DrawableRes + public int getIconResIdFromAudioDeviceType( + @AudioDeviceInfo.AudioDeviceType int audioDeviceType) { + if (mAudioDeviceTypeToIconMap.containsKey(audioDeviceType)) { + return mAudioDeviceTypeToIconMap.get(audioDeviceType).mIconDrawableRes; + } + return DEFAULT_ICON; + } + + /** Returns a drawable res ID for an icon representing the given mediaRouteType. */ + @DrawableRes + public int getIconResIdFromMediaRouteType( + @MediaRoute2Info.Type int mediaRouteType) { + if (mMediaRouteTypeToIconMap.containsKey(mediaRouteType)) { + return mMediaRouteTypeToIconMap.get(mediaRouteType).mIconDrawableRes; + } + return DEFAULT_ICON; + } + + private static class Device { + @AudioDeviceInfo.AudioDeviceType + private final int mAudioDeviceType; + + @MediaRoute2Info.Type + private final int mMediaRouteType; + + @DrawableRes + private final int mIconDrawableRes; + + Device(@AudioDeviceInfo.AudioDeviceType int audioDeviceType, + @MediaRoute2Info.Type int mediaRouteType, + @DrawableRes int iconDrawableRes) { + mAudioDeviceType = audioDeviceType; + mMediaRouteType = mediaRouteType; + mIconDrawableRes = iconDrawableRes; + } + } +} diff --git a/packages/SettingsLib/src/com/android/settingslib/media/LocalMediaManager.java b/packages/SettingsLib/src/com/android/settingslib/media/LocalMediaManager.java index b5facf34d1e7b..31d5921a86735 100644 --- a/packages/SettingsLib/src/com/android/settingslib/media/LocalMediaManager.java +++ b/packages/SettingsLib/src/com/android/settingslib/media/LocalMediaManager.java @@ -21,6 +21,7 @@ import android.app.Notification; import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothDevice; import android.content.Context; +import android.graphics.drawable.Drawable; import android.media.RoutingSessionInfo; import android.os.Build; import android.text.TextUtils; @@ -226,6 +227,18 @@ public class LocalMediaManager implements BluetoothCallback { } } + /** + * Dispatch a change in the about-to-connect device. See + * {@link DeviceCallback#onAboutToConnectDeviceChanged} for more information. + */ + public void dispatchAboutToConnectDeviceChanged( + @Nullable String deviceName, + @Nullable Drawable deviceIcon) { + for (DeviceCallback callback : getCallbacks()) { + callback.onAboutToConnectDeviceChanged(deviceName, deviceIcon); + } + } + /** * Stop scan MediaDevice */ @@ -674,6 +687,21 @@ public class LocalMediaManager implements BluetoothCallback { * {@link android.media.MediaRoute2ProviderService#REASON_INVALID_COMMAND}, */ default void onRequestFailed(int reason){}; + + /** + * Callback for notifying that we have a new about-to-connect device. + * + * An about-to-connect device is a device that is not yet connected but is expected to + * connect imminently and should be displayed as the current device in the media player. + * See [AudioManager.muteAwaitConnection] for more details. + * + * @param deviceName the name of the device (displayed to the user). + * @param deviceIcon the icon that should be used with the device. + */ + default void onAboutToConnectDeviceChanged( + @Nullable String deviceName, + @Nullable Drawable deviceIcon + ) {} } /** diff --git a/packages/SettingsLib/src/com/android/settingslib/media/PhoneMediaDevice.java b/packages/SettingsLib/src/com/android/settingslib/media/PhoneMediaDevice.java index c16ecb5587125..921c245264458 100644 --- a/packages/SettingsLib/src/com/android/settingslib/media/PhoneMediaDevice.java +++ b/packages/SettingsLib/src/com/android/settingslib/media/PhoneMediaDevice.java @@ -47,10 +47,12 @@ public class PhoneMediaDevice extends MediaDevice { private String mSummary = ""; + private final DeviceIconUtil mDeviceIconUtil; + PhoneMediaDevice(Context context, MediaRouter2Manager routerManager, MediaRoute2Info info, String packageName) { super(context, routerManager, info, packageName); - + mDeviceIconUtil = new DeviceIconUtil(); initDeviceRecord(); } @@ -94,23 +96,7 @@ public class PhoneMediaDevice extends MediaDevice { @VisibleForTesting int getDrawableResId() { - int resId; - switch (mRouteInfo.getType()) { - case TYPE_USB_DEVICE: - case TYPE_USB_HEADSET: - case TYPE_USB_ACCESSORY: - case TYPE_DOCK: - case TYPE_HDMI: - case TYPE_WIRED_HEADSET: - case TYPE_WIRED_HEADPHONES: - resId = R.drawable.ic_headphone; - break; - case TYPE_BUILTIN_SPEAKER: - default: - resId = R.drawable.ic_smartphone; - break; - } - return resId; + return mDeviceIconUtil.getIconResIdFromMediaRouteType(mRouteInfo.getType()); } @Override diff --git a/packages/SettingsLib/tests/robotests/src/com/android/settingslib/media/DeviceIconUtilTest.java b/packages/SettingsLib/tests/robotests/src/com/android/settingslib/media/DeviceIconUtilTest.java new file mode 100644 index 0000000000000..72dfc17332750 --- /dev/null +++ b/packages/SettingsLib/tests/robotests/src/com/android/settingslib/media/DeviceIconUtilTest.java @@ -0,0 +1,150 @@ +/* + * Copyright 2022 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.settingslib.media; + +import static com.google.common.truth.Truth.assertThat; + +import android.media.AudioDeviceInfo; +import android.media.MediaRoute2Info; + +import com.android.settingslib.R; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +@RunWith(RobolectricTestRunner.class) +public class DeviceIconUtilTest { + private final DeviceIconUtil mDeviceIconUtil = new DeviceIconUtil(); + + @Test + public void getIconResIdFromMediaRouteType_usbDevice_isHeadphone() { + assertThat(mDeviceIconUtil.getIconResIdFromMediaRouteType(MediaRoute2Info.TYPE_USB_DEVICE)) + .isEqualTo(R.drawable.ic_headphone); + } + + @Test + public void getIconResIdFromMediaRouteType_usbHeadset_isHeadphone() { + assertThat(mDeviceIconUtil.getIconResIdFromMediaRouteType(MediaRoute2Info.TYPE_USB_HEADSET)) + .isEqualTo(R.drawable.ic_headphone); + } + + @Test + public void getIconResIdFromMediaRouteType_usbAccessory_isHeadphone() { + assertThat( + mDeviceIconUtil.getIconResIdFromMediaRouteType(MediaRoute2Info.TYPE_USB_ACCESSORY)) + .isEqualTo(R.drawable.ic_headphone); + } + + @Test + public void getIconResIdFromMediaRouteType_dock_isHeadphone() { + assertThat(mDeviceIconUtil.getIconResIdFromMediaRouteType(MediaRoute2Info.TYPE_DOCK)) + .isEqualTo(R.drawable.ic_headphone); + } + + @Test + public void getIconResIdFromMediaRouteType_hdmi_isHeadphone() { + assertThat(mDeviceIconUtil.getIconResIdFromMediaRouteType(MediaRoute2Info.TYPE_HDMI)) + .isEqualTo(R.drawable.ic_headphone); + } + + @Test + public void getIconResIdFromMediaRouteType_wiredHeadset_isHeadphone() { + assertThat( + mDeviceIconUtil.getIconResIdFromMediaRouteType(MediaRoute2Info.TYPE_WIRED_HEADSET)) + .isEqualTo(R.drawable.ic_headphone); + } + + @Test + public void getIconResIdFromMediaRouteType_wiredHeadphones_isHeadphone() { + assertThat( + mDeviceIconUtil.getIconResIdFromMediaRouteType(MediaRoute2Info.TYPE_WIRED_HEADPHONES)) + .isEqualTo(R.drawable.ic_headphone); + } + + @Test + public void getIconResIdFromMediaRouteType_builtinSpeaker_isSmartphone() { + assertThat( + mDeviceIconUtil.getIconResIdFromMediaRouteType(MediaRoute2Info.TYPE_BUILTIN_SPEAKER)) + .isEqualTo(R.drawable.ic_smartphone); + } + + @Test + public void getIconResIdFromMediaRouteType_unsupportedType_isSmartphone() { + assertThat(mDeviceIconUtil.getIconResIdFromMediaRouteType(MediaRoute2Info.TYPE_UNKNOWN)) + .isEqualTo(R.drawable.ic_smartphone); + } + + @Test + public void getIconResIdFromAudioDeviceType_usbDevice_isHeadphone() { + assertThat(mDeviceIconUtil.getIconResIdFromAudioDeviceType(AudioDeviceInfo.TYPE_USB_DEVICE)) + .isEqualTo(R.drawable.ic_headphone); + } + + @Test + public void getIconResIdFromAudioDeviceType_usbHeadset_isHeadphone() { + assertThat( + mDeviceIconUtil.getIconResIdFromAudioDeviceType(AudioDeviceInfo.TYPE_USB_HEADSET)) + .isEqualTo(R.drawable.ic_headphone); + } + + @Test + public void getIconResIdFromAudioDeviceType_usbAccessory_isHeadphone() { + assertThat( + mDeviceIconUtil.getIconResIdFromAudioDeviceType(AudioDeviceInfo.TYPE_USB_ACCESSORY)) + .isEqualTo(R.drawable.ic_headphone); + } + + @Test + public void getIconResIdFromAudioDeviceType_dock_isHeadphone() { + assertThat(mDeviceIconUtil.getIconResIdFromAudioDeviceType(AudioDeviceInfo.TYPE_DOCK)) + .isEqualTo(R.drawable.ic_headphone); + } + + @Test + public void getIconResIdFromAudioDeviceType_hdmi_isHeadphone() { + assertThat(mDeviceIconUtil.getIconResIdFromAudioDeviceType(AudioDeviceInfo.TYPE_HDMI)) + .isEqualTo(R.drawable.ic_headphone); + } + + @Test + public void getIconResIdFromAudioDeviceType_wiredHeadset_isHeadphone() { + assertThat( + mDeviceIconUtil.getIconResIdFromAudioDeviceType(AudioDeviceInfo.TYPE_WIRED_HEADSET)) + .isEqualTo(R.drawable.ic_headphone); + } + + @Test + public void getIconResIdFromAudioDeviceType_wiredHeadphones_isHeadphone() { + assertThat( + mDeviceIconUtil.getIconResIdFromAudioDeviceType(AudioDeviceInfo.TYPE_WIRED_HEADPHONES)) + .isEqualTo(R.drawable.ic_headphone); + } + + @Test + public void getIconResIdFromAudioDeviceType_builtinSpeaker_isSmartphone() { + assertThat( + mDeviceIconUtil.getIconResIdFromAudioDeviceType(AudioDeviceInfo.TYPE_BUILTIN_SPEAKER)) + .isEqualTo(R.drawable.ic_smartphone); + } + + @Test + public void getIconResIdFromAudioDeviceType_unsupportedType_isSmartphone() { + assertThat(mDeviceIconUtil.getIconResIdFromAudioDeviceType(AudioDeviceInfo.TYPE_UNKNOWN)) + .isEqualTo(R.drawable.ic_smartphone); + } +} diff --git a/packages/SystemUI/src/com/android/systemui/dagger/SysUIComponent.java b/packages/SystemUI/src/com/android/systemui/dagger/SysUIComponent.java index 73551058dfd35..81fa99a8d22b2 100644 --- a/packages/SystemUI/src/com/android/systemui/dagger/SysUIComponent.java +++ b/packages/SystemUI/src/com/android/systemui/dagger/SysUIComponent.java @@ -25,6 +25,7 @@ import com.android.systemui.SystemUIAppComponentFactory; import com.android.systemui.dagger.qualifiers.PerUser; import com.android.systemui.dump.DumpManager; import com.android.systemui.keyguard.KeyguardSliceProvider; +import com.android.systemui.media.muteawait.MediaMuteAwaitConnectionCli; import com.android.systemui.media.taptotransfer.MediaTttCommandLineHelper; import com.android.systemui.media.taptotransfer.receiver.MediaTttChipControllerReceiver; import com.android.systemui.media.taptotransfer.sender.MediaTttChipControllerSender; @@ -150,6 +151,7 @@ public interface SysUIComponent { getMediaTttChipControllerSender(); getMediaTttChipControllerReceiver(); getMediaTttCommandLineHelper(); + getMediaMuteAwaitConnectionCli(); getUnfoldLatencyTracker().init(); getFoldStateLoggingProvider().ifPresent(FoldStateLoggingProvider::init); getFoldStateLogger().ifPresent(FoldStateLogger::init); @@ -226,6 +228,9 @@ public interface SysUIComponent { /** */ Optional getMediaTttCommandLineHelper(); + /** */ + Optional getMediaMuteAwaitConnectionCli(); + /** * Returns {@link CoreStartable}s that should be started with the application. */ diff --git a/packages/SystemUI/src/com/android/systemui/flags/Flags.java b/packages/SystemUI/src/com/android/systemui/flags/Flags.java index 51101da897486..dd91f6f4969fb 100644 --- a/packages/SystemUI/src/com/android/systemui/flags/Flags.java +++ b/packages/SystemUI/src/com/android/systemui/flags/Flags.java @@ -142,6 +142,7 @@ public class Flags { public static final BooleanFlag MEDIA_TAP_TO_TRANSFER = new BooleanFlag(900, true); public static final BooleanFlag MEDIA_SESSION_ACTIONS = new BooleanFlag(901, true); public static final BooleanFlag MEDIA_SESSION_LAYOUT = new BooleanFlag(902, false); + public static final BooleanFlag MEDIA_MUTE_AWAIT = new BooleanFlag(904, true); // Pay no attention to the reflection behind the curtain. // ========================== Curtain ========================== diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaDeviceManager.kt b/packages/SystemUI/src/com/android/systemui/media/MediaDeviceManager.kt index bed254fe82497..f972da560ed87 100644 --- a/packages/SystemUI/src/com/android/systemui/media/MediaDeviceManager.kt +++ b/packages/SystemUI/src/com/android/systemui/media/MediaDeviceManager.kt @@ -16,6 +16,7 @@ package com.android.systemui.media +import android.graphics.drawable.Drawable import android.media.MediaRouter2Manager import android.media.session.MediaController import androidx.annotation.AnyThread @@ -27,6 +28,8 @@ import com.android.systemui.Dumpable import com.android.systemui.dagger.qualifiers.Background import com.android.systemui.dagger.qualifiers.Main import com.android.systemui.dump.DumpManager +import com.android.systemui.media.muteawait.MediaMuteAwaitConnectionManager +import com.android.systemui.media.muteawait.MediaMuteAwaitConnectionManagerFactory import java.io.FileDescriptor import java.io.PrintWriter import java.util.concurrent.Executor @@ -41,6 +44,7 @@ class MediaDeviceManager @Inject constructor( private val controllerFactory: MediaControllerFactory, private val localMediaManagerFactory: LocalMediaManagerFactory, private val mr2manager: MediaRouter2Manager, + private val muteAwaitConnectionManagerFactory: MediaMuteAwaitConnectionManagerFactory, @Main private val fgExecutor: Executor, @Background private val bgExecutor: Executor, dumpManager: DumpManager @@ -80,8 +84,16 @@ class MediaDeviceManager @Inject constructor( val controller = data.token?.let { controllerFactory.create(it) } - entry = Entry(key, oldKey, controller, - localMediaManagerFactory.create(data.packageName)) + val localMediaManager = localMediaManagerFactory.create(data.packageName) + val muteAwaitConnectionManager = + muteAwaitConnectionManagerFactory.create(localMediaManager) + entry = Entry( + key, + oldKey, + controller, + localMediaManager, + muteAwaitConnectionManager + ) entries[key] = entry entry.start() } @@ -126,7 +138,8 @@ class MediaDeviceManager @Inject constructor( val key: String, val oldKey: String?, val controller: MediaController?, - val localMediaManager: LocalMediaManager + val localMediaManager: LocalMediaManager, + val muteAwaitConnectionManager: MediaMuteAwaitConnectionManager? ) : LocalMediaManager.DeviceCallback, MediaController.Callback() { val token @@ -142,11 +155,15 @@ class MediaDeviceManager @Inject constructor( } } } + // A device that is not yet connected but is expected to connect imminently. Because it's + // expected to connect imminently, it should be displayed as the current device. + private var aboutToConnectDeviceOverride: MediaDeviceData? = null @AnyThread fun start() = bgExecutor.execute { localMediaManager.registerCallback(this) localMediaManager.startScan() + muteAwaitConnectionManager?.startListening() playbackType = controller?.playbackInfo?.playbackType ?: PLAYBACK_TYPE_UNKNOWN controller?.registerCallback(this) updateCurrent() @@ -159,6 +176,7 @@ class MediaDeviceManager @Inject constructor( controller?.unregisterCallback(this) localMediaManager.stopScan() localMediaManager.unregisterCallback(this) + muteAwaitConnectionManager?.stopListening() } fun dump(fd: FileDescriptor, pw: PrintWriter, args: Array) { @@ -197,8 +215,21 @@ class MediaDeviceManager @Inject constructor( } } + override fun onAboutToConnectDeviceChanged(deviceName: String?, deviceIcon: Drawable?) { + aboutToConnectDeviceOverride = if (deviceName == null || deviceIcon == null) { + null + } else { + MediaDeviceData(enabled = true, deviceIcon, deviceName) + } + updateCurrent() + } + @WorkerThread private fun updateCurrent() { + if (aboutToConnectDeviceOverride != null) { + current = aboutToConnectDeviceOverride + return + } val device = localMediaManager.currentConnectedDevice val route = controller?.let { mr2manager.getRoutingSessionForMediaController(it) } diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaFlags.kt b/packages/SystemUI/src/com/android/systemui/media/MediaFlags.kt index b9795f1265fa3..e1467683c986c 100644 --- a/packages/SystemUI/src/com/android/systemui/media/MediaFlags.kt +++ b/packages/SystemUI/src/com/android/systemui/media/MediaFlags.kt @@ -37,4 +37,9 @@ class MediaFlags @Inject constructor(private val featureFlags: FeatureFlags) { return featureFlags.isEnabled(Flags.MEDIA_SESSION_ACTIONS) && featureFlags.isEnabled(Flags.MEDIA_SESSION_LAYOUT) } -} \ No newline at end of file + + /** + * Check whether we support displaying information about mute await connections. + */ + fun areMuteAwaitConnectionsEnabled() = featureFlags.isEnabled(Flags.MEDIA_MUTE_AWAIT) +} diff --git a/packages/SystemUI/src/com/android/systemui/media/dagger/MediaModule.java b/packages/SystemUI/src/com/android/systemui/media/dagger/MediaModule.java index f8b34f9769e42..f9333a3e157d9 100644 --- a/packages/SystemUI/src/com/android/systemui/media/dagger/MediaModule.java +++ b/packages/SystemUI/src/com/android/systemui/media/dagger/MediaModule.java @@ -23,10 +23,12 @@ import android.view.WindowManager; import com.android.systemui.dagger.SysUISingleton; import com.android.systemui.dagger.qualifiers.Main; import com.android.systemui.media.MediaDataManager; +import com.android.systemui.media.MediaFlags; import com.android.systemui.media.MediaHierarchyManager; import com.android.systemui.media.MediaHost; import com.android.systemui.media.MediaHostStatesManager; import com.android.systemui.media.dream.dagger.MediaComplicationComponent; +import com.android.systemui.media.muteawait.MediaMuteAwaitConnectionCli; import com.android.systemui.media.nearby.NearbyMediaDevicesService; import com.android.systemui.media.taptotransfer.MediaTttCommandLineHelper; import com.android.systemui.media.taptotransfer.MediaTttFlags; @@ -140,6 +142,20 @@ public interface MediaModule { new MediaTttCommandLineHelper(commandRegistry, context, mainExecutor)); } + /** */ + @Provides + @SysUISingleton + static Optional providesMediaMuteAwaitConnectionCli( + MediaFlags mediaFlags, + CommandRegistry commandRegistry, + Context context + ) { + if (!mediaFlags.areMuteAwaitConnectionsEnabled()) { + return Optional.empty(); + } + return Optional.of(new MediaMuteAwaitConnectionCli(commandRegistry, context)); + } + /** Inject into NearbyMediaDevicesService. */ @Binds @IntoMap diff --git a/packages/SystemUI/src/com/android/systemui/media/muteawait/MediaMuteAwaitConnectionCli.kt b/packages/SystemUI/src/com/android/systemui/media/muteawait/MediaMuteAwaitConnectionCli.kt new file mode 100644 index 0000000000000..2ae3a631fa5a1 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/media/muteawait/MediaMuteAwaitConnectionCli.kt @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2022 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.muteawait + +import android.content.Context +import android.media.AudioAttributes.USAGE_MEDIA +import android.media.AudioDeviceAttributes +import android.media.AudioManager +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.statusbar.commandline.Command +import com.android.systemui.statusbar.commandline.CommandRegistry +import java.io.PrintWriter +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +/** A command line interface to manually test [MediaMuteAwaitConnectionManager]. */ +@SysUISingleton +class MediaMuteAwaitConnectionCli @Inject constructor( + commandRegistry: CommandRegistry, + private val context: Context +) { + init { + commandRegistry.registerCommand(MEDIA_MUTE_AWAIT_COMMAND) { MuteAwaitCommand() } + } + + inner class MuteAwaitCommand : Command { + override fun execute(pw: PrintWriter, args: List) { + val device = AudioDeviceAttributes( + AudioDeviceAttributes.ROLE_OUTPUT, + /* type= */ Integer.parseInt(args[0]), + ADDRESS, + /* name= */ args[1], + listOf(), + listOf(), + ) + val startOrCancel = args[2] + + val audioManager: AudioManager = + context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + when (startOrCancel) { + START -> + audioManager.muteAwaitConnection( + intArrayOf(USAGE_MEDIA), device, TIMEOUT, TIMEOUT_UNITS + ) + CANCEL -> audioManager.cancelMuteAwaitConnection(device) + else -> pw.println("Must specify `$START` or `$CANCEL`; was $startOrCancel") + } + } + override fun help(pw: PrintWriter) { + pw.println("Usage: adb shell cmd statusbar $MEDIA_MUTE_AWAIT_COMMAND " + + "[type] [name] [$START|$CANCEL]") + } + } +} + +private const val MEDIA_MUTE_AWAIT_COMMAND = "media-mute-await" +private const val START = "start" +private const val CANCEL = "cancel" +private const val ADDRESS = "address" +private const val TIMEOUT = 5L +private val TIMEOUT_UNITS = TimeUnit.SECONDS diff --git a/packages/SystemUI/src/com/android/systemui/media/muteawait/MediaMuteAwaitConnectionManager.kt b/packages/SystemUI/src/com/android/systemui/media/muteawait/MediaMuteAwaitConnectionManager.kt new file mode 100644 index 0000000000000..22bc5572f5a5b --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/media/muteawait/MediaMuteAwaitConnectionManager.kt @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2022 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.muteawait + +import android.content.Context +import android.graphics.drawable.Drawable +import android.media.AudioAttributes.USAGE_MEDIA +import android.media.AudioDeviceAttributes +import android.media.AudioManager +import com.android.settingslib.media.DeviceIconUtil +import com.android.settingslib.media.LocalMediaManager +import com.android.systemui.dagger.qualifiers.Main +import java.util.concurrent.Executor + +/** + * A class responsible for keeping track of devices that have muted audio playback until the device + * is connected. The device connection expected to happen imminently, so we'd like to display the + * device name in the media player. When the about-to-connect device changes, [localMediaManager] + * will be notified. + * + * See [AudioManager.muteAwaitConnection] and b/206614671 for more details. + * + * TODO(b/206614671): Add logging. + */ +class MediaMuteAwaitConnectionManager constructor( + @Main private val mainExecutor: Executor, + private val localMediaManager: LocalMediaManager, + private val context: Context, + private val deviceIconUtil: DeviceIconUtil +) { + var currentMutedDevice: AudioDeviceAttributes? = null + + val audioManager: AudioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + + val muteAwaitConnectionChangeListener = object : AudioManager.MuteAwaitConnectionCallback() { + override fun onMutedUntilConnection(device: AudioDeviceAttributes, mutedUsages: IntArray) { + if (USAGE_MEDIA in mutedUsages) { + // There should only be one device that's mutedUntilConnection at a time, so we can + // safely override any previous value. + currentMutedDevice = device + localMediaManager.dispatchAboutToConnectDeviceChanged(device.name, device.getIcon()) + } + } + + override fun onUnmutedEvent( + @UnmuteEvent unmuteEvent: Int, + device: AudioDeviceAttributes, + mutedUsages: IntArray + ) { + if (currentMutedDevice == device && USAGE_MEDIA in mutedUsages) { + currentMutedDevice = null + localMediaManager.dispatchAboutToConnectDeviceChanged(null, null) + } + } + } + + /** Start listening for mute await events. */ + fun startListening() { + audioManager.registerMuteAwaitConnectionCallback( + mainExecutor, muteAwaitConnectionChangeListener + ) + val currentDevice = audioManager.mutingExpectedDevice + if (currentDevice != null) { + currentMutedDevice = currentDevice + localMediaManager.dispatchAboutToConnectDeviceChanged( + currentDevice.name, currentDevice.getIcon() + ) + } + } + + /** Stop listening for mute await events. */ + fun stopListening() { + audioManager.unregisterMuteAwaitConnectionCallback(muteAwaitConnectionChangeListener) + } + + private fun AudioDeviceAttributes.getIcon(): Drawable { + return deviceIconUtil.getIconFromAudioDeviceType(this.type, context) + } +} diff --git a/packages/SystemUI/src/com/android/systemui/media/muteawait/MediaMuteAwaitConnectionManagerFactory.kt b/packages/SystemUI/src/com/android/systemui/media/muteawait/MediaMuteAwaitConnectionManagerFactory.kt new file mode 100644 index 0000000000000..118b2dd4dc90d --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/media/muteawait/MediaMuteAwaitConnectionManagerFactory.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2022 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.muteawait + +import android.content.Context +import com.android.settingslib.media.DeviceIconUtil +import com.android.settingslib.media.LocalMediaManager +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.dagger.qualifiers.Main +import com.android.systemui.media.MediaFlags +import java.util.concurrent.Executor +import javax.inject.Inject + +/** Factory class to create [MediaMuteAwaitConnectionManager] instances. */ +@SysUISingleton +class MediaMuteAwaitConnectionManagerFactory @Inject constructor( + private val mediaFlags: MediaFlags, + private val context: Context, + @Main private val mainExecutor: Executor +) { + private val deviceIconUtil = DeviceIconUtil() + + /** Creates a [MediaMuteAwaitConnectionManager]. */ + fun create(localMediaManager: LocalMediaManager): MediaMuteAwaitConnectionManager? { + if (!mediaFlags.areMuteAwaitConnectionsEnabled()) { + return null + } + return MediaMuteAwaitConnectionManager( + mainExecutor, localMediaManager, context, deviceIconUtil + ) + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/MediaDeviceManagerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/MediaDeviceManagerTest.kt index 3d59497fd978e..64b5b86f47f66 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/media/MediaDeviceManagerTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/media/MediaDeviceManagerTest.kt @@ -30,6 +30,8 @@ import com.android.settingslib.media.LocalMediaManager import com.android.settingslib.media.MediaDevice import com.android.systemui.SysuiTestCase import com.android.systemui.dump.DumpManager +import com.android.systemui.media.muteawait.MediaMuteAwaitConnectionManager +import com.android.systemui.media.muteawait.MediaMuteAwaitConnectionManagerFactory import com.android.systemui.util.concurrency.FakeExecutor import com.android.systemui.util.time.FakeSystemClock @@ -44,6 +46,7 @@ import org.mockito.ArgumentCaptor import org.mockito.Mock import org.mockito.Mockito import org.mockito.Mockito.any +import org.mockito.Mockito.mock import org.mockito.Mockito.never import org.mockito.Mockito.reset import org.mockito.Mockito.verify @@ -71,6 +74,8 @@ public class MediaDeviceManagerTest : SysuiTestCase() { @Mock private lateinit var lmmFactory: LocalMediaManagerFactory @Mock private lateinit var lmm: LocalMediaManager @Mock private lateinit var mr2: MediaRouter2Manager + @Mock private lateinit var muteAwaitFactory: MediaMuteAwaitConnectionManagerFactory + @Mock private lateinit var muteAwaitManager: MediaMuteAwaitConnectionManager private lateinit var fakeFgExecutor: FakeExecutor private lateinit var fakeBgExecutor: FakeExecutor @Mock private lateinit var dumpster: DumpManager @@ -88,14 +93,22 @@ public class MediaDeviceManagerTest : SysuiTestCase() { fun setUp() { fakeFgExecutor = FakeExecutor(FakeSystemClock()) fakeBgExecutor = FakeExecutor(FakeSystemClock()) - manager = MediaDeviceManager(controllerFactory, lmmFactory, mr2, fakeFgExecutor, - fakeBgExecutor, dumpster) + manager = MediaDeviceManager( + controllerFactory, + lmmFactory, + mr2, + muteAwaitFactory, + fakeFgExecutor, + fakeBgExecutor, + dumpster + ) manager.addListener(listener) // Configure mocks. whenever(device.name).thenReturn(DEVICE_NAME) whenever(device.iconWithoutBackground).thenReturn(icon) whenever(lmmFactory.create(PACKAGE)).thenReturn(lmm) + whenever(muteAwaitFactory.create(lmm)).thenReturn(muteAwaitManager) whenever(lmm.getCurrentConnectedDevice()).thenReturn(device) whenever(mr2.getRoutingSessionForMediaController(any())).thenReturn(route) @@ -146,6 +159,7 @@ public class MediaDeviceManagerTest : SysuiTestCase() { manager.onMediaDataRemoved(KEY) fakeBgExecutor.runAllReady() verify(lmm).unregisterCallback(any()) + verify(muteAwaitManager).stopListening() } @Test @@ -169,6 +183,7 @@ public class MediaDeviceManagerTest : SysuiTestCase() { fakeFgExecutor.runAllReady() // THEN the listener for the old key should removed. verify(lmm).unregisterCallback(any()) + verify(muteAwaitManager).stopListening() // AND a new device event emitted val data = captureDeviceData(KEY, KEY_OLD) assertThat(data.enabled).isTrue() @@ -240,6 +255,7 @@ public class MediaDeviceManagerTest : SysuiTestCase() { manager.onMediaDataLoaded(KEY, null, mediaData) fakeBgExecutor.runAllReady() val deviceCallback = captureCallback() + verify(muteAwaitManager).startListening() // WHEN the device list changes deviceCallback.onDeviceListUpdate(mutableListOf(device)) assertThat(fakeBgExecutor.runAllReady()).isEqualTo(1) @@ -267,6 +283,51 @@ public class MediaDeviceManagerTest : SysuiTestCase() { assertThat(data.icon).isEqualTo(icon) } + @Test + fun onAboutToConnectDeviceChangedWithNonNullParams() { + manager.onMediaDataLoaded(KEY, null, mediaData) + // Run and reset the executors and listeners so we only focus on new events. + fakeBgExecutor.runAllReady() + fakeFgExecutor.runAllReady() + reset(listener) + + val deviceCallback = captureCallback() + // WHEN the about-to-connect device changes to non-null + val name = "AboutToConnectDeviceName" + val mockIcon = mock(Drawable::class.java) + deviceCallback.onAboutToConnectDeviceChanged(name, mockIcon) + assertThat(fakeFgExecutor.runAllReady()).isEqualTo(1) + // THEN the about-to-connect device is returned + val data = captureDeviceData(KEY) + assertThat(data.enabled).isTrue() + assertThat(data.name).isEqualTo(name) + assertThat(data.icon).isEqualTo(mockIcon) + } + + @Test + fun onAboutToConnectDeviceChangedWithNullParams() { + manager.onMediaDataLoaded(KEY, null, mediaData) + fakeBgExecutor.runAllReady() + val deviceCallback = captureCallback() + // First set a non-null about-to-connect device + deviceCallback.onAboutToConnectDeviceChanged( + "AboutToConnectDeviceName", mock(Drawable::class.java) + ) + // Run and reset the executors and listeners so we only focus on new events. + fakeBgExecutor.runAllReady() + fakeFgExecutor.runAllReady() + reset(listener) + + // WHEN the about-to-connect device changes to null + deviceCallback.onAboutToConnectDeviceChanged(null, null) + assertThat(fakeFgExecutor.runAllReady()).isEqualTo(1) + // THEN the normal device is returned + val data = captureDeviceData(KEY) + assertThat(data.enabled).isTrue() + assertThat(data.name).isEqualTo(DEVICE_NAME) + assertThat(data.icon).isEqualTo(icon) + } + @Test fun listenerReceivesKeyRemoved() { manager.onMediaDataLoaded(KEY, null, mediaData) diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/muteawait/MediaMuteAwaitConnectionManagerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/muteawait/MediaMuteAwaitConnectionManagerTest.kt new file mode 100644 index 0000000000000..88c451499d21b --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/media/muteawait/MediaMuteAwaitConnectionManagerTest.kt @@ -0,0 +1,202 @@ +/* + * Copyright (C) 2022 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.muteawait + +import android.content.Context +import android.graphics.drawable.Drawable +import android.media.AudioAttributes.USAGE_MEDIA +import android.media.AudioAttributes.USAGE_UNKNOWN +import android.media.AudioDeviceAttributes +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.media.AudioManager.MuteAwaitConnectionCallback.EVENT_CONNECTION +import android.test.suitebuilder.annotation.SmallTest +import com.android.settingslib.media.DeviceIconUtil +import com.android.settingslib.media.LocalMediaManager +import com.android.systemui.R +import com.android.systemui.SysuiTestCase +import com.android.systemui.util.concurrency.FakeExecutor +import com.android.systemui.util.mockito.any +import com.android.systemui.util.mockito.eq +import com.android.systemui.util.time.FakeSystemClock +import org.junit.Before +import org.junit.Test +import org.mockito.ArgumentCaptor +import org.mockito.Mock +import org.mockito.Mockito.never +import org.mockito.Mockito.reset +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` as whenever +import org.mockito.MockitoAnnotations + + +@SmallTest +class MediaMuteAwaitConnectionManagerTest : SysuiTestCase() { + private lateinit var muteAwaitConnectionManager: MediaMuteAwaitConnectionManager + @Mock + private lateinit var audioManager: AudioManager + @Mock + private lateinit var deviceIconUtil: DeviceIconUtil + @Mock + private lateinit var localMediaManager: LocalMediaManager + private lateinit var icon: Drawable + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + context.addMockSystemService(Context.AUDIO_SERVICE, audioManager) + icon = context.getDrawable(R.drawable.ic_cake)!! + whenever(deviceIconUtil.getIconFromAudioDeviceType(any(), any())).thenReturn(icon) + + muteAwaitConnectionManager = MediaMuteAwaitConnectionManager( + FakeExecutor(FakeSystemClock()), + localMediaManager, + context, + deviceIconUtil + ) + } + + @Test + fun constructor_audioManagerCallbackNotRegistered() { + verify(audioManager, never()).registerMuteAwaitConnectionCallback(any(), any()) + } + + @Test + fun startListening_audioManagerCallbackRegistered() { + muteAwaitConnectionManager.startListening() + + verify(audioManager).registerMuteAwaitConnectionCallback(any(), any()) + } + + @Test + fun stopListening_audioManagerCallbackUnregistered() { + muteAwaitConnectionManager.stopListening() + + verify(audioManager).unregisterMuteAwaitConnectionCallback(any()) + } + + @Test + fun startListening_audioManagerHasNoMuteAwaitDevice_localMediaMangerNotNotified() { + whenever(audioManager.mutingExpectedDevice).thenReturn(null) + + muteAwaitConnectionManager.startListening() + + verify(localMediaManager, never()).dispatchAboutToConnectDeviceChanged(any(), any()) + } + + @Test + fun startListening_audioManagerHasMuteAwaitDevice_localMediaMangerNotified() { + whenever(audioManager.mutingExpectedDevice).thenReturn(DEVICE) + + muteAwaitConnectionManager.startListening() + + verify(localMediaManager).dispatchAboutToConnectDeviceChanged(eq(DEVICE_NAME), eq(icon)) + } + + @Test + fun onMutedUntilConnection_notUsageMedia_localMediaManagerNotNotified() { + muteAwaitConnectionManager.startListening() + val muteAwaitListener = getMuteAwaitListener() + + muteAwaitListener.onMutedUntilConnection(DEVICE, intArrayOf(USAGE_UNKNOWN)) + + verify(localMediaManager, never()).dispatchAboutToConnectDeviceChanged(any(), any()) + } + + @Test + fun onMutedUntilConnection_isUsageMedia_localMediaManagerNotified() { + muteAwaitConnectionManager.startListening() + val muteAwaitListener = getMuteAwaitListener() + + + muteAwaitListener.onMutedUntilConnection(DEVICE, intArrayOf(USAGE_MEDIA)) + + verify(localMediaManager).dispatchAboutToConnectDeviceChanged(eq(DEVICE_NAME), eq(icon)) + } + + @Test + fun onUnmutedEvent_noDeviceMutedBefore_localMediaManagerNotNotified() { + muteAwaitConnectionManager.startListening() + val muteAwaitListener = getMuteAwaitListener() + + muteAwaitListener.onUnmutedEvent(EVENT_CONNECTION, DEVICE, intArrayOf(USAGE_MEDIA)) + + verify(localMediaManager, never()).dispatchAboutToConnectDeviceChanged(any(), any()) + } + + @Test + fun onUnmutedEvent_notSameDevice_localMediaManagerNotNotified() { + muteAwaitConnectionManager.startListening() + val muteAwaitListener = getMuteAwaitListener() + muteAwaitListener.onMutedUntilConnection(DEVICE, intArrayOf(USAGE_MEDIA)) + reset(localMediaManager) + + val otherDevice = AudioDeviceAttributes( + AudioDeviceAttributes.ROLE_OUTPUT, + AudioDeviceInfo.TYPE_USB_HEADSET, + "address", + "DifferentName", + listOf(), + listOf(), + ) + muteAwaitListener.onUnmutedEvent(EVENT_CONNECTION, otherDevice, intArrayOf(USAGE_MEDIA)) + + verify(localMediaManager, never()).dispatchAboutToConnectDeviceChanged(any(), any()) + } + + @Test + fun onUnmutedEvent_notUsageMedia_localMediaManagerNotNotified() { + muteAwaitConnectionManager.startListening() + val muteAwaitListener = getMuteAwaitListener() + muteAwaitListener.onMutedUntilConnection(DEVICE, intArrayOf(USAGE_MEDIA)) + reset(localMediaManager) + + muteAwaitListener.onUnmutedEvent(EVENT_CONNECTION, DEVICE, intArrayOf(USAGE_UNKNOWN)) + + verify(localMediaManager, never()).dispatchAboutToConnectDeviceChanged(any(), any()) + } + + @Test + fun onUnmutedEvent_sameDeviceAndUsageMedia_localMediaManagerNotified() { + muteAwaitConnectionManager.startListening() + val muteAwaitListener = getMuteAwaitListener() + muteAwaitListener.onMutedUntilConnection(DEVICE, intArrayOf(USAGE_MEDIA)) + reset(localMediaManager) + + muteAwaitListener.onUnmutedEvent(EVENT_CONNECTION, DEVICE, intArrayOf(USAGE_MEDIA)) + + verify(localMediaManager).dispatchAboutToConnectDeviceChanged(eq(null), eq(null)) + } + + private fun getMuteAwaitListener(): AudioManager.MuteAwaitConnectionCallback { + val listenerCaptor = ArgumentCaptor.forClass( + AudioManager.MuteAwaitConnectionCallback::class.java + ) + verify(audioManager).registerMuteAwaitConnectionCallback(any(), listenerCaptor.capture()) + return listenerCaptor.value!! + } +} + +private const val DEVICE_NAME = "DeviceName" +private val DEVICE = AudioDeviceAttributes( + AudioDeviceAttributes.ROLE_OUTPUT, + AudioDeviceInfo.TYPE_USB_HEADSET, + "address", + DEVICE_NAME, + listOf(), + listOf(), +)