From 4b13fb36553566f0e9558b72e579a8074521fbe3 Mon Sep 17 00:00:00 2001 From: Yiyi Shen Date: Tue, 25 Feb 2025 16:44:22 +0800 Subject: [PATCH 1/2] [Audiosharing] Add activity skeleton to handle new connected sink Test: atest Flag: com.android.settingslib.flags.promote_audio_sharing_for_second_auto_connected_lea_device Bug: 395786392 Change-Id: Iff1378af8d9a8aa50ccd02b92630541acb24a1ad --- ...luetooth_le_audio_sharing_join_handler.xml | 19 ++++ .../AudioSharingJoinHandlerActivity.java | 33 +++++++ .../AudioSharingJoinHandlerController.java | 55 +++++++++++ ...ioSharingJoinHandlerDashboardFragment.java | 48 ++++++++++ .../AudioSharingJoinHandlerActivityTest.java | 54 +++++++++++ ...AudioSharingJoinHandlerControllerTest.java | 94 +++++++++++++++++++ ...aringJoinHandlerDashboardFragmentTest.java | 52 ++++++++++ 7 files changed, 355 insertions(+) create mode 100644 res/xml/bluetooth_le_audio_sharing_join_handler.xml create mode 100644 src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerActivity.java create mode 100644 src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerController.java create mode 100644 src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerDashboardFragment.java create mode 100644 tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerActivityTest.java create mode 100644 tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerControllerTest.java create mode 100644 tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerDashboardFragmentTest.java diff --git a/res/xml/bluetooth_le_audio_sharing_join_handler.xml b/res/xml/bluetooth_le_audio_sharing_join_handler.xml new file mode 100644 index 00000000000..eda29a6e62d --- /dev/null +++ b/res/xml/bluetooth_le_audio_sharing_join_handler.xml @@ -0,0 +1,19 @@ + + + + \ No newline at end of file diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerActivity.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerActivity.java new file mode 100644 index 00000000000..0e5297d09b7 --- /dev/null +++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerActivity.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settings.connecteddevice.audiosharing; + +import com.android.settings.SettingsActivity; + +public class AudioSharingJoinHandlerActivity extends SettingsActivity { + private static final String TAG = "AudioSharingJoinHandlerActivity"; + + @Override + protected boolean isToolbarEnabled() { + return false; + } + + @Override + protected boolean isValidFragment(String fragmentName) { + return AudioSharingJoinHandlerDashboardFragment.class.getName().equals(fragmentName); + } +} diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerController.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerController.java new file mode 100644 index 00000000000..376675fa772 --- /dev/null +++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerController.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settings.connecteddevice.audiosharing; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.lifecycle.DefaultLifecycleObserver; + +import com.android.settings.core.BasePreferenceController; +import com.android.settingslib.bluetooth.BluetoothUtils; +import com.android.settingslib.flags.Flags; + +public class AudioSharingJoinHandlerController extends BasePreferenceController + implements DefaultLifecycleObserver { + private static final String TAG = "AudioSharingJoinHandlerCtrl"; + private static final String KEY = "audio_sharing_join_handler"; + + public AudioSharingJoinHandlerController(@NonNull Context context, + @NonNull String preferenceKey) { + super(context, preferenceKey); + } + + @Override + public int getAvailabilityStatus() { + return (Flags.promoteAudioSharingForSecondAutoConnectedLeaDevice() + && BluetoothUtils.isAudioSharingUIAvailable(mContext)) + ? AVAILABLE_UNSEARCHABLE + : UNSUPPORTED_ON_DEVICE; + } + + @Override + public String getPreferenceKey() { + return KEY; + } + + @Override + public int getSliceHighlightMenuRes() { + return 0; + } +} diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerDashboardFragment.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerDashboardFragment.java new file mode 100644 index 00000000000..6042e5558d2 --- /dev/null +++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerDashboardFragment.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settings.connecteddevice.audiosharing; + +import android.content.Context; + +import com.android.settings.R; +import com.android.settings.dashboard.DashboardFragment; + +public class AudioSharingJoinHandlerDashboardFragment extends DashboardFragment { + private static final String TAG = "AudioSharingJoinHandlerFrag"; + + @Override + public int getMetricsCategory() { + // TODO: use real enum + return 0; + } + + @Override + protected int getPreferenceScreenResId() { + return R.xml.bluetooth_le_audio_sharing_join_handler; + } + + @Override + protected String getLogTag() { + return TAG; + } + + @Override + public void onAttach(Context context) { + super.onAttach(context); + use(AudioSharingJoinHandlerController.class); + } +} diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerActivityTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerActivityTest.java new file mode 100644 index 00000000000..d72a9e06be7 --- /dev/null +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerActivityTest.java @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settings.connecteddevice.audiosharing; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.Mockito.spy; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; + +@RunWith(RobolectricTestRunner.class) +public class AudioSharingJoinHandlerActivityTest { + @Rule + public final MockitoRule mMockitoRule = MockitoJUnit.rule(); + + private AudioSharingJoinHandlerActivity mActivity; + + @Before + public void setUp() { + mActivity = spy(Robolectric.buildActivity(AudioSharingJoinHandlerActivity.class).get()); + } + + @Test + public void isValidFragment_returnsTrue() { + assertThat(mActivity.isValidFragment( + AudioSharingJoinHandlerDashboardFragment.class.getName())).isTrue(); + } + + @Test + public void isValidFragment_returnsFalse() { + assertThat(mActivity.isValidFragment("")).isFalse(); + } +} diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerControllerTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerControllerTest.java new file mode 100644 index 00000000000..51365e6d977 --- /dev/null +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerControllerTest.java @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settings.connecteddevice.audiosharing; + +import static com.android.settings.core.BasePreferenceController.AVAILABLE_UNSEARCHABLE; +import static com.android.settings.core.BasePreferenceController.UNSUPPORTED_ON_DEVICE; + +import static com.google.common.truth.Truth.assertThat; + +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothStatusCodes; +import android.content.Context; +import android.platform.test.annotations.DisableFlags; +import android.platform.test.annotations.EnableFlags; +import android.platform.test.flag.junit.SetFlagsRule; + +import androidx.test.core.app.ApplicationProvider; + +import com.android.settings.testutils.shadow.ShadowBluetoothAdapter; +import com.android.settingslib.flags.Flags; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Spy; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.shadow.api.Shadow; + +@RunWith(RobolectricTestRunner.class) +@Config(shadows = ShadowBluetoothAdapter.class) +public class AudioSharingJoinHandlerControllerTest { + private static final String PREF_KEY = "audio_sharing_join_handler"; + + @Rule + public final MockitoRule mMockitoRule = MockitoJUnit.rule(); + @Rule + public final SetFlagsRule mSetFlagsRule = new SetFlagsRule(); + @Spy + Context mContext = ApplicationProvider.getApplicationContext(); + private AudioSharingJoinHandlerController mController; + + @Before + public void setUp() { + ShadowBluetoothAdapter shadowBluetoothAdapter = + Shadow.extract(BluetoothAdapter.getDefaultAdapter()); + shadowBluetoothAdapter.setEnabled(true); + shadowBluetoothAdapter.setIsLeAudioBroadcastSourceSupported( + BluetoothStatusCodes.FEATURE_SUPPORTED); + shadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported( + BluetoothStatusCodes.FEATURE_SUPPORTED); + mController = new AudioSharingJoinHandlerController(mContext, PREF_KEY); + } + + @Test + @EnableFlags({Flags.FLAG_PROMOTE_AUDIO_SHARING_FOR_SECOND_AUTO_CONNECTED_LEA_DEVICE, + Flags.FLAG_ENABLE_LE_AUDIO_SHARING}) + public void getAvailabilityStatus_flagOn() { + assertThat(mController.getAvailabilityStatus()).isEqualTo(AVAILABLE_UNSEARCHABLE); + } + + @Test + @DisableFlags(Flags.FLAG_PROMOTE_AUDIO_SHARING_FOR_SECOND_AUTO_CONNECTED_LEA_DEVICE) + public void getAvailabilityStatus_flagOff() { + assertThat(mController.getAvailabilityStatus()).isEqualTo(UNSUPPORTED_ON_DEVICE); + } + + @Test + public void getPreferenceKey_returnsCorrectKey() { + assertThat(mController.getPreferenceKey()).isEqualTo(PREF_KEY); + } + + @Test + public void getSliceHighlightMenuRes_returnsZero() { + assertThat(mController.getSliceHighlightMenuRes()).isEqualTo(0); + } +} diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerDashboardFragmentTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerDashboardFragmentTest.java new file mode 100644 index 00000000000..3fd27b10b11 --- /dev/null +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerDashboardFragmentTest.java @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settings.connecteddevice.audiosharing; + +import static com.google.common.truth.Truth.assertThat; + +import com.android.settings.R; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +@RunWith(RobolectricTestRunner.class) +public class AudioSharingJoinHandlerDashboardFragmentTest { + private AudioSharingJoinHandlerDashboardFragment mFragment; + + @Before + public void setUp() { + mFragment = new AudioSharingJoinHandlerDashboardFragment(); + } + + @Test + public void getPreferenceScreenResId_returnsCorrectXml() { + assertThat(mFragment.getPreferenceScreenResId()) + .isEqualTo(R.xml.bluetooth_le_audio_sharing_join_handler); + } + + @Test + public void getLogTag_returnsCorrectTag() { + assertThat(mFragment.getLogTag()).isEqualTo("AudioSharingJoinHandlerFrag"); + } + + @Test + public void getMetricsCategory_returnsCorrectCategory() { + assertThat(mFragment.getMetricsCategory()).isEqualTo(0); + } +} From 51c5c2e4738c931538cef7e0761d98448a4dcfca Mon Sep 17 00:00:00 2001 From: Yiyi Shen Date: Wed, 26 Feb 2025 17:12:45 +0800 Subject: [PATCH 2/2] [Audiosharing] Handle device connected in handler activity Test: atest Flag: com.android.settingslib.flags.promote_audio_sharing_for_second_auto_connected_lea_device Bug: 395786392 Change-Id: Icea9ad31d3115bf90557e8c2795987cd1d0c824b --- .../AudioSharingJoinHandlerActivity.java | 13 + .../AudioSharingJoinHandlerController.java | 221 ++++++++++++++++- ...ioSharingJoinHandlerDashboardFragment.java | 9 +- .../AudioSharingJoinHandlerActivityTest.java | 40 +++ ...AudioSharingJoinHandlerControllerTest.java | 234 +++++++++++++++++- 5 files changed, 514 insertions(+), 3 deletions(-) diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerActivity.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerActivity.java index 0e5297d09b7..82c099d6cdc 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerActivity.java +++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerActivity.java @@ -16,11 +16,24 @@ package com.android.settings.connecteddevice.audiosharing; +import android.os.Bundle; + import com.android.settings.SettingsActivity; +import com.android.settingslib.bluetooth.BluetoothUtils; +import com.android.settingslib.flags.Flags; public class AudioSharingJoinHandlerActivity extends SettingsActivity { private static final String TAG = "AudioSharingJoinHandlerActivity"; + @Override + protected void onCreate(Bundle savedState) { + super.onCreate(savedState); + if (!Flags.promoteAudioSharingForSecondAutoConnectedLeaDevice() + || !BluetoothUtils.isAudioSharingUIAvailable(this)) { + finish(); + } + } + @Override protected boolean isToolbarEnabled() { return false; diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerController.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerController.java index 376675fa772..6b664788d64 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerController.java +++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerController.java @@ -16,25 +16,183 @@ package com.android.settings.connecteddevice.audiosharing; +import static com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast.EXTRA_BLUETOOTH_DEVICE; + +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothLeBroadcastAssistant; +import android.bluetooth.BluetoothLeBroadcastMetadata; +import android.bluetooth.BluetoothLeBroadcastReceiveState; +import android.bluetooth.BluetoothProfile; import android.content.Context; +import android.content.Intent; +import android.util.Log; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.annotation.WorkerThread; import androidx.lifecycle.DefaultLifecycleObserver; +import androidx.lifecycle.LifecycleOwner; +import androidx.preference.PreferenceScreen; +import com.android.settings.bluetooth.Utils; import com.android.settings.core.BasePreferenceController; +import com.android.settings.dashboard.DashboardFragment; +import com.android.settingslib.bluetooth.BluetoothCallback; +import com.android.settingslib.bluetooth.BluetoothEventManager; import com.android.settingslib.bluetooth.BluetoothUtils; +import com.android.settingslib.bluetooth.CachedBluetoothDevice; +import com.android.settingslib.bluetooth.CachedBluetoothDeviceManager; +import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant; +import com.android.settingslib.bluetooth.LocalBluetoothManager; import com.android.settingslib.flags.Flags; +import com.android.settingslib.utils.ThreadUtils; + +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; public class AudioSharingJoinHandlerController extends BasePreferenceController - implements DefaultLifecycleObserver { + implements DefaultLifecycleObserver, BluetoothCallback { private static final String TAG = "AudioSharingJoinHandlerCtrl"; private static final String KEY = "audio_sharing_join_handler"; + @Nullable private final LocalBluetoothManager mBtManager; + @Nullable private final BluetoothEventManager mEventManager; + @Nullable private final CachedBluetoothDeviceManager mDeviceManager; + @Nullable private final LocalBluetoothLeBroadcastAssistant mAssistant; + private final Executor mExecutor; + @Nullable private DashboardFragment mFragment; + @Nullable private AudioSharingDialogHandler mDialogHandler; + @VisibleForTesting + BluetoothLeBroadcastAssistant.Callback mAssistantCallback = + new BluetoothLeBroadcastAssistant.Callback() { + @Override + public void onSearchStarted(int reason) { + } + + @Override + public void onSearchStartFailed(int reason) { + } + + @Override + public void onSearchStopped(int reason) { + } + + @Override + public void onSearchStopFailed(int reason) { + } + + @Override + public void onSourceFound(@NonNull BluetoothLeBroadcastMetadata source) { + } + + @Override + public void onSourceAdded( + @NonNull BluetoothDevice sink, int sourceId, int reason) { + Log.d(TAG, "onSourceAdded: dismiss stale dialog."); + if (mDeviceManager != null && mDialogHandler != null) { + CachedBluetoothDevice cachedDevice = mDeviceManager.findDevice(sink); + if (cachedDevice != null) { + mDialogHandler.closeOpeningDialogsForLeaDevice(cachedDevice); + } + } + } + + @Override + public void onSourceAddFailed( + @NonNull BluetoothDevice sink, + @NonNull BluetoothLeBroadcastMetadata source, + int reason) { + } + + @Override + public void onSourceModified( + @NonNull BluetoothDevice sink, int sourceId, int reason) { + } + + @Override + public void onSourceModifyFailed( + @NonNull BluetoothDevice sink, int sourceId, int reason) { + } + + @Override + public void onSourceRemoved( + @NonNull BluetoothDevice sink, int sourceId, int reason) { + } + + @Override + public void onSourceRemoveFailed( + @NonNull BluetoothDevice sink, int sourceId, int reason) { + } + + @Override + public void onReceiveStateChanged( + @NonNull BluetoothDevice sink, + int sourceId, + @NonNull BluetoothLeBroadcastReceiveState state) { + } + }; + public AudioSharingJoinHandlerController(@NonNull Context context, @NonNull String preferenceKey) { super(context, preferenceKey); + mBtManager = Utils.getLocalBtManager(mContext); + mEventManager = mBtManager == null ? null : mBtManager.getEventManager(); + mDeviceManager = mBtManager == null ? null : mBtManager.getCachedDeviceManager(); + mAssistant = mBtManager == null ? null + : mBtManager.getProfileManager().getLeAudioBroadcastAssistantProfile(); + mExecutor = Executors.newSingleThreadExecutor(); } + /** + * Initialize the controller. + * + * @param fragment The fragment to provide the context and metrics category for {@link + * AudioSharingBluetoothDeviceUpdater} and provide the host for dialogs. + */ + public void init(@NonNull DashboardFragment fragment) { + mFragment = fragment; + mDialogHandler = new AudioSharingDialogHandler(mContext, fragment); + } + + @Override + public void onStart(@NonNull LifecycleOwner owner) { + var unused = ThreadUtils.postOnBackgroundThread(() -> { + if (!isAvailable()) { + Log.d(TAG, "Skip onStart(), feature is not supported."); + return; + } + if (mEventManager == null || mDialogHandler == null || mAssistant == null) { + Log.d(TAG, "Skip onStart(), profile is not ready."); + return; + } + Log.d(TAG, "onStart() Register callbacks."); + mEventManager.registerCallback(this); + mAssistant.registerServiceCallBack(mExecutor, mAssistantCallback); + mDialogHandler.registerCallbacks(mExecutor); + }); + } + + @Override + public void onStop(@NonNull LifecycleOwner owner) { + var unused = ThreadUtils.postOnBackgroundThread(() -> { + if (!isAvailable()) { + Log.d(TAG, "Skip onStop(), feature is not supported."); + return; + } + if (mEventManager == null || mDialogHandler == null || mAssistant == null) { + Log.d(TAG, "Skip onStop(), profile is not ready."); + return; + } + Log.d(TAG, "onStop() Unregister callbacks."); + mEventManager.unregisterCallback(this); + mAssistant.unregisterServiceCallBack(mAssistantCallback); + mDialogHandler.unregisterCallbacks(); + }); + } + + @Override public int getAvailabilityStatus() { return (Flags.promoteAudioSharingForSecondAutoConnectedLeaDevice() @@ -52,4 +210,65 @@ public class AudioSharingJoinHandlerController extends BasePreferenceController public int getSliceHighlightMenuRes() { return 0; } + + @Override + public void displayPreference(@NonNull PreferenceScreen screen) { + super.displayPreference(screen); + if (mFragment == null + || mFragment.getActivity() == null + || mFragment.getActivity().getIntent() == null) { + Log.d(TAG, "Skip handleDeviceConnectedFromIntent, fragment intent is null"); + return; + } + Intent intent = mFragment.getActivity().getIntent(); + var unused = + ThreadUtils.postOnBackgroundThread(() -> handleDeviceConnectedFromIntent(intent)); + } + + @Override + public void onProfileConnectionStateChanged( + @NonNull CachedBluetoothDevice cachedDevice, + @ConnectionState int state, + int bluetoothProfile) { + if (mDialogHandler == null || mFragment == null) { + Log.d(TAG, "Ignore onProfileConnectionStateChanged, not init correctly"); + return; + } + // Close related dialogs if the BT remote device is disconnected. + if (state == BluetoothAdapter.STATE_DISCONNECTED) { + boolean isLeAudioSupported = BluetoothUtils.isLeAudioSupported(cachedDevice); + if (isLeAudioSupported + && bluetoothProfile == BluetoothProfile.LE_AUDIO_BROADCAST_ASSISTANT) { + mDialogHandler.closeOpeningDialogsForLeaDevice(cachedDevice); + } else if (!isLeAudioSupported && !cachedDevice.isConnected()) { + mDialogHandler.closeOpeningDialogsForNonLeaDevice(cachedDevice); + } + } + } + + /** Handle just connected device via intent. */ + @WorkerThread + public void handleDeviceConnectedFromIntent(@NonNull Intent intent) { + BluetoothDevice device = intent.getParcelableExtra(EXTRA_BLUETOOTH_DEVICE, + BluetoothDevice.class); + CachedBluetoothDevice cachedDevice = + (device == null || mDeviceManager == null) + ? null + : mDeviceManager.findDevice(device); + if (cachedDevice == null) { + Log.d(TAG, "Skip handleDeviceConnectedFromIntent, device is null"); + return; + } + if (mDialogHandler == null) { + Log.d(TAG, "Skip handleDeviceConnectedFromIntent, handler is null"); + return; + } + Log.d(TAG, "handleDeviceConnectedFromIntent, device = " + device.getAnonymizedAddress()); + mDialogHandler.handleDeviceConnected(cachedDevice, /* userTriggered= */ false); + } + + @VisibleForTesting + void setDialogHandler(@Nullable AudioSharingDialogHandler dialogHandler) { + mDialogHandler = dialogHandler; + } } diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerDashboardFragment.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerDashboardFragment.java index 6042e5558d2..ff7dab6205f 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerDashboardFragment.java +++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerDashboardFragment.java @@ -18,12 +18,16 @@ package com.android.settings.connecteddevice.audiosharing; import android.content.Context; +import androidx.annotation.Nullable; + import com.android.settings.R; import com.android.settings.dashboard.DashboardFragment; public class AudioSharingJoinHandlerDashboardFragment extends DashboardFragment { private static final String TAG = "AudioSharingJoinHandlerFrag"; + @Nullable private AudioSharingJoinHandlerController mController; + @Override public int getMetricsCategory() { // TODO: use real enum @@ -43,6 +47,9 @@ public class AudioSharingJoinHandlerDashboardFragment extends DashboardFragment @Override public void onAttach(Context context) { super.onAttach(context); - use(AudioSharingJoinHandlerController.class); + mController = use(AudioSharingJoinHandlerController.class); + if (mController != null) { + mController.init(this); + } } } diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerActivityTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerActivityTest.java index d72a9e06be7..db43e460bb1 100644 --- a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerActivityTest.java +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerActivityTest.java @@ -18,7 +18,19 @@ package com.android.settings.connecteddevice.audiosharing; import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothStatusCodes; +import android.os.Bundle; +import android.platform.test.annotations.DisableFlags; +import android.platform.test.annotations.EnableFlags; +import android.platform.test.flag.junit.SetFlagsRule; + +import com.android.settings.testutils.shadow.ShadowBluetoothAdapter; +import com.android.settingslib.flags.Flags; import org.junit.Before; import org.junit.Rule; @@ -28,17 +40,45 @@ import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; import org.robolectric.Robolectric; import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.shadow.api.Shadow; @RunWith(RobolectricTestRunner.class) +@Config(shadows = {ShadowBluetoothAdapter.class}) public class AudioSharingJoinHandlerActivityTest { @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule(); + @Rule + public final SetFlagsRule mSetFlagsRule = new SetFlagsRule(); + private ShadowBluetoothAdapter mShadowBluetoothAdapter; private AudioSharingJoinHandlerActivity mActivity; @Before public void setUp() { mActivity = spy(Robolectric.buildActivity(AudioSharingJoinHandlerActivity.class).get()); + mShadowBluetoothAdapter = Shadow.extract(BluetoothAdapter.getDefaultAdapter()); + mShadowBluetoothAdapter.setEnabled(true); + mShadowBluetoothAdapter.setIsLeAudioBroadcastSourceSupported( + BluetoothStatusCodes.FEATURE_SUPPORTED); + mShadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported( + BluetoothStatusCodes.FEATURE_SUPPORTED); + } + + @Test + @DisableFlags(Flags.FLAG_PROMOTE_AUDIO_SHARING_FOR_SECOND_AUTO_CONNECTED_LEA_DEVICE) + public void onCreate_flagOff_finish() { + mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); + mActivity.onCreate(new Bundle()); + verify(mActivity).finish(); + } + + @Test + @EnableFlags({Flags.FLAG_ENABLE_LE_AUDIO_SHARING, + Flags.FLAG_PROMOTE_AUDIO_SHARING_FOR_SECOND_AUTO_CONNECTED_LEA_DEVICE}) + public void onCreate_flagOn_create() { + mActivity.onCreate(new Bundle()); + verify(mActivity, never()).finish(); } @Test diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerControllerTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerControllerTest.java index 51365e6d977..7b8ab6d7b9b 100644 --- a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerControllerTest.java +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinHandlerControllerTest.java @@ -18,25 +18,63 @@ package com.android.settings.connecteddevice.audiosharing; import static com.android.settings.core.BasePreferenceController.AVAILABLE_UNSEARCHABLE; import static com.android.settings.core.BasePreferenceController.UNSUPPORTED_ON_DEVICE; +import static com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast.EXTRA_BLUETOOTH_DEVICE; import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.robolectric.Shadows.shadowOf; + import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothLeBroadcastAssistant; +import android.bluetooth.BluetoothLeBroadcastMetadata; +import android.bluetooth.BluetoothLeBroadcastReceiveState; +import android.bluetooth.BluetoothProfile; import android.bluetooth.BluetoothStatusCodes; import android.content.Context; +import android.content.Intent; +import android.os.Looper; import android.platform.test.annotations.DisableFlags; import android.platform.test.annotations.EnableFlags; import android.platform.test.flag.junit.SetFlagsRule; +import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.LifecycleOwner; +import androidx.preference.PreferenceScreen; import androidx.test.core.app.ApplicationProvider; +import com.android.settings.bluetooth.Utils; +import com.android.settings.dashboard.DashboardFragment; import com.android.settings.testutils.shadow.ShadowBluetoothAdapter; +import com.android.settings.testutils.shadow.ShadowBluetoothUtils; +import com.android.settings.testutils.shadow.ShadowFragment; +import com.android.settingslib.bluetooth.BluetoothCallback; +import com.android.settingslib.bluetooth.BluetoothEventManager; +import com.android.settingslib.bluetooth.CachedBluetoothDevice; +import com.android.settingslib.bluetooth.CachedBluetoothDeviceManager; +import com.android.settingslib.bluetooth.LeAudioProfile; +import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant; +import com.android.settingslib.bluetooth.LocalBluetoothManager; +import com.android.settingslib.bluetooth.LocalBluetoothProfileManager; +import com.android.settingslib.core.lifecycle.Lifecycle; import com.android.settingslib.flags.Flags; +import com.google.common.collect.ImmutableList; + +import org.junit.After; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.Mock; import org.mockito.Spy; import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; @@ -44,8 +82,14 @@ import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; import org.robolectric.shadow.api.Shadow; +import java.util.concurrent.Executor; + @RunWith(RobolectricTestRunner.class) -@Config(shadows = ShadowBluetoothAdapter.class) +@Config(shadows = { + ShadowBluetoothAdapter.class, + ShadowBluetoothUtils.class, + ShadowFragment.class, +}) public class AudioSharingJoinHandlerControllerTest { private static final String PREF_KEY = "audio_sharing_join_handler"; @@ -55,6 +99,18 @@ public class AudioSharingJoinHandlerControllerTest { public final SetFlagsRule mSetFlagsRule = new SetFlagsRule(); @Spy Context mContext = ApplicationProvider.getApplicationContext(); + private Lifecycle mLifecycle; + private LifecycleOwner mLifecycleOwner; + @Mock + private LocalBluetoothManager mLocalBtManager; + @Mock private BluetoothEventManager mEventManager; + @Mock private LocalBluetoothProfileManager mProfileManager; + @Mock private CachedBluetoothDeviceManager mDeviceManager; + @Mock private LocalBluetoothLeBroadcastAssistant mAssistant; + @Mock private PreferenceScreen mScreen; + @Mock private DashboardFragment mFragment; + @Mock private FragmentActivity mActivity; + @Mock private AudioSharingDialogHandler mDialogHandler; private AudioSharingJoinHandlerController mController; @Before @@ -66,7 +122,67 @@ public class AudioSharingJoinHandlerControllerTest { BluetoothStatusCodes.FEATURE_SUPPORTED); shadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported( BluetoothStatusCodes.FEATURE_SUPPORTED); + mLifecycleOwner = () -> mLifecycle; + mLifecycle = new Lifecycle(mLifecycleOwner); + ShadowBluetoothUtils.sLocalBluetoothManager = mLocalBtManager; + mLocalBtManager = Utils.getLocalBtManager(mContext); + when(mLocalBtManager.getEventManager()).thenReturn(mEventManager); + when(mLocalBtManager.getProfileManager()).thenReturn(mProfileManager); + when(mLocalBtManager.getCachedDeviceManager()).thenReturn(mDeviceManager); + when(mProfileManager.getLeAudioBroadcastAssistantProfile()).thenReturn(mAssistant); mController = new AudioSharingJoinHandlerController(mContext, PREF_KEY); + doReturn(mActivity).when(mFragment).getActivity(); + mController.init(mFragment); + mController.setDialogHandler(mDialogHandler); + } + + @After + public void tearDown() { + ShadowBluetoothUtils.reset(); + } + + @Test + @DisableFlags(Flags.FLAG_PROMOTE_AUDIO_SHARING_FOR_SECOND_AUTO_CONNECTED_LEA_DEVICE) + public void onStart_flagOff_doNothing() { + mController.onStart(mLifecycleOwner); + verify(mEventManager, never()).registerCallback(any(BluetoothCallback.class)); + verify(mDialogHandler, never()).registerCallbacks(any(Executor.class)); + verify(mAssistant, never()) + .registerServiceCallBack( + any(Executor.class), any(BluetoothLeBroadcastAssistant.Callback.class)); + } + + @Test + @EnableFlags({Flags.FLAG_ENABLE_LE_AUDIO_SHARING, + Flags.FLAG_PROMOTE_AUDIO_SHARING_FOR_SECOND_AUTO_CONNECTED_LEA_DEVICE}) + public void onStart_flagOn_registerCallbacks() { + mController.onStart(mLifecycleOwner); + verify(mEventManager).registerCallback(any(BluetoothCallback.class)); + verify(mDialogHandler).registerCallbacks(any(Executor.class)); + verify(mAssistant) + .registerServiceCallBack( + any(Executor.class), any(BluetoothLeBroadcastAssistant.Callback.class)); + } + + @Test + @DisableFlags(Flags.FLAG_PROMOTE_AUDIO_SHARING_FOR_SECOND_AUTO_CONNECTED_LEA_DEVICE) + public void onStop_flagOff_doNothing() { + mController.onStop(mLifecycleOwner); + verify(mEventManager, never()).unregisterCallback(any(BluetoothCallback.class)); + verify(mDialogHandler, never()).unregisterCallbacks(); + verify(mAssistant, never()) + .unregisterServiceCallBack(any(BluetoothLeBroadcastAssistant.Callback.class)); + } + + @Test + @EnableFlags({Flags.FLAG_ENABLE_LE_AUDIO_SHARING, + Flags.FLAG_PROMOTE_AUDIO_SHARING_FOR_SECOND_AUTO_CONNECTED_LEA_DEVICE}) + public void onStop_flagOn_unregisterCallbacks() { + mController.onStop(mLifecycleOwner); + verify(mEventManager).unregisterCallback(any(BluetoothCallback.class)); + verify(mDialogHandler).unregisterCallbacks(); + verify(mAssistant) + .unregisterServiceCallBack(any(BluetoothLeBroadcastAssistant.Callback.class)); } @Test @@ -91,4 +207,120 @@ public class AudioSharingJoinHandlerControllerTest { public void getSliceHighlightMenuRes_returnsZero() { assertThat(mController.getSliceHighlightMenuRes()).isEqualTo(0); } + + @Test + @EnableFlags({Flags.FLAG_ENABLE_LE_AUDIO_SHARING, + Flags.FLAG_PROMOTE_AUDIO_SHARING_FOR_SECOND_AUTO_CONNECTED_LEA_DEVICE}) + public void displayPreference_flagOn_updateDeviceList() { + mController.displayPreference(mScreen); + + } + + @Test + public void onProfileConnectionStateChanged_notDisconnectedProfile_doNothing() { + CachedBluetoothDevice cachedDevice = mock(CachedBluetoothDevice.class); + + mController.onProfileConnectionStateChanged( + cachedDevice, BluetoothAdapter.STATE_CONNECTED, + BluetoothProfile.LE_AUDIO_BROADCAST_ASSISTANT); + verifyNoInteractions(mDialogHandler); + } + + @Test + public void onProfileConnectionStateChanged_leaDeviceDisconnected_closeOpeningDialogsForIt() { + // Test when LEA device LE_AUDIO_BROADCAST_ASSISTANT disconnected. + BluetoothDevice device = mock(BluetoothDevice.class); + CachedBluetoothDevice cachedDevice = mock(CachedBluetoothDevice.class); + LeAudioProfile profile = mock(LeAudioProfile.class); + when(profile.isEnabled(device)).thenReturn(true); + when(cachedDevice.getProfiles()).thenReturn(ImmutableList.of(profile)); + when(cachedDevice.isConnected()).thenReturn(true); + when(cachedDevice.getDevice()).thenReturn(device); + + mController.onProfileConnectionStateChanged( + cachedDevice, + BluetoothAdapter.STATE_DISCONNECTED, + BluetoothProfile.LE_AUDIO_BROADCAST_ASSISTANT); + verify(mDialogHandler).closeOpeningDialogsForLeaDevice(cachedDevice); + } + + @Test + public void + onProfileConnectionStateChanged_classicDeviceDisconnected_closeOpeningDialogsForIt() { + // Test when classic device totally disconnected + BluetoothDevice device = mock(BluetoothDevice.class); + CachedBluetoothDevice cachedDevice = mock(CachedBluetoothDevice.class); + LeAudioProfile profile = mock(LeAudioProfile.class); + when(profile.isEnabled(device)).thenReturn(false); + when(cachedDevice.getProfiles()).thenReturn(ImmutableList.of(profile)); + when(cachedDevice.isConnected()).thenReturn(false); + when(cachedDevice.getDevice()).thenReturn(device); + + mController.onProfileConnectionStateChanged( + cachedDevice, BluetoothAdapter.STATE_DISCONNECTED, BluetoothProfile.A2DP); + verify(mDialogHandler).closeOpeningDialogsForNonLeaDevice(cachedDevice); + } + + @Test + @EnableFlags({Flags.FLAG_ENABLE_LE_AUDIO_SHARING, + Flags.FLAG_PROMOTE_AUDIO_SHARING_FOR_SECOND_AUTO_CONNECTED_LEA_DEVICE}) + public void handleDeviceConnectedFromIntent_noDevice_doNothing() { + Intent intent = new Intent(); + doReturn(intent).when(mActivity).getIntent(); + mController.displayPreference(mScreen); + shadowOf(Looper.getMainLooper()).idle(); + + verify(mDeviceManager, never()).findDevice(any(BluetoothDevice.class)); + verify(mDialogHandler, never()) + .handleDeviceConnected(any(CachedBluetoothDevice.class), anyBoolean()); + } + + @Test + @EnableFlags({Flags.FLAG_ENABLE_LE_AUDIO_SHARING, + Flags.FLAG_PROMOTE_AUDIO_SHARING_FOR_SECOND_AUTO_CONNECTED_LEA_DEVICE}) + public void handleDeviceClickFromIntent_handle() { + CachedBluetoothDevice cachedDevice = mock(CachedBluetoothDevice.class); + BluetoothDevice device = mock(BluetoothDevice.class); + when(mDeviceManager.findDevice(device)).thenReturn(cachedDevice); + Intent intent = new Intent(); + intent.putExtra(EXTRA_BLUETOOTH_DEVICE, device); + doReturn(intent).when(mActivity).getIntent(); + mController.displayPreference(mScreen); + shadowOf(Looper.getMainLooper()).idle(); + + verify(mDialogHandler).handleDeviceConnected(cachedDevice, /* userTriggered = */ false); + } + + @Test + public void testBluetoothLeBroadcastAssistantCallbacks_closeOpeningDialogsForSourceAdded() { + CachedBluetoothDevice cachedDevice = mock(CachedBluetoothDevice.class); + BluetoothDevice device = mock(BluetoothDevice.class); + when(mDeviceManager.findDevice(device)).thenReturn(cachedDevice); + // onSourceAdded will dismiss stale dialogs + mController.mAssistantCallback.onSourceAdded(device, /* sourceId= */ + 1, /* reason= */ 1); + + verify(mDialogHandler).closeOpeningDialogsForLeaDevice(cachedDevice); + } + + @Test + public void testBluetoothLeBroadcastAssistantCallbacks_doNothing() { + BluetoothDevice device = mock(BluetoothDevice.class); + mController.mAssistantCallback.onSearchStarted(/* reason= */ 1); + mController.mAssistantCallback.onSearchStartFailed(/* reason= */ 1); + mController.mAssistantCallback.onSearchStopped(/* reason= */ 1); + mController.mAssistantCallback.onSearchStopFailed(/* reason= */ 1); + BluetoothLeBroadcastReceiveState state = mock(BluetoothLeBroadcastReceiveState.class); + mController.mAssistantCallback.onReceiveStateChanged(device, /* sourceId= */ 1, state); + mController.mAssistantCallback.onSourceModified(device, /* sourceId= */ 1, /* reason= */ 1); + mController.mAssistantCallback.onSourceModifyFailed(device, /* sourceId= */ 1, /* reason= */ + 1); + BluetoothLeBroadcastMetadata metadata = mock(BluetoothLeBroadcastMetadata.class); + mController.mAssistantCallback.onSourceFound(metadata); + mController.mAssistantCallback.onSourceLost(/* broadcastId= */ 1); + shadowOf(Looper.getMainLooper()).idle(); + + // Above callbacks won't dismiss stale dialogs + verifyNoInteractions(mDialogHandler); + } }