[Audiosharing] Migrate feature from overlay to Settings

Bug: 340379827
Test: atest
Change-Id: I3a88ac1d2f575f3be1f26f617479bbfd25cf6a8e
This commit is contained in:
Yiyi Shen
2024-05-14 12:50:20 +08:00
parent a719e78a4c
commit e5bd60a0cf
133 changed files with 18497 additions and 275 deletions

View File

@@ -23,7 +23,8 @@ import android.util.Log;
import androidx.preference.Preference;
import com.android.settings.connecteddevice.DevicePreferenceCallback;
import com.android.settings.overlay.FeatureFactory;
import com.android.settings.connecteddevice.audiosharing.AudioSharingUtils;
import com.android.settingslib.bluetooth.BluetoothUtils;
import com.android.settingslib.bluetooth.CachedBluetoothDevice;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
@@ -76,11 +77,17 @@ public class AvailableMediaBluetoothDeviceUpdater extends BluetoothDeviceUpdater
// It would show in Available Devices group if the audio sharing flag is disabled or
// the device is not in the audio sharing session.
if (cachedDevice.isConnectedLeAudioDevice()) {
boolean isAudioSharingFilterMatched =
FeatureFactory.getFeatureFactory()
.getAudioSharingFeatureProvider()
.isAudioSharingFilterMatched(cachedDevice, mLocalManager);
if (!isAudioSharingFilterMatched) {
if (AudioSharingUtils.isFeatureEnabled()
&& BluetoothUtils.hasConnectedBroadcastSource(
cachedDevice, mLocalBtManager)) {
Log.d(
TAG,
"Filter out device : "
+ cachedDevice.getName()
+ ", it is in audio sharing.");
return false;
} else {
Log.d(
TAG,
"isFilterMatched() device : "
@@ -88,13 +95,6 @@ public class AvailableMediaBluetoothDeviceUpdater extends BluetoothDeviceUpdater
+ ", the LE Audio profile is connected and not in sharing "
+ "if broadcast enabled.");
return true;
} else {
Log.d(
TAG,
"Filter out device : "
+ cachedDevice.getName()
+ ", it is in audio sharing.");
return false;
}
}

View File

@@ -17,6 +17,10 @@ package com.android.settings.connecteddevice;
import static com.android.settingslib.Utils.isAudioModeOngoingCall;
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.pm.PackageManager;
@@ -38,13 +42,18 @@ import com.android.settings.bluetooth.AvailableMediaBluetoothDeviceUpdater;
import com.android.settings.bluetooth.BluetoothDevicePreference;
import com.android.settings.bluetooth.BluetoothDeviceUpdater;
import com.android.settings.bluetooth.Utils;
import com.android.settings.connecteddevice.audiosharing.AudioSharingDialogHandler;
import com.android.settings.connecteddevice.audiosharing.AudioSharingUtils;
import com.android.settings.core.BasePreferenceController;
import com.android.settings.dashboard.DashboardFragment;
import com.android.settingslib.bluetooth.BluetoothCallback;
import com.android.settingslib.bluetooth.BluetoothUtils;
import com.android.settingslib.bluetooth.CachedBluetoothDevice;
import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
import com.android.settingslib.core.lifecycle.Lifecycle;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
/**
* Controller to maintain the {@link androidx.preference.PreferenceGroup} for all available media
@@ -57,23 +66,78 @@ public class AvailableMediaDeviceGroupController extends BasePreferenceControlle
private static final String TAG = "AvailableMediaDeviceGroupController";
private static final String KEY = "available_device_list";
private final Executor mExecutor;
@VisibleForTesting @Nullable LocalBluetoothManager mLocalBluetoothManager;
@VisibleForTesting @Nullable PreferenceGroup mPreferenceGroup;
@VisibleForTesting LocalBluetoothManager mLocalBluetoothManager;
@Nullable private BluetoothDeviceUpdater mBluetoothDeviceUpdater;
@Nullable private FragmentManager mFragmentManager;
@Nullable private AudioSharingDialogHandler mDialogHandler;
private BluetoothLeBroadcastAssistant.Callback mAssistantCallback =
new BluetoothLeBroadcastAssistant.Callback() {
@Override
public void onSearchStarted(int reason) {}
public AvailableMediaDeviceGroupController(
Context context,
@Nullable DashboardFragment fragment,
@Nullable Lifecycle lifecycle) {
@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) {}
@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) {
Log.d(TAG, "onSourceRemoved: update media device list.");
if (mBluetoothDeviceUpdater != null) {
mBluetoothDeviceUpdater.forceUpdate();
}
}
@Override
public void onSourceRemoveFailed(
@NonNull BluetoothDevice sink, int sourceId, int reason) {}
@Override
public void onReceiveStateChanged(
@NonNull BluetoothDevice sink,
int sourceId,
@NonNull BluetoothLeBroadcastReceiveState state) {
if (BluetoothUtils.isConnected(state)) {
Log.d(TAG, "onReceiveStateChanged: synced, update media device list.");
if (mBluetoothDeviceUpdater != null) {
mBluetoothDeviceUpdater.forceUpdate();
}
}
}
};
public AvailableMediaDeviceGroupController(Context context) {
super(context, KEY);
if (fragment != null) {
init(fragment);
}
if (lifecycle != null) {
lifecycle.addObserver(this);
}
mLocalBluetoothManager = Utils.getLocalBtManager(mContext);
mExecutor = Executors.newSingleThreadExecutor();
}
@Override
@@ -82,6 +146,21 @@ public class AvailableMediaDeviceGroupController extends BasePreferenceControlle
Log.e(TAG, "onStart() Bluetooth is not supported on this device");
return;
}
if (AudioSharingUtils.isFeatureEnabled()) {
LocalBluetoothLeBroadcastAssistant assistant =
mLocalBluetoothManager
.getProfileManager()
.getLeAudioBroadcastAssistantProfile();
if (assistant != null) {
if (DEBUG) {
Log.d(TAG, "onStart() Register callbacks for assistant.");
}
assistant.registerServiceCallBack(mExecutor, mAssistantCallback);
}
if (mDialogHandler != null) {
mDialogHandler.registerCallbacks(mExecutor);
}
}
mLocalBluetoothManager.getEventManager().registerCallback(this);
if (mBluetoothDeviceUpdater != null) {
mBluetoothDeviceUpdater.registerCallback();
@@ -95,6 +174,21 @@ public class AvailableMediaDeviceGroupController extends BasePreferenceControlle
Log.e(TAG, "onStop() Bluetooth is not supported on this device");
return;
}
if (AudioSharingUtils.isFeatureEnabled()) {
LocalBluetoothLeBroadcastAssistant assistant =
mLocalBluetoothManager
.getProfileManager()
.getLeAudioBroadcastAssistantProfile();
if (assistant != null) {
if (DEBUG) {
Log.d(TAG, "onStop() Register callbacks for assistant.");
}
assistant.unregisterServiceCallBack(mAssistantCallback);
}
if (mDialogHandler != null) {
mDialogHandler.unregisterCallbacks();
}
}
if (mBluetoothDeviceUpdater != null) {
mBluetoothDeviceUpdater.unregisterCallback();
}
@@ -155,7 +249,11 @@ public class AvailableMediaDeviceGroupController extends BasePreferenceControlle
public void onDeviceClick(Preference preference) {
final CachedBluetoothDevice cachedDevice =
((BluetoothDevicePreference) preference).getBluetoothDevice();
cachedDevice.setActive();
if (AudioSharingUtils.isFeatureEnabled() && mDialogHandler != null) {
mDialogHandler.handleDeviceConnected(cachedDevice, /* userTriggered= */ true);
} else {
cachedDevice.setActive();
}
}
public void init(DashboardFragment fragment) {
@@ -165,6 +263,9 @@ public class AvailableMediaDeviceGroupController extends BasePreferenceControlle
fragment.getContext(),
AvailableMediaDeviceGroupController.this,
fragment.getMetricsCategory());
if (AudioSharingUtils.isFeatureEnabled()) {
mDialogHandler = new AudioSharingDialogHandler(mContext, fragment);
}
}
@VisibleForTesting
@@ -177,6 +278,11 @@ public class AvailableMediaDeviceGroupController extends BasePreferenceControlle
mBluetoothDeviceUpdater = bluetoothDeviceUpdater;
}
@VisibleForTesting
public void setDialogHandler(AudioSharingDialogHandler dialogHandler) {
mDialogHandler = dialogHandler;
}
@Override
public void onAudioModeChanged() {
updateTitle();

View File

@@ -22,12 +22,13 @@ import android.provider.DeviceConfig;
import android.text.TextUtils;
import android.util.Log;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import com.android.settings.R;
import com.android.settings.SettingsActivity;
import com.android.settings.Utils;
import com.android.settings.connecteddevice.audiosharing.AudioSharingDevicePreferenceController;
import com.android.settings.connecteddevice.audiosharing.AudioSharingUtils;
import com.android.settings.core.SettingsUIDeviceConfig;
import com.android.settings.dashboard.DashboardFragment;
import com.android.settings.overlay.FeatureFactory;
@@ -35,13 +36,8 @@ import com.android.settings.overlay.SurveyFeatureProvider;
import com.android.settings.search.BaseSearchIndexProvider;
import com.android.settings.slices.SlicePreferenceController;
import com.android.settingslib.bluetooth.HearingAidStatsLogUtils;
import com.android.settingslib.core.AbstractPreferenceController;
import com.android.settingslib.core.lifecycle.Lifecycle;
import com.android.settingslib.search.SearchIndexable;
import java.util.ArrayList;
import java.util.List;
@SearchIndexable(forTarget = SearchIndexable.ALL & ~SearchIndexable.ARC)
public class ConnectedDeviceDashboardFragment extends DashboardFragment {
@@ -91,6 +87,10 @@ public class ConnectedDeviceDashboardFragment extends DashboardFragment {
+ ", action : "
+ action);
}
if (AudioSharingUtils.isFeatureEnabled()) {
use(AudioSharingDevicePreferenceController.class).init(this);
}
use(AvailableMediaDeviceGroupController.class).init(this);
use(ConnectedDeviceGroupController.class).init(this);
use(PreviouslyConnectedDevicePreferenceController.class).init(this);
use(SlicePreferenceController.class)
@@ -112,31 +112,6 @@ public class ConnectedDeviceDashboardFragment extends DashboardFragment {
}
}
@Override
protected List<AbstractPreferenceController> createPreferenceControllers(Context context) {
return buildPreferenceControllers(context, /* fragment= */ this, getSettingsLifecycle());
}
private static List<AbstractPreferenceController> buildPreferenceControllers(
Context context,
@Nullable ConnectedDeviceDashboardFragment fragment,
@Nullable Lifecycle lifecycle) {
final List<AbstractPreferenceController> controllers = new ArrayList<>();
AbstractPreferenceController availableMediaController =
FeatureFactory.getFeatureFactory()
.getAudioSharingFeatureProvider()
.createAvailableMediaDeviceGroupController(context, fragment, lifecycle);
controllers.add(availableMediaController);
AbstractPreferenceController audioSharingController =
FeatureFactory.getFeatureFactory()
.getAudioSharingFeatureProvider()
.createAudioSharingDevicePreferenceController(context, fragment, lifecycle);
if (audioSharingController != null) {
controllers.add(audioSharingController);
}
return controllers;
}
@VisibleForTesting
boolean isAlwaysDiscoverable(String callingAppPackageName, String action) {
return TextUtils.equals(SLICE_ACTION, action)
@@ -147,12 +122,5 @@ public class ConnectedDeviceDashboardFragment extends DashboardFragment {
/** For Search. */
public static final BaseSearchIndexProvider SEARCH_INDEX_DATA_PROVIDER =
new BaseSearchIndexProvider(R.xml.connected_devices) {
@Override
public List<AbstractPreferenceController> createPreferenceControllers(
Context context) {
return buildPreferenceControllers(
context, /* fragment= */ null, /* lifecycle= */ null);
}
};
new BaseSearchIndexProvider(R.xml.connected_devices);
}

View File

@@ -0,0 +1,36 @@
/*
* Copyright (C) 2024 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.os.Bundle;
import com.android.settings.SettingsActivity;
public class AudioSharingActivity extends SettingsActivity {
@Override
protected void onCreate(Bundle savedState) {
super.onCreate(savedState);
if (!AudioSharingUtils.isFeatureEnabled()) {
finish();
}
}
@Override
protected boolean isValidFragment(String fragmentName) {
return AudioSharingDashboardFragment.class.getName().equals(fragmentName);
}
}

View File

@@ -0,0 +1,127 @@
/*
* Copyright (C) 2023 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.bluetooth.BluetoothAdapter;
import android.content.Context;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.LifecycleOwner;
import androidx.preference.Preference;
import androidx.preference.PreferenceScreen;
import com.android.settings.bluetooth.Utils;
import com.android.settings.core.BasePreferenceController;
import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
import com.android.settingslib.bluetooth.LocalBluetoothProfileManager;
import com.android.settingslib.utils.ThreadUtils;
public abstract class AudioSharingBasePreferenceController extends BasePreferenceController
implements DefaultLifecycleObserver {
private static final String TAG = "AudioSharingBasePreferenceController";
private final BluetoothAdapter mBluetoothAdapter;
@Nullable private final LocalBluetoothManager mBtManager;
@Nullable private final LocalBluetoothProfileManager mProfileManager;
@Nullable protected final LocalBluetoothLeBroadcast mBroadcast;
@Nullable protected Preference mPreference;
public AudioSharingBasePreferenceController(Context context, String preferenceKey) {
super(context, preferenceKey);
mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
mBtManager = Utils.getLocalBtManager(context);
mProfileManager = mBtManager == null ? null : mBtManager.getProfileManager();
mBroadcast = mProfileManager == null ? null : mProfileManager.getLeAudioBroadcastProfile();
}
@Override
public int getAvailabilityStatus() {
return AudioSharingUtils.isFeatureEnabled() ? AVAILABLE : UNSUPPORTED_ON_DEVICE;
}
@Override
public void displayPreference(PreferenceScreen screen) {
super.displayPreference(screen);
mPreference = screen.findPreference(getPreferenceKey());
}
@Override
public void onStart(@NonNull LifecycleOwner owner) {
updateVisibility();
}
/** Update the visibility of the preference. */
protected void updateVisibility() {
if (mPreference == null) {
Log.d(TAG, "Skip updateVisibility, null preference");
return;
}
var unused =
ThreadUtils.postOnBackgroundThread(
() -> {
if (!isAvailable()) {
Log.w(TAG, "Skip updateVisibility, unavailable preference");
AudioSharingUtils.postOnMainThread(
mContext,
() -> { // Check nullability to pass NullAway check
if (mPreference != null) {
mPreference.setVisible(false);
}
});
return;
}
boolean isBtOn = isBluetoothStateOn();
boolean isProfileReady =
AudioSharingUtils.isAudioSharingProfileReady(mProfileManager);
boolean isBroadcasting = isBroadcasting();
boolean isVisible = isBtOn && isProfileReady && isBroadcasting;
Log.d(
TAG,
"updateVisibility, isBtOn = "
+ isBtOn
+ ", isProfileReady = "
+ isProfileReady
+ ", isBroadcasting = "
+ isBroadcasting);
AudioSharingUtils.postOnMainThread(
mContext,
() -> { // Check nullability to pass NullAway check
if (mPreference != null) {
mPreference.setVisible(isVisible);
}
});
});
}
/**
* Triggered when {@link AudioSharingDashboardFragment} receive onAudioSharingProfilesConnected
* callbacks.
*/
protected void onAudioSharingProfilesConnected() {}
protected boolean isBroadcasting() {
return mBroadcast != null && mBroadcast.isEnabled(null);
}
protected boolean isBluetoothStateOn() {
return mBluetoothAdapter != null && mBluetoothAdapter.isEnabled();
}
}

View File

@@ -0,0 +1,91 @@
/*
* Copyright (C) 2023 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 android.util.Log;
import androidx.annotation.Nullable;
import androidx.preference.Preference;
import com.android.settings.bluetooth.BluetoothDeviceUpdater;
import com.android.settings.bluetooth.Utils;
import com.android.settings.connecteddevice.DevicePreferenceCallback;
import com.android.settingslib.bluetooth.BluetoothUtils;
import com.android.settingslib.bluetooth.CachedBluetoothDevice;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
public class AudioSharingBluetoothDeviceUpdater extends BluetoothDeviceUpdater
implements Preference.OnPreferenceClickListener {
private static final String TAG = "AudioSharingBluetoothDeviceUpdater";
private static final String PREF_KEY = "audio_sharing_bt";
@Nullable private LocalBluetoothManager mLocalBtManager;
public AudioSharingBluetoothDeviceUpdater(
Context context,
DevicePreferenceCallback devicePreferenceCallback,
int metricsCategory) {
super(context, devicePreferenceCallback, metricsCategory);
mLocalBtManager = Utils.getLocalBluetoothManager(context);
}
@Override
public boolean isFilterMatched(CachedBluetoothDevice cachedDevice) {
boolean isFilterMatched = false;
if (isDeviceConnected(cachedDevice) && isDeviceInCachedDevicesList(cachedDevice)) {
// If device is LE audio device and has a broadcast source,
// it would show in audio sharing devices group.
if (AudioSharingUtils.isFeatureEnabled()
&& cachedDevice.isConnectedLeAudioDevice()
&& BluetoothUtils.hasConnectedBroadcastSource(cachedDevice, mLocalBtManager)) {
isFilterMatched = true;
}
}
Log.d(
TAG,
"isFilterMatched() device : "
+ cachedDevice.getName()
+ ", isFilterMatched : "
+ isFilterMatched);
return isFilterMatched;
}
@Override
public boolean onPreferenceClick(Preference preference) {
mMetricsFeatureProvider.logClickedPreference(preference, mMetricsCategory);
return true;
}
@Override
protected String getPreferenceKey() {
return PREF_KEY;
}
@Override
protected String getLogTag() {
return TAG;
}
@Override
protected void update(CachedBluetoothDevice cachedBluetoothDevice) {
super.update(cachedBluetoothDevice);
Log.d(TAG, "Map : " + mPreferenceMap);
}
}

View File

@@ -0,0 +1,252 @@
/*
* Copyright (C) 2024 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.bluetooth.BluetoothLeBroadcast;
import android.bluetooth.BluetoothLeBroadcastMetadata;
import android.content.Context;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.LifecycleOwner;
import androidx.preference.PreferenceScreen;
import androidx.preference.TwoStatePreference;
import com.android.settings.R;
import com.android.settings.bluetooth.Utils;
import com.android.settings.core.TogglePreferenceController;
import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
import com.android.settingslib.bluetooth.LocalBluetoothProfileManager;
import com.android.settingslib.utils.ThreadUtils;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
public class AudioSharingCompatibilityPreferenceController extends TogglePreferenceController
implements DefaultLifecycleObserver, LocalBluetoothProfileManager.ServiceListener {
private static final String TAG = "AudioSharingCompatibilityPrefController";
private static final String PREF_KEY = "audio_sharing_stream_compatibility";
@Nullable private final LocalBluetoothManager mBtManager;
@Nullable private final LocalBluetoothProfileManager mProfileManager;
@Nullable private final LocalBluetoothLeBroadcast mBroadcast;
@Nullable private TwoStatePreference mPreference;
private final Executor mExecutor;
private AtomicBoolean mCallbacksRegistered = new AtomicBoolean(false);
private final BluetoothLeBroadcast.Callback mBroadcastCallback =
new BluetoothLeBroadcast.Callback() {
@Override
public void onBroadcastStarted(int reason, int broadcastId) {
Log.d(
TAG,
"onBroadcastStarted(), reason = "
+ reason
+ ", broadcastId = "
+ broadcastId);
updateEnabled();
}
@Override
public void onBroadcastStartFailed(int reason) {}
@Override
public void onBroadcastMetadataChanged(
int broadcastId, @NonNull BluetoothLeBroadcastMetadata metadata) {}
@Override
public void onBroadcastStopped(int reason, int broadcastId) {
Log.d(
TAG,
"onBroadcastStopped(), reason = "
+ reason
+ ", broadcastId = "
+ broadcastId);
updateEnabled();
}
@Override
public void onBroadcastStopFailed(int reason) {}
@Override
public void onBroadcastUpdated(int reason, int broadcastId) {}
@Override
public void onBroadcastUpdateFailed(int reason, int broadcastId) {}
@Override
public void onPlaybackStarted(int reason, int broadcastId) {}
@Override
public void onPlaybackStopped(int reason, int broadcastId) {}
};
public AudioSharingCompatibilityPreferenceController(Context context, String preferenceKey) {
super(context, preferenceKey);
mBtManager = Utils.getLocalBtManager(context);
mProfileManager = mBtManager == null ? null : mBtManager.getProfileManager();
mBroadcast = mProfileManager == null ? null : mProfileManager.getLeAudioBroadcastProfile();
mExecutor = Executors.newSingleThreadExecutor();
}
@Override
public void onStart(@NonNull LifecycleOwner owner) {
if (!isAvailable()) {
Log.d(TAG, "Skip register callbacks, feature not support");
return;
}
if (!AudioSharingUtils.isAudioSharingProfileReady(mProfileManager)) {
Log.d(TAG, "Skip register callbacks, profile not ready");
if (mProfileManager != null) {
mProfileManager.addServiceListener(this);
}
return;
}
registerCallbacks();
}
@Override
public void onStop(@NonNull LifecycleOwner owner) {
if (!isAvailable()) {
Log.d(TAG, "Skip unregister callbacks, feature not support");
return;
}
if (mProfileManager != null) {
mProfileManager.removeServiceListener(this);
}
if (mBroadcast == null || !AudioSharingUtils.isAudioSharingProfileReady(mProfileManager)) {
Log.d(TAG, "Skip unregister callbacks, profile not ready");
return;
}
if (mCallbacksRegistered.get()) {
Log.d(TAG, "Unregister callbacks");
mBroadcast.unregisterServiceCallBack(mBroadcastCallback);
mCallbacksRegistered.set(false);
}
}
@Override
public int getAvailabilityStatus() {
return AudioSharingUtils.isFeatureEnabled() ? AVAILABLE : UNSUPPORTED_ON_DEVICE;
}
@Override
public void displayPreference(PreferenceScreen screen) {
super.displayPreference(screen);
mPreference = screen.findPreference(getPreferenceKey());
updateEnabled();
}
@Override
public String getPreferenceKey() {
return PREF_KEY;
}
@Override
public boolean isChecked() {
return mBroadcast != null && mBroadcast.getImproveCompatibility();
}
@Override
public boolean setChecked(boolean isChecked) {
if (mBroadcast == null || mBroadcast.getImproveCompatibility() == isChecked) {
if (mBroadcast != null) {
Log.d(TAG, "Skip setting improveCompatibility, unchanged");
}
return false;
}
mBroadcast.setImproveCompatibility(isChecked);
// TODO: call updateBroadcast once framework change ready.
return true;
}
@Override
public void onServiceConnected() {
if (AudioSharingUtils.isAudioSharingProfileReady(mProfileManager)) {
registerCallbacks();
AudioSharingUtils.postOnMainThread(
mContext,
() -> {
if (mPreference != null) {
updateState(mPreference);
}
updateEnabled();
});
if (mProfileManager != null) {
mProfileManager.removeServiceListener(this);
}
}
}
@Override
public void onServiceDisconnected() {
// Do nothing
}
@Override
public int getSliceHighlightMenuRes() {
return 0;
}
/** Test only: set callbacks registration state for test setup. */
@VisibleForTesting
public void setCallbacksRegistered(boolean registered) {
mCallbacksRegistered.set(registered);
}
private void registerCallbacks() {
if (mBroadcast == null) {
Log.d(TAG, "Skip register callbacks, profile not ready");
return;
}
if (!mCallbacksRegistered.get()) {
Log.d(TAG, "Register callbacks");
mBroadcast.registerServiceCallBack(mExecutor, mBroadcastCallback);
mCallbacksRegistered.set(true);
}
}
private void updateEnabled() {
int disabledDescriptionRes =
R.string.audio_sharing_stream_compatibility_disabled_description;
int descriptionRes = R.string.audio_sharing_stream_compatibility_description;
var unused =
ThreadUtils.postOnBackgroundThread(
() -> {
boolean isBroadcasting = AudioSharingUtils.isBroadcasting(mBtManager);
AudioSharingUtils.postOnMainThread(
mContext,
() -> {
if (mPreference != null) {
mPreference.setEnabled(!isBroadcasting);
mPreference.setSummary(
isBroadcasting
? mContext.getString(
disabledDescriptionRes)
: mContext.getString(descriptionRes));
}
});
});
}
}

View File

@@ -0,0 +1,71 @@
/*
* Copyright (C) 2024 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.app.Dialog;
import android.os.Bundle;
import android.util.Log;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import com.android.settings.R;
import com.android.settings.core.instrumentation.InstrumentedDialogFragment;
public class AudioSharingConfirmDialogFragment extends InstrumentedDialogFragment {
private static final String TAG = "AudioSharingConfirmDialog";
@Override
public int getMetricsCategory() {
// TODO: add metrics category.
return 0;
}
/**
* Display the {@link AudioSharingConfirmDialogFragment} dialog.
*
* @param host The Fragment this dialog will be hosted.
*/
public static void show(Fragment host) {
if (!AudioSharingUtils.isFeatureEnabled()) return;
FragmentManager manager = host.getChildFragmentManager();
AlertDialog dialog = AudioSharingDialogHelper.getDialogIfShowing(manager, TAG);
if (dialog != null) {
Log.d(TAG, "Dialog is showing, return.");
return;
}
Log.d(TAG, "Show up the confirm dialog.");
AudioSharingConfirmDialogFragment dialogFrag = new AudioSharingConfirmDialogFragment();
dialogFrag.show(manager, TAG);
}
@Override
public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
AlertDialog dialog =
AudioSharingDialogFactory.newBuilder(getActivity())
.setTitle(R.string.audio_sharing_confirm_dialog_title)
.setTitleIcon(com.android.settingslib.R.drawable.ic_bt_le_audio_sharing)
.setIsCustomBodyEnabled(true)
.setCustomMessage(R.string.audio_sharing_comfirm_dialog_content)
.setPositiveButton(com.android.settings.R.string.okay, (d, w) -> dismiss())
.build();
dialog.setCanceledOnTouchOutside(true);
return dialog;
}
}

View File

@@ -0,0 +1,111 @@
/*
* Copyright (C) 2023 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.app.settings.SettingsEnums;
import android.content.Context;
import android.os.Bundle;
import com.android.settings.R;
import com.android.settings.SettingsActivity;
import com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsCategoryController;
import com.android.settings.dashboard.DashboardFragment;
import com.android.settings.widget.SettingsMainSwitchBar;
public class AudioSharingDashboardFragment extends DashboardFragment
implements AudioSharingSwitchBarController.OnAudioSharingStateChangedListener {
private static final String TAG = "AudioSharingDashboardFrag";
SettingsMainSwitchBar mMainSwitchBar;
private AudioSharingSwitchBarController mSwitchBarController;
private AudioSharingDeviceVolumeGroupController mAudioSharingDeviceVolumeGroupController;
private CallsAndAlarmsPreferenceController mCallsAndAlarmsPreferenceController;
private AudioSharingPlaySoundPreferenceController mAudioSharingPlaySoundPreferenceController;
private AudioStreamsCategoryController mAudioStreamsCategoryController;
public AudioSharingDashboardFragment() {
super();
}
@Override
public int getMetricsCategory() {
return SettingsEnums.AUDIO_SHARING_SETTINGS;
}
@Override
protected String getLogTag() {
return TAG;
}
@Override
public int getHelpResource() {
return R.string.help_url_audio_sharing;
}
@Override
protected int getPreferenceScreenResId() {
return R.xml.bluetooth_le_audio_sharing;
}
@Override
public void onAttach(Context context) {
super.onAttach(context);
mAudioSharingDeviceVolumeGroupController =
use(AudioSharingDeviceVolumeGroupController.class);
mAudioSharingDeviceVolumeGroupController.init(this);
mCallsAndAlarmsPreferenceController = use(CallsAndAlarmsPreferenceController.class);
mCallsAndAlarmsPreferenceController.init(this);
mAudioSharingPlaySoundPreferenceController =
use(AudioSharingPlaySoundPreferenceController.class);
mAudioStreamsCategoryController = use(AudioStreamsCategoryController.class);
}
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
// Assume we are in a SettingsActivity. This is only safe because we currently use
// SettingsActivity as base for all preference fragments.
final SettingsActivity activity = (SettingsActivity) getActivity();
mMainSwitchBar = activity.getSwitchBar();
mMainSwitchBar.setTitle(getText(R.string.audio_sharing_switch_title));
mSwitchBarController = new AudioSharingSwitchBarController(activity, mMainSwitchBar, this);
mSwitchBarController.init(this);
getSettingsLifecycle().addObserver(mSwitchBarController);
mMainSwitchBar.show();
}
@Override
public void onAudioSharingStateChanged() {
updateVisibilityForAttachedPreferences();
}
@Override
public void onAudioSharingProfilesConnected() {
onProfilesConnectedForAttachedPreferences();
}
private void updateVisibilityForAttachedPreferences() {
mAudioSharingDeviceVolumeGroupController.updateVisibility();
mCallsAndAlarmsPreferenceController.updateVisibility();
mAudioSharingPlaySoundPreferenceController.updateVisibility();
mAudioStreamsCategoryController.updateVisibility();
}
private void onProfilesConnectedForAttachedPreferences() {
mAudioSharingDeviceVolumeGroupController.onAudioSharingProfilesConnected();
}
}

View File

@@ -0,0 +1,116 @@
/*
* Copyright (C) 2023 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 android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.android.settings.R;
import java.util.List;
public class AudioSharingDeviceAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
private static final String TAG = "AudioSharingDeviceAdapter";
private final Context mContext;
private final List<AudioSharingDeviceItem> mDevices;
private final OnClickListener mOnClickListener;
private final ActionType mType;
public AudioSharingDeviceAdapter(
@NonNull Context context,
@NonNull List<AudioSharingDeviceItem> devices,
@NonNull OnClickListener listener,
@NonNull ActionType type) {
mContext = context;
mDevices = devices;
mOnClickListener = listener;
mType = type;
}
/**
* The action type when user click on the item.
*
* <p>We choose the item text based on this type.
*/
public enum ActionType {
// Click on the item will add the item to audio sharing
SHARE,
// Click on the item will remove the item from audio sharing
REMOVE,
}
private class AudioSharingDeviceViewHolder extends RecyclerView.ViewHolder {
private final Button mButtonView;
AudioSharingDeviceViewHolder(View view) {
super(view);
mButtonView = view.findViewById(R.id.device_button);
}
public void bindView(int position) {
if (mButtonView != null) {
String btnText = switch (mType) {
case SHARE ->
mContext.getString(
R.string.audio_sharing_share_with_button_label,
mDevices.get(position).getName());
case REMOVE ->
mContext.getString(
R.string.audio_sharing_disconnect_device_button_label,
mDevices.get(position).getName());
};
mButtonView.setText(btnText);
mButtonView.setOnClickListener(
v -> mOnClickListener.onClick(mDevices.get(position)));
} else {
Log.w(TAG, "bind view skipped due to button view is null");
}
}
}
@Override
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
View view =
LayoutInflater.from(parent.getContext())
.inflate(R.layout.audio_sharing_device_item, parent, false);
return new AudioSharingDeviceViewHolder(view);
}
@Override
public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
((AudioSharingDeviceViewHolder) holder).bindView(position);
}
@Override
public int getItemCount() {
return mDevices.size();
}
public interface OnClickListener {
/** Called when an item has been clicked. */
void onClick(AudioSharingDeviceItem item);
}
}

View File

@@ -0,0 +1,75 @@
/*
* Copyright (C) 2023 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.os.Parcel;
import android.os.Parcelable;
public final class AudioSharingDeviceItem implements Parcelable {
private final String mName;
private final int mGroupId;
private final boolean mIsActive;
public AudioSharingDeviceItem(String name, int groupId, boolean isActive) {
mName = name;
mGroupId = groupId;
mIsActive = isActive;
}
public String getName() {
return mName;
}
public int getGroupId() {
return mGroupId;
}
public boolean isActive() {
return mIsActive;
}
public AudioSharingDeviceItem(Parcel in) {
mName = in.readString();
mGroupId = in.readInt();
mIsActive = in.readBoolean();
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeString(mName);
dest.writeInt(mGroupId);
dest.writeBoolean(mIsActive);
}
@Override
public int describeContents() {
return 0;
}
public static final Creator<AudioSharingDeviceItem> CREATOR =
new Creator<AudioSharingDeviceItem>() {
@Override
public AudioSharingDeviceItem createFromParcel(Parcel in) {
return new AudioSharingDeviceItem(in);
}
@Override
public AudioSharingDeviceItem[] newArray(int size) {
return new AudioSharingDeviceItem[size];
}
};
}

View File

@@ -0,0 +1,486 @@
/*
* Copyright (C) 2023 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.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.os.Bundle;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.LifecycleOwner;
import androidx.preference.Preference;
import androidx.preference.PreferenceGroup;
import androidx.preference.PreferenceScreen;
import com.android.settings.SettingsActivity;
import com.android.settings.bluetooth.BluetoothDeviceUpdater;
import com.android.settings.bluetooth.Utils;
import com.android.settings.connecteddevice.DevicePreferenceCallback;
import com.android.settings.core.BasePreferenceController;
import com.android.settings.dashboard.DashboardFragment;
import com.android.settingslib.bluetooth.A2dpProfile;
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.HeadsetProfile;
import com.android.settingslib.bluetooth.HearingAidProfile;
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 java.util.Locale;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
public class AudioSharingDevicePreferenceController extends BasePreferenceController
implements DefaultLifecycleObserver,
DevicePreferenceCallback,
BluetoothCallback,
LocalBluetoothProfileManager.ServiceListener {
private static final boolean DEBUG = BluetoothUtils.D;
private static final String TAG = "AudioSharingDevicePrefController";
private static final String KEY = "audio_sharing_device_list";
private static final String KEY_AUDIO_SHARING_SETTINGS =
"connected_device_audio_sharing_settings";
@Nullable private final LocalBluetoothManager mBtManager;
@Nullable private final CachedBluetoothDeviceManager mDeviceManager;
@Nullable private final BluetoothEventManager mEventManager;
@Nullable private final LocalBluetoothProfileManager mProfileManager;
@Nullable private final LocalBluetoothLeBroadcastAssistant mAssistant;
private final Executor mExecutor;
@Nullable private PreferenceGroup mPreferenceGroup;
@Nullable private Preference mAudioSharingSettingsPreference;
@Nullable private BluetoothDeviceUpdater mBluetoothDeviceUpdater;
@Nullable private DashboardFragment mFragment;
@Nullable private AudioSharingDialogHandler mDialogHandler;
private AtomicBoolean mIntentHandled = new AtomicBoolean(false);
private BluetoothLeBroadcastAssistant.Callback mBroadcastAssistantCallback =
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) {}
@Override
public void onSourceAddFailed(
@NonNull BluetoothDevice sink,
@NonNull BluetoothLeBroadcastMetadata source,
int reason) {
AudioSharingUtils.toastMessage(
mContext,
String.format(
Locale.US,
"Fail to add source to %s reason %d",
sink.getAddress(),
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) {
Log.d(TAG, "onSourceRemoved: update sharing device list.");
if (mBluetoothDeviceUpdater != null) {
mBluetoothDeviceUpdater.forceUpdate();
}
}
@Override
public void onSourceRemoveFailed(
@NonNull BluetoothDevice sink, int sourceId, int reason) {
AudioSharingUtils.toastMessage(
mContext,
String.format(
Locale.US,
"Fail to remove source from %s reason %d",
sink.getAddress(),
reason));
}
@Override
public void onReceiveStateChanged(
@NonNull BluetoothDevice sink,
int sourceId,
@NonNull BluetoothLeBroadcastReceiveState state) {
if (BluetoothUtils.isConnected(state)) {
Log.d(TAG, "onSourceAdded: update sharing device list.");
if (mBluetoothDeviceUpdater != null) {
mBluetoothDeviceUpdater.forceUpdate();
}
if (mDeviceManager != null && mDialogHandler != null) {
CachedBluetoothDevice cachedDevice = mDeviceManager.findDevice(sink);
if (cachedDevice != null) {
mDialogHandler.closeOpeningDialogsForLeaDevice(cachedDevice);
}
}
}
}
};
public AudioSharingDevicePreferenceController(Context context) {
super(context, KEY);
mBtManager = Utils.getLocalBtManager(mContext);
mEventManager = mBtManager == null ? null : mBtManager.getEventManager();
mDeviceManager = mBtManager == null ? null : mBtManager.getCachedDeviceManager();
mProfileManager = mBtManager == null ? null : mBtManager.getProfileManager();
mAssistant =
mProfileManager == null
? null
: mProfileManager.getLeAudioBroadcastAssistantProfile();
mExecutor = Executors.newSingleThreadExecutor();
}
@Override
public void onStart(@NonNull LifecycleOwner owner) {
if (!isAvailable()) {
Log.d(TAG, "Skip onStart(), feature is not supported.");
return;
}
if (!AudioSharingUtils.isAudioSharingProfileReady(mProfileManager)
&& mProfileManager != null) {
Log.d(TAG, "Register profile service listener");
mProfileManager.addServiceListener(this);
}
if (mEventManager == null
|| mAssistant == null
|| mDialogHandler == null
|| mBluetoothDeviceUpdater == null) {
Log.d(TAG, "Skip onStart(), profile is not ready.");
return;
}
Log.d(TAG, "onStart() Register callbacks.");
mEventManager.registerCallback(this);
mAssistant.registerServiceCallBack(mExecutor, mBroadcastAssistantCallback);
mDialogHandler.registerCallbacks(mExecutor);
mBluetoothDeviceUpdater.registerCallback();
mBluetoothDeviceUpdater.refreshPreference();
}
@Override
public void onStop(@NonNull LifecycleOwner owner) {
if (!isAvailable()) {
Log.d(TAG, "Skip onStop(), feature is not supported.");
return;
}
if (mProfileManager != null) {
mProfileManager.removeServiceListener(this);
}
if (mEventManager == null
|| mAssistant == null
|| mDialogHandler == null
|| mBluetoothDeviceUpdater == null) {
Log.d(TAG, "Skip onStop(), profile is not ready.");
return;
}
Log.d(TAG, "onStop() Unregister callbacks.");
mEventManager.unregisterCallback(this);
mAssistant.unregisterServiceCallBack(mBroadcastAssistantCallback);
mDialogHandler.unregisterCallbacks();
mBluetoothDeviceUpdater.unregisterCallback();
}
@Override
public void onServiceConnected() {
if (AudioSharingUtils.isAudioSharingProfileReady(mProfileManager)) {
if (mProfileManager != null) {
mProfileManager.removeServiceListener(this);
}
if (!mIntentHandled.get()) {
Log.d(TAG, "onServiceConnected: handleDeviceClickFromIntent");
handleDeviceClickFromIntent();
mIntentHandled.set(true);
}
}
}
@Override
public void onServiceDisconnected() {
// Do nothing
}
@Override
public void displayPreference(PreferenceScreen screen) {
super.displayPreference(screen);
mPreferenceGroup = screen.findPreference(KEY);
if (mPreferenceGroup != null) {
mAudioSharingSettingsPreference =
mPreferenceGroup.findPreference(KEY_AUDIO_SHARING_SETTINGS);
mPreferenceGroup.setVisible(false);
}
if (mAudioSharingSettingsPreference != null) {
mAudioSharingSettingsPreference.setVisible(false);
}
if (isAvailable()) {
if (mBluetoothDeviceUpdater != null) {
mBluetoothDeviceUpdater.setPrefContext(screen.getContext());
mBluetoothDeviceUpdater.forceUpdate();
}
if (AudioSharingUtils.isAudioSharingProfileReady(mProfileManager)) {
if (!mIntentHandled.get()) {
Log.d(TAG, "displayPreference: profile ready, handleDeviceClickFromIntent");
handleDeviceClickFromIntent();
mIntentHandled.set(true);
}
}
}
}
@Override
public int getAvailabilityStatus() {
return AudioSharingUtils.isFeatureEnabled() && mBluetoothDeviceUpdater != null
? AVAILABLE_UNSEARCHABLE
: UNSUPPORTED_ON_DEVICE;
}
@Override
public String getPreferenceKey() {
return KEY;
}
@Override
public void onDeviceAdded(Preference preference) {
if (mPreferenceGroup != null) {
if (mPreferenceGroup.getPreferenceCount() == 1) {
mPreferenceGroup.setVisible(true);
if (mAudioSharingSettingsPreference != null) {
mAudioSharingSettingsPreference.setVisible(true);
}
}
mPreferenceGroup.addPreference(preference);
}
}
@Override
public void onDeviceRemoved(Preference preference) {
if (mPreferenceGroup != null) {
mPreferenceGroup.removePreference(preference);
if (mPreferenceGroup.getPreferenceCount() == 1) {
mPreferenceGroup.setVisible(false);
if (mAudioSharingSettingsPreference != null) {
mAudioSharingSettingsPreference.setVisible(false);
}
}
}
}
@Override
public void onProfileConnectionStateChanged(
@NonNull CachedBluetoothDevice cachedDevice,
@ConnectionState int state,
int bluetoothProfile) {
if (mDialogHandler == null || mAssistant == null || mFragment == null) {
Log.d(TAG, "Ignore onProfileConnectionStateChanged, not init correctly");
return;
}
if (!isMediaDevice(cachedDevice)) {
Log.d(TAG, "Ignore onProfileConnectionStateChanged, not a media device");
return;
}
// Close related dialogs if the BT remote device is disconnected.
if (state == BluetoothAdapter.STATE_DISCONNECTED) {
boolean isLeAudioSupported = AudioSharingUtils.isLeAudioSupported(cachedDevice);
if (isLeAudioSupported
&& bluetoothProfile == BluetoothProfile.LE_AUDIO_BROADCAST_ASSISTANT) {
mDialogHandler.closeOpeningDialogsForLeaDevice(cachedDevice);
return;
}
if (!isLeAudioSupported && !cachedDevice.isConnected()) {
mDialogHandler.closeOpeningDialogsForNonLeaDevice(cachedDevice);
return;
}
}
if (state != BluetoothAdapter.STATE_CONNECTED || !cachedDevice.getDevice().isConnected()) {
Log.d(TAG, "Ignore onProfileConnectionStateChanged, not connected state");
return;
}
handleOnProfileStateChanged(cachedDevice, bluetoothProfile);
}
/**
* 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(DashboardFragment fragment) {
mFragment = fragment;
mBluetoothDeviceUpdater =
new AudioSharingBluetoothDeviceUpdater(
fragment.getContext(),
AudioSharingDevicePreferenceController.this,
fragment.getMetricsCategory());
mDialogHandler = new AudioSharingDialogHandler(mContext, fragment);
}
@VisibleForTesting
public void setBluetoothDeviceUpdater(@Nullable BluetoothDeviceUpdater bluetoothDeviceUpdater) {
mBluetoothDeviceUpdater = bluetoothDeviceUpdater;
}
@VisibleForTesting
public void setDialogHandler(@Nullable AudioSharingDialogHandler dialogHandler) {
mDialogHandler = dialogHandler;
}
@VisibleForTesting
public void setHostFragment(@Nullable DashboardFragment fragment) {
mFragment = fragment;
}
/** Test only: set intent handle state for test. */
@VisibleForTesting
public void setIntentHandled(boolean handled) {
mIntentHandled.set(handled);
}
private void handleOnProfileStateChanged(
@NonNull CachedBluetoothDevice cachedDevice, int bluetoothProfile) {
boolean isLeAudioSupported = AudioSharingUtils.isLeAudioSupported(cachedDevice);
// For eligible (LE audio) remote device, we only check its connected LE audio assistant
// profile.
if (isLeAudioSupported
&& bluetoothProfile != BluetoothProfile.LE_AUDIO_BROADCAST_ASSISTANT) {
Log.d(
TAG,
"Ignore onProfileConnectionStateChanged, not the le assistant profile for"
+ " le audio device");
return;
}
boolean isFirstConnectedProfile = isFirstConnectedProfile(cachedDevice, bluetoothProfile);
// For ineligible (non LE audio) remote device, we only check its first connected profile.
if (!isLeAudioSupported && !isFirstConnectedProfile) {
Log.d(
TAG,
"Ignore onProfileConnectionStateChanged, not the first connected profile for"
+ " non le audio device");
return;
}
if (DEBUG) {
Log.d(
TAG,
"Start handling onProfileConnectionStateChanged for "
+ cachedDevice.getDevice().getAnonymizedAddress());
}
// Check nullability to pass NullAway check
if (mDialogHandler != null) {
mDialogHandler.handleDeviceConnected(cachedDevice, /* userTriggered= */ false);
}
}
private boolean isMediaDevice(CachedBluetoothDevice cachedDevice) {
return cachedDevice.getConnectableProfiles().stream()
.anyMatch(
profile ->
profile instanceof A2dpProfile
|| profile instanceof HearingAidProfile
|| profile instanceof LeAudioProfile
|| profile instanceof HeadsetProfile);
}
private boolean isFirstConnectedProfile(
CachedBluetoothDevice cachedDevice, int bluetoothProfile) {
return cachedDevice.getProfiles().stream()
.noneMatch(
profile ->
profile.getProfileId() != bluetoothProfile
&& profile.getConnectionStatus(cachedDevice.getDevice())
== BluetoothProfile.STATE_CONNECTED);
}
/**
* Handle device click triggered by intent.
*
* <p>When user click device from BT QS dialog, BT QS will send intent to open {@link
* com.android.settings.connecteddevice.ConnectedDeviceDashboardFragment} and handle device
* click event under some conditions.
*
* <p>This method will be called when displayPreference if the audio sharing profiles are ready.
* If the profiles are not ready when the preference display, this method will be called when
* onServiceConnected.
*/
private void handleDeviceClickFromIntent() {
if (mFragment == null
|| mFragment.getActivity() == null
|| mFragment.getActivity().getIntent() == null) {
Log.d(TAG, "Skip handleDeviceClickFromIntent, fragment intent is null");
return;
}
Intent intent = mFragment.getActivity().getIntent();
Bundle args = intent.getBundleExtra(SettingsActivity.EXTRA_SHOW_FRAGMENT_ARGUMENTS);
BluetoothDevice device =
args == null
? null
: args.getParcelable(EXTRA_BLUETOOTH_DEVICE, BluetoothDevice.class);
CachedBluetoothDevice cachedDevice =
(device == null || mDeviceManager == null)
? null
: mDeviceManager.findDevice(device);
if (cachedDevice == null) {
Log.d(TAG, "Skip handleDeviceClickFromIntent, device is null");
return;
}
// Check nullability to pass NullAway check
if (device != null && !device.isConnected()) {
Log.d(TAG, "handleDeviceClickFromIntent: connect");
cachedDevice.connect();
} else if (mDialogHandler != null) {
Log.d(TAG, "handleDeviceClickFromIntent: trigger dialog handler");
mDialogHandler.handleDeviceConnected(cachedDevice, /* userTriggered= */ true);
}
}
}

View File

@@ -0,0 +1,177 @@
/*
* Copyright (C) 2023 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.bluetooth.BluetoothCsipSetCoordinator;
import android.bluetooth.BluetoothDevice;
import android.content.Context;
import android.media.AudioManager;
import android.util.Log;
import android.widget.SeekBar;
import androidx.annotation.Nullable;
import androidx.preference.Preference;
import com.android.settings.bluetooth.BluetoothDevicePreference;
import com.android.settings.bluetooth.BluetoothDeviceUpdater;
import com.android.settings.bluetooth.Utils;
import com.android.settings.connecteddevice.DevicePreferenceCallback;
import com.android.settingslib.bluetooth.BluetoothUtils;
import com.android.settingslib.bluetooth.CachedBluetoothDevice;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
import com.android.settingslib.bluetooth.VolumeControlProfile;
public class AudioSharingDeviceVolumeControlUpdater extends BluetoothDeviceUpdater
implements Preference.OnPreferenceClickListener {
private static final String TAG = "AudioSharingDeviceVolumeControlUpdater";
private static final String PREF_KEY = "audio_sharing_volume_control";
@Nullable private final LocalBluetoothManager mBtManager;
@Nullable private final VolumeControlProfile mVolumeControl;
public AudioSharingDeviceVolumeControlUpdater(
Context context,
DevicePreferenceCallback devicePreferenceCallback,
int metricsCategory) {
super(context, devicePreferenceCallback, metricsCategory);
mBtManager = Utils.getLocalBluetoothManager(context);
mVolumeControl =
mBtManager == null
? null
: mBtManager.getProfileManager().getVolumeControlProfile();
}
@Override
public boolean isFilterMatched(CachedBluetoothDevice cachedDevice) {
boolean isFilterMatched = false;
if (isDeviceConnected(cachedDevice) && isDeviceInCachedDevicesList(cachedDevice)) {
// If device is LE audio device and in a sharing session on current sharing device,
// it would show in volume control group.
if (cachedDevice.isConnectedLeAudioDevice()
&& AudioSharingUtils.isBroadcasting(mBtManager)
&& BluetoothUtils.hasConnectedBroadcastSource(cachedDevice, mBtManager)) {
isFilterMatched = true;
}
}
Log.d(
TAG,
"isFilterMatched() device : "
+ cachedDevice.getName()
+ ", isFilterMatched : "
+ isFilterMatched);
return isFilterMatched;
}
@Override
public boolean onPreferenceClick(Preference preference) {
return true;
}
@Override
protected void addPreference(CachedBluetoothDevice cachedDevice) {
if (cachedDevice == null) return;
final BluetoothDevice device = cachedDevice.getDevice();
if (!mPreferenceMap.containsKey(device)) {
SeekBar.OnSeekBarChangeListener listener =
new SeekBar.OnSeekBarChangeListener() {
@Override
public void onProgressChanged(
SeekBar seekBar, int progress, boolean fromUser) {}
@Override
public void onStartTrackingTouch(SeekBar seekBar) {}
@Override
public void onStopTrackingTouch(SeekBar seekBar) {
int progress = seekBar.getProgress();
int groupId = AudioSharingUtils.getGroupId(cachedDevice);
if (groupId != BluetoothCsipSetCoordinator.GROUP_ID_INVALID
&& groupId
== AudioSharingUtils.getFallbackActiveGroupId(
mContext)) {
// Set media stream volume for primary buds, audio manager will
// update all buds volume in the audio sharing.
setAudioManagerStreamVolume(progress);
} else {
// Set buds volume for other buds.
setDeviceVolume(cachedDevice, progress);
}
}
};
AudioSharingDeviceVolumePreference vPreference =
new AudioSharingDeviceVolumePreference(mPrefContext, cachedDevice);
vPreference.initialize();
vPreference.setOnSeekBarChangeListener(listener);
vPreference.setKey(getPreferenceKey());
vPreference.setIcon(com.android.settingslib.R.drawable.ic_bt_untethered_earbuds);
vPreference.setTitle(cachedDevice.getName());
mPreferenceMap.put(device, vPreference);
mDevicePreferenceCallback.onDeviceAdded(vPreference);
}
}
@Override
protected String getPreferenceKey() {
return PREF_KEY;
}
@Override
protected String getLogTag() {
return TAG;
}
@Override
protected void update(CachedBluetoothDevice cachedBluetoothDevice) {
super.update(cachedBluetoothDevice);
Log.d(TAG, "Map : " + mPreferenceMap);
}
@Override
protected void addPreference(
CachedBluetoothDevice cachedDevice, @BluetoothDevicePreference.SortType int type) {}
@Override
protected void launchDeviceDetails(Preference preference) {}
@Override
public void refreshPreference() {}
private void setDeviceVolume(CachedBluetoothDevice cachedDevice, int progress) {
if (mVolumeControl != null) {
mVolumeControl.setDeviceVolume(
cachedDevice.getDevice(), progress, /* isGroupOp= */ true);
}
}
private void setAudioManagerStreamVolume(int progress) {
int seekbarRange =
AudioSharingDeviceVolumePreference.MAX_VOLUME
- AudioSharingDeviceVolumePreference.MIN_VOLUME;
try {
AudioManager audioManager = mContext.getSystemService(AudioManager.class);
int streamVolumeRange =
audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
- audioManager.getStreamMinVolume(AudioManager.STREAM_MUSIC);
int volume = Math.round((float) progress * streamVolumeRange / seekbarRange);
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, volume, 0);
} catch (RuntimeException e) {
Log.e(TAG, "Fail to setAudioManagerStreamVolumeForFallbackDevice, error = " + e);
}
}
}

View File

@@ -0,0 +1,427 @@
/*
* Copyright (C) 2023 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.connecteddevice.audiosharing.AudioSharingUtils.SETTINGS_KEY_FALLBACK_DEVICE_GROUP_ID;
import android.annotation.IntRange;
import android.bluetooth.BluetoothCsipSetCoordinator;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothLeBroadcastAssistant;
import android.bluetooth.BluetoothLeBroadcastMetadata;
import android.bluetooth.BluetoothLeBroadcastReceiveState;
import android.bluetooth.BluetoothVolumeControl;
import android.content.ContentResolver;
import android.content.Context;
import android.database.ContentObserver;
import android.media.AudioManager;
import android.os.Handler;
import android.os.Looper;
import android.provider.Settings;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.lifecycle.LifecycleOwner;
import androidx.preference.Preference;
import androidx.preference.PreferenceGroup;
import androidx.preference.PreferenceScreen;
import com.android.settings.bluetooth.BluetoothDeviceUpdater;
import com.android.settings.bluetooth.Utils;
import com.android.settings.connecteddevice.DevicePreferenceCallback;
import com.android.settings.dashboard.DashboardFragment;
import com.android.settingslib.bluetooth.BluetoothUtils;
import com.android.settingslib.bluetooth.CachedBluetoothDevice;
import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
import com.android.settingslib.bluetooth.LocalBluetoothProfileManager;
import com.android.settingslib.bluetooth.VolumeControlProfile;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
public class AudioSharingDeviceVolumeGroupController extends AudioSharingBasePreferenceController
implements DevicePreferenceCallback {
private static final String TAG = "AudioSharingDeviceVolumeGroupController";
private static final String KEY = "audio_sharing_device_volume_group";
@Nullable private final LocalBluetoothManager mBtManager;
@Nullable private final LocalBluetoothProfileManager mProfileManager;
@Nullable private final LocalBluetoothLeBroadcastAssistant mAssistant;
@Nullable private final VolumeControlProfile mVolumeControl;
@Nullable private final ContentResolver mContentResolver;
@Nullable private BluetoothDeviceUpdater mBluetoothDeviceUpdater;
private final Executor mExecutor;
private final ContentObserver mSettingsObserver;
@Nullable private PreferenceGroup mPreferenceGroup;
private List<AudioSharingDeviceVolumePreference> mVolumePreferences = new ArrayList<>();
private Map<Integer, Integer> mValueMap = new HashMap<Integer, Integer>();
private AtomicBoolean mCallbacksRegistered = new AtomicBoolean(false);
private BluetoothVolumeControl.Callback mVolumeControlCallback =
new BluetoothVolumeControl.Callback() {
@Override
public void onVolumeOffsetChanged(
@NonNull BluetoothDevice device, int volumeOffset) {}
@Override
public void onDeviceVolumeChanged(
@NonNull BluetoothDevice device,
@IntRange(from = -255, to = 255) int volume) {
CachedBluetoothDevice cachedDevice =
mBtManager == null
? null
: mBtManager.getCachedDeviceManager().findDevice(device);
if (cachedDevice == null) return;
int groupId = AudioSharingUtils.getGroupId(cachedDevice);
mValueMap.put(groupId, volume);
for (AudioSharingDeviceVolumePreference preference : mVolumePreferences) {
if (preference.getCachedDevice() != null
&& AudioSharingUtils.getGroupId(preference.getCachedDevice())
== groupId) {
// If the callback return invalid volume, try to
// get the volume from AudioManager.STREAM_MUSIC
int finalVolume = getAudioVolumeIfNeeded(volume);
Log.d(
TAG,
"onDeviceVolumeChanged: set volume to "
+ finalVolume
+ " for "
+ device.getAnonymizedAddress());
mContext.getMainExecutor()
.execute(() -> preference.setProgress(finalVolume));
break;
}
}
}
};
private BluetoothLeBroadcastAssistant.Callback mBroadcastAssistantCallback =
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) {}
@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) {
Log.d(TAG, "onSourceRemoved: update volume list.");
if (mBluetoothDeviceUpdater != null) {
mBluetoothDeviceUpdater.forceUpdate();
}
}
@Override
public void onSourceRemoveFailed(
@NonNull BluetoothDevice sink, int sourceId, int reason) {}
@Override
public void onReceiveStateChanged(
@NonNull BluetoothDevice sink,
int sourceId,
@NonNull BluetoothLeBroadcastReceiveState state) {
if (BluetoothUtils.isConnected(state)) {
Log.d(TAG, "onReceiveStateChanged: synced, update volume list.");
if (mBluetoothDeviceUpdater != null) {
mBluetoothDeviceUpdater.forceUpdate();
}
}
}
};
public AudioSharingDeviceVolumeGroupController(Context context) {
super(context, KEY);
mBtManager = Utils.getLocalBtManager(mContext);
mProfileManager = mBtManager == null ? null : mBtManager.getProfileManager();
mAssistant =
mProfileManager == null
? null
: mProfileManager.getLeAudioBroadcastAssistantProfile();
mVolumeControl = mProfileManager == null ? null : mProfileManager.getVolumeControlProfile();
mExecutor = Executors.newSingleThreadExecutor();
mContentResolver = context.getContentResolver();
mSettingsObserver = new SettingsObserver();
}
private class SettingsObserver extends ContentObserver {
SettingsObserver() {
super(new Handler(Looper.getMainLooper()));
}
@Override
public void onChange(boolean selfChange) {
Log.d(TAG, "onChange, fallback device group id has been changed");
for (AudioSharingDeviceVolumePreference preference : mVolumePreferences) {
preference.setOrder(getPreferenceOrderForDevice(preference.getCachedDevice()));
}
}
}
@Override
public void onStart(@NonNull LifecycleOwner owner) {
super.onStart(owner);
registerCallbacks();
}
@Override
public void onStop(@NonNull LifecycleOwner owner) {
super.onStop(owner);
unregisterCallbacks();
}
@Override
public void onDestroy(@NonNull LifecycleOwner owner) {
mVolumePreferences.clear();
}
@Override
public void displayPreference(PreferenceScreen screen) {
super.displayPreference(screen);
mPreferenceGroup = screen.findPreference(KEY);
if (mPreferenceGroup != null) {
mPreferenceGroup.setVisible(false);
}
if (isAvailable() && mBluetoothDeviceUpdater != null) {
mBluetoothDeviceUpdater.setPrefContext(screen.getContext());
mBluetoothDeviceUpdater.forceUpdate();
}
}
@Override
public String getPreferenceKey() {
return KEY;
}
@Override
public void onDeviceAdded(Preference preference) {
if (mPreferenceGroup != null) {
if (mPreferenceGroup.getPreferenceCount() == 0) {
mPreferenceGroup.setVisible(true);
}
mPreferenceGroup.addPreference(preference);
}
if (preference instanceof AudioSharingDeviceVolumePreference) {
var volumePref = (AudioSharingDeviceVolumePreference) preference;
CachedBluetoothDevice cachedDevice = volumePref.getCachedDevice();
volumePref.setOrder(getPreferenceOrderForDevice(cachedDevice));
mVolumePreferences.add(volumePref);
if (volumePref.getProgress() > 0) return;
int volume = mValueMap.getOrDefault(AudioSharingUtils.getGroupId(cachedDevice), -1);
// If the volume is invalid, try to get the volume from AudioManager.STREAM_MUSIC
int finalVolume = getAudioVolumeIfNeeded(volume);
Log.d(
TAG,
"onDeviceAdded: set volume to "
+ finalVolume
+ " for "
+ cachedDevice.getDevice().getAnonymizedAddress());
AudioSharingUtils.postOnMainThread(mContext, () -> volumePref.setProgress(finalVolume));
}
}
@Override
public void onDeviceRemoved(Preference preference) {
if (mPreferenceGroup != null) {
mPreferenceGroup.removePreference(preference);
if (mPreferenceGroup.getPreferenceCount() == 0) {
mPreferenceGroup.setVisible(false);
}
}
if (preference instanceof AudioSharingDeviceVolumePreference) {
var volumePref = (AudioSharingDeviceVolumePreference) preference;
if (mVolumePreferences.contains(volumePref)) {
mVolumePreferences.remove(volumePref);
}
CachedBluetoothDevice device = volumePref.getCachedDevice();
Log.d(
TAG,
"onDeviceRemoved: "
+ (device == null
? "null"
: device.getDevice().getAnonymizedAddress()));
}
}
@Override
public void updateVisibility() {
if (mPreferenceGroup != null && mPreferenceGroup.getPreferenceCount() == 0) {
mPreferenceGroup.setVisible(false);
return;
}
super.updateVisibility();
}
@Override
public void onAudioSharingProfilesConnected() {
registerCallbacks();
}
/**
* 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(DashboardFragment fragment) {
mBluetoothDeviceUpdater =
new AudioSharingDeviceVolumeControlUpdater(
fragment.getContext(),
AudioSharingDeviceVolumeGroupController.this,
fragment.getMetricsCategory());
}
@VisibleForTesting
public void setDeviceUpdater(@Nullable AudioSharingDeviceVolumeControlUpdater updater) {
mBluetoothDeviceUpdater = updater;
}
/** Test only: set callback registration status in tests. */
@VisibleForTesting
public void setCallbacksRegistered(boolean registered) {
mCallbacksRegistered.set(registered);
}
/** Test only: set volume map in tests. */
@VisibleForTesting
public void setVolumeMap(@Nullable Map<Integer, Integer> map) {
mValueMap.clear();
mValueMap.putAll(map);
}
/** Test only: set value for private preferenceGroup in tests. */
@VisibleForTesting
public void setPreferenceGroup(@Nullable PreferenceGroup group) {
mPreferenceGroup = group;
mPreference = group;
}
@VisibleForTesting
ContentObserver getSettingsObserver() {
return mSettingsObserver;
}
private void registerCallbacks() {
if (!isAvailable()) {
Log.d(TAG, "Skip registerCallbacks(). Feature is not available.");
return;
}
if (mAssistant == null
|| mVolumeControl == null
|| mBluetoothDeviceUpdater == null
|| mContentResolver == null
|| !AudioSharingUtils.isAudioSharingProfileReady(mProfileManager)) {
Log.d(TAG, "Skip registerCallbacks(). Profile is not ready.");
return;
}
if (!mCallbacksRegistered.get()) {
Log.d(TAG, "registerCallbacks()");
mAssistant.registerServiceCallBack(mExecutor, mBroadcastAssistantCallback);
mVolumeControl.registerCallback(mExecutor, mVolumeControlCallback);
mBluetoothDeviceUpdater.registerCallback();
mContentResolver.registerContentObserver(
Settings.Secure.getUriFor(SETTINGS_KEY_FALLBACK_DEVICE_GROUP_ID),
false,
mSettingsObserver);
mCallbacksRegistered.set(true);
}
}
private void unregisterCallbacks() {
if (!isAvailable()) {
Log.d(TAG, "Skip unregister callbacks. Feature is not available.");
return;
}
if (mAssistant == null
|| mVolumeControl == null
|| mBluetoothDeviceUpdater == null
|| mContentResolver == null
|| !AudioSharingUtils.isAudioSharingProfileReady(mProfileManager)) {
Log.d(TAG, "Skip unregisterCallbacks(). Profile is not ready.");
return;
}
if (mCallbacksRegistered.get()) {
Log.d(TAG, "unregisterCallbacks()");
mAssistant.unregisterServiceCallBack(mBroadcastAssistantCallback);
mVolumeControl.unregisterCallback(mVolumeControlCallback);
mBluetoothDeviceUpdater.unregisterCallback();
mContentResolver.unregisterContentObserver(mSettingsObserver);
mValueMap.clear();
mCallbacksRegistered.set(false);
}
}
private int getAudioVolumeIfNeeded(int volume) {
if (volume >= 0) return volume;
try {
AudioManager audioManager = mContext.getSystemService(AudioManager.class);
int max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC);
int min = audioManager.getStreamMinVolume(AudioManager.STREAM_MUSIC);
return Math.round(
audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) * 255f / (max - min));
} catch (RuntimeException e) {
Log.e(TAG, "Fail to fetch current music stream volume, error = " + e);
return volume;
}
}
private int getPreferenceOrderForDevice(@NonNull CachedBluetoothDevice cachedDevice) {
int groupId = AudioSharingUtils.getGroupId(cachedDevice);
// The fallback device rank first among the audio sharing device list.
return (groupId != BluetoothCsipSetCoordinator.GROUP_ID_INVALID
&& groupId == AudioSharingUtils.getFallbackActiveGroupId(mContext))
? 0
: 1;
}
}

View File

@@ -0,0 +1,57 @@
/*
* Copyright (C) 2023 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 android.widget.SeekBar;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.android.settings.R;
import com.android.settings.widget.SeekBarPreference;
import com.android.settingslib.bluetooth.CachedBluetoothDevice;
public class AudioSharingDeviceVolumePreference extends SeekBarPreference {
public static final int MIN_VOLUME = 0;
public static final int MAX_VOLUME = 255;
private final CachedBluetoothDevice mCachedDevice;
@Nullable protected SeekBar mSeekBar;
public AudioSharingDeviceVolumePreference(
Context context, @NonNull CachedBluetoothDevice device) {
super(context);
setLayoutResource(R.layout.preference_volume_slider);
mCachedDevice = device;
}
@NonNull
public CachedBluetoothDevice getCachedDevice() {
return mCachedDevice;
}
/**
* Initialize {@link AudioSharingDeviceVolumePreference}.
*
* <p>Need to be called after creating the preference.
*/
public void initialize() {
setMax(MAX_VOLUME);
setMin(MIN_VOLUME);
}
}

View File

@@ -0,0 +1,348 @@
/*
* Copyright (C) 2024 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 android.content.DialogInterface;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.StringRes;
import androidx.appcompat.app.AlertDialog;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.android.settings.R;
import javax.annotation.CheckReturnValue;
public class AudioSharingDialogFactory {
private static final String TAG = "AudioSharingDialogFactory";
/**
* Initializes a builder for the dialog to be shown for audio sharing.
*
* @param context The {@link Context} that will be used to create the dialog.
* @return A configurable builder for the dialog.
*/
@NonNull
public static AudioSharingDialogFactory.DialogBuilder newBuilder(@NonNull Context context) {
return new AudioSharingDialogFactory.DialogBuilder(context);
}
/** Builder class with configurable options for the dialog to be shown for audio sharing. */
public static class DialogBuilder {
private Context mContext;
private AlertDialog.Builder mBuilder;
private View mCustomTitle;
private View mCustomBody;
private boolean mIsCustomBodyEnabled;
/**
* Private constructor for the dialog builder class. Should not be invoked directly;
* instead, use {@link AudioSharingDialogFactory#newBuilder(Context)}.
*
* @param context The {@link Context} that will be used to create the dialog.
*/
private DialogBuilder(@NonNull Context context) {
mContext = context;
mBuilder = new AlertDialog.Builder(context);
LayoutInflater inflater = LayoutInflater.from(mBuilder.getContext());
mCustomTitle =
inflater.inflate(R.layout.dialog_custom_title_audio_sharing, /* root= */ null);
mCustomBody =
inflater.inflate(R.layout.dialog_custom_body_audio_sharing, /* parent= */ null);
}
/**
* Sets title of the dialog custom title.
*
* @param titleRes Resource ID of the string to be used for the dialog title.
* @return This builder.
*/
@NonNull
public AudioSharingDialogFactory.DialogBuilder setTitle(@StringRes int titleRes) {
TextView title = mCustomTitle.findViewById(R.id.title_text);
title.setText(titleRes);
return this;
}
/**
* Sets title of the dialog custom title.
*
* @param titleText The text to be used for the title.
* @return This builder.
*/
@NonNull
public AudioSharingDialogFactory.DialogBuilder setTitle(@NonNull CharSequence titleText) {
TextView title = mCustomTitle.findViewById(R.id.title_text);
title.setText(titleText);
return this;
}
/**
* Sets the title icon of the dialog custom title.
*
* @param iconRes The text to be used for the title.
* @return This builder.
*/
@NonNull
public AudioSharingDialogFactory.DialogBuilder setTitleIcon(@DrawableRes int iconRes) {
ImageView icon = mCustomTitle.findViewById(R.id.title_icon);
icon.setImageResource(iconRes);
return this;
}
/**
* Sets the message body of the dialog.
*
* @param messageRes Resource ID of the string to be used for the message body.
* @return This builder.
*/
@NonNull
public AudioSharingDialogFactory.DialogBuilder setMessage(@StringRes int messageRes) {
mBuilder.setMessage(messageRes);
return this;
}
/**
* Sets the message body of the dialog.
*
* @param message The text to be used for the message body.
* @return This builder.
*/
@NonNull
public AudioSharingDialogFactory.DialogBuilder setMessage(@NonNull CharSequence message) {
mBuilder.setMessage(message);
return this;
}
/** Whether to use custom body. */
@NonNull
public AudioSharingDialogFactory.DialogBuilder setIsCustomBodyEnabled(
boolean isCustomBodyEnabled) {
mIsCustomBodyEnabled = isCustomBodyEnabled;
return this;
}
/**
* Sets the custom image of the dialog custom body.
*
* @param iconRes The text to be used for the title.
* @return This builder.
*/
@NonNull
public AudioSharingDialogFactory.DialogBuilder setCustomImage(@DrawableRes int iconRes) {
ImageView image = mCustomBody.findViewById(R.id.description_image);
image.setImageResource(iconRes);
image.setVisibility(View.VISIBLE);
return this;
}
/**
* Sets the custom message of the dialog custom body.
*
* @param messageRes Resource ID of the string to be used for the message body.
* @return This builder.
*/
@NonNull
public AudioSharingDialogFactory.DialogBuilder setCustomMessage(@StringRes int messageRes) {
TextView subTitle = mCustomBody.findViewById(R.id.description_text);
subTitle.setText(messageRes);
subTitle.setVisibility(View.VISIBLE);
return this;
}
/**
* Sets the custom message of the dialog custom body.
*
* @param message The text to be used for the custom message body.
* @return This builder.
*/
@NonNull
public AudioSharingDialogFactory.DialogBuilder setCustomMessage(
@NonNull CharSequence message) {
TextView subTitle = mCustomBody.findViewById(R.id.description_text);
subTitle.setText(message);
subTitle.setVisibility(View.VISIBLE);
return this;
}
/**
* Sets the custom device actions of the dialog custom body.
*
* @param adapter The adapter for device items to build dialog actions.
* @return This builder.
*/
@NonNull
public AudioSharingDialogFactory.DialogBuilder setCustomDeviceActions(
@NonNull AudioSharingDeviceAdapter adapter) {
RecyclerView recyclerView = mCustomBody.findViewById(R.id.device_btn_list);
recyclerView.setAdapter(adapter);
recyclerView.setLayoutManager(
new LinearLayoutManager(mContext, LinearLayoutManager.VERTICAL, false));
recyclerView.setVisibility(View.VISIBLE);
return this;
}
/**
* Sets the positive button label and listener for the dialog.
*
* @param labelRes Resource ID of the string to be used for the positive button label.
* @param listener The listener to be invoked when the positive button is pressed.
* @return This builder.
*/
@NonNull
public AudioSharingDialogFactory.DialogBuilder setPositiveButton(
@StringRes int labelRes, @NonNull DialogInterface.OnClickListener listener) {
mBuilder.setPositiveButton(labelRes, listener);
return this;
}
/**
* Sets the positive button label and listener for the dialog.
*
* @param label The text to be used for the positive button label.
* @param listener The listener to be invoked when the positive button is pressed.
* @return This builder.
*/
@NonNull
public AudioSharingDialogFactory.DialogBuilder setPositiveButton(
@NonNull CharSequence label, @NonNull DialogInterface.OnClickListener listener) {
mBuilder.setPositiveButton(label, listener);
return this;
}
/**
* Sets the custom positive button label and listener for the dialog custom body.
*
* @param labelRes Resource ID of the string to be used for the positive button label.
* @param listener The listener to be invoked when the positive button is pressed.
* @return This builder.
*/
@NonNull
public AudioSharingDialogFactory.DialogBuilder setCustomPositiveButton(
@StringRes int labelRes, @NonNull View.OnClickListener listener) {
Button positiveBtn = mCustomBody.findViewById(R.id.positive_btn);
positiveBtn.setText(labelRes);
positiveBtn.setOnClickListener(listener);
positiveBtn.setVisibility(View.VISIBLE);
return this;
}
/**
* Sets the custom positive button label and listener for the dialog custom body.
*
* @param label The text to be used for the positive button label.
* @param listener The listener to be invoked when the positive button is pressed.
* @return This builder.
*/
@NonNull
public AudioSharingDialogFactory.DialogBuilder setCustomPositiveButton(
@NonNull CharSequence label, @NonNull View.OnClickListener listener) {
Button positiveBtn = mCustomBody.findViewById(R.id.positive_btn);
positiveBtn.setText(label);
positiveBtn.setOnClickListener(listener);
positiveBtn.setVisibility(View.VISIBLE);
return this;
}
/**
* Sets the negative button label and listener for the dialog.
*
* @param labelRes Resource ID of the string to be used for the negative button label.
* @param listener The listener to be invoked when the negative button is pressed.
* @return This builder.
*/
@NonNull
public AudioSharingDialogFactory.DialogBuilder setNegativeButton(
@StringRes int labelRes, @NonNull DialogInterface.OnClickListener listener) {
mBuilder.setNegativeButton(labelRes, listener);
return this;
}
/**
* Sets the negative button label and listener for the dialog.
*
* @param label The text to be used for the negative button label.
* @param listener The listener to be invoked when the negative button is pressed.
* @return This builder.
*/
@NonNull
public AudioSharingDialogFactory.DialogBuilder setNegativeButton(
@NonNull CharSequence label, @NonNull DialogInterface.OnClickListener listener) {
mBuilder.setNegativeButton(label, listener);
return this;
}
/**
* Sets the custom negative button label and listener for the dialog custom body.
*
* @param labelRes Resource ID of the string to be used for the negative button label.
* @param listener The listener to be invoked when the negative button is pressed.
* @return This builder.
*/
@NonNull
public AudioSharingDialogFactory.DialogBuilder setCustomNegativeButton(
@StringRes int labelRes, @NonNull View.OnClickListener listener) {
Button negativeBtn = mCustomBody.findViewById(R.id.negative_btn);
negativeBtn.setText(labelRes);
negativeBtn.setOnClickListener(listener);
negativeBtn.setVisibility(View.VISIBLE);
return this;
}
/**
* Sets the custom negative button label and listener for the dialog custom body.
*
* @param label The text to be used for the negative button label.
* @param listener The listener to be invoked when the negative button is pressed.
* @return This builder.
*/
@NonNull
public AudioSharingDialogFactory.DialogBuilder setCustomNegativeButton(
@NonNull CharSequence label, @NonNull View.OnClickListener listener) {
Button negativeBtn = mCustomBody.findViewById(R.id.negative_btn);
negativeBtn.setText(label);
negativeBtn.setOnClickListener(listener);
negativeBtn.setVisibility(View.VISIBLE);
return this;
}
/**
* Builds a dialog with the current configs.
*
* @return The dialog to be shown for audio sharing.
*/
@NonNull
@CheckReturnValue
public AlertDialog build() {
if (mIsCustomBodyEnabled) {
mBuilder.setView(mCustomBody);
}
final AlertDialog dialog =
mBuilder.setCustomTitle(mCustomTitle).setCancelable(false).create();
dialog.setCanceledOnTouchOutside(false);
return dialog;
}
}
}

View File

@@ -0,0 +1,137 @@
/*
* Copyright (C) 2023 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.app.Dialog;
import android.app.settings.SettingsEnums;
import android.os.Bundle;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import com.android.settings.R;
import com.android.settings.core.instrumentation.InstrumentedDialogFragment;
import com.google.common.collect.Iterables;
import java.util.List;
public class AudioSharingDialogFragment extends InstrumentedDialogFragment {
private static final String TAG = "AudioSharingDialog";
private static final String BUNDLE_KEY_DEVICE_ITEMS = "bundle_key_device_items";
// The host creates an instance of this dialog fragment must implement this interface to receive
// event callbacks.
public interface DialogEventListener {
/**
* Called when users click the device item for sharing in the dialog.
*
* @param item The device item clicked.
*/
void onItemClick(AudioSharingDeviceItem item);
}
@Nullable private static DialogEventListener sListener;
@Override
public int getMetricsCategory() {
return SettingsEnums.DIALOG_START_AUDIO_SHARING;
}
/**
* Display the {@link AudioSharingDialogFragment} dialog.
*
* @param host The Fragment this dialog will be hosted.
* @param deviceItems The connected device items eligible for audio sharing.
* @param listener The callback to handle the user action on this dialog.
*/
public static void show(
@NonNull Fragment host,
@NonNull List<AudioSharingDeviceItem> deviceItems,
@NonNull DialogEventListener listener) {
if (!AudioSharingUtils.isFeatureEnabled()) return;
final FragmentManager manager = host.getChildFragmentManager();
sListener = listener;
AlertDialog dialog = AudioSharingDialogHelper.getDialogIfShowing(manager, TAG);
if (dialog != null) {
Log.d(TAG, "Dialog is showing, return.");
return;
}
Log.d(TAG, "Show up the dialog.");
final Bundle bundle = new Bundle();
bundle.putParcelableList(BUNDLE_KEY_DEVICE_ITEMS, deviceItems);
AudioSharingDialogFragment dialogFrag = new AudioSharingDialogFragment();
dialogFrag.setArguments(bundle);
dialogFrag.show(manager, TAG);
}
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
Bundle arguments = requireArguments();
List<AudioSharingDeviceItem> deviceItems =
arguments.getParcelable(BUNDLE_KEY_DEVICE_ITEMS, List.class);
AudioSharingDialogFactory.DialogBuilder builder =
AudioSharingDialogFactory.newBuilder(getActivity())
.setTitleIcon(com.android.settingslib.R.drawable.ic_bt_le_audio_sharing)
.setIsCustomBodyEnabled(true);
if (deviceItems.isEmpty()) {
builder.setTitle(R.string.audio_sharing_share_dialog_title)
.setCustomImage(R.drawable.audio_sharing_guidance)
.setCustomMessage(R.string.audio_sharing_dialog_connect_device_content)
.setNegativeButton(
R.string.audio_sharing_close_button_label, (dig, which) -> dismiss());
} else if (deviceItems.size() == 1) {
AudioSharingDeviceItem deviceItem = Iterables.getOnlyElement(deviceItems);
builder.setTitle(
getString(
R.string.audio_sharing_share_with_dialog_title,
deviceItem.getName()))
.setCustomMessage(R.string.audio_sharing_dialog_share_content)
.setCustomPositiveButton(
R.string.audio_sharing_share_button_label,
v -> {
if (sListener != null) {
sListener.onItemClick(deviceItem);
}
dismiss();
})
.setCustomNegativeButton(
R.string.audio_sharing_no_thanks_button_label, v -> dismiss());
} else {
builder.setTitle(R.string.audio_sharing_share_with_more_dialog_title)
.setCustomMessage(R.string.audio_sharing_dialog_share_more_content)
.setCustomDeviceActions(
new AudioSharingDeviceAdapter(
getContext(),
deviceItems,
(AudioSharingDeviceItem item) -> {
if (sListener != null) {
sListener.onItemClick(item);
}
dismiss();
},
AudioSharingDeviceAdapter.ActionType.SHARE))
.setCustomNegativeButton(com.android.settings.R.string.cancel, v -> dismiss());
}
return builder.build();
}
}

View File

@@ -0,0 +1,452 @@
/*
* Copyright (C) 2024 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.app.settings.SettingsEnums;
import android.bluetooth.BluetoothCsipSetCoordinator;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothLeBroadcast;
import android.bluetooth.BluetoothLeBroadcastMetadata;
import android.bluetooth.BluetoothLeBroadcastReceiveState;
import android.content.Context;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.DialogFragment;
import androidx.fragment.app.Fragment;
import com.android.settings.bluetooth.Utils;
import com.android.settings.core.SubSettingLauncher;
import com.android.settings.dashboard.DashboardFragment;
import com.android.settingslib.bluetooth.BluetoothUtils;
import com.android.settingslib.bluetooth.CachedBluetoothDevice;
import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast;
import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executor;
public class AudioSharingDialogHandler {
private static final String TAG = "AudioSharingDialogHandler";
private final Context mContext;
private final Fragment mHostFragment;
@Nullable private final LocalBluetoothManager mLocalBtManager;
@Nullable private final LocalBluetoothLeBroadcast mBroadcast;
@Nullable private final LocalBluetoothLeBroadcastAssistant mAssistant;
private List<BluetoothDevice> mTargetSinks = new ArrayList<>();
private final BluetoothLeBroadcast.Callback mBroadcastCallback =
new BluetoothLeBroadcast.Callback() {
@Override
public void onBroadcastStarted(int reason, int broadcastId) {
Log.d(
TAG,
"onBroadcastStarted(), reason = "
+ reason
+ ", broadcastId = "
+ broadcastId);
}
@Override
public void onBroadcastStartFailed(int reason) {
Log.d(TAG, "onBroadcastStartFailed(), reason = " + reason);
AudioSharingUtils.toastMessage(
mContext, "Fail to start broadcast, reason " + reason);
}
@Override
public void onBroadcastMetadataChanged(
int broadcastId, @NonNull BluetoothLeBroadcastMetadata metadata) {
Log.d(
TAG,
"onBroadcastMetadataChanged(), broadcastId = "
+ broadcastId
+ ", metadata = "
+ metadata);
}
@Override
public void onBroadcastStopped(int reason, int broadcastId) {
Log.d(
TAG,
"onBroadcastStopped(), reason = "
+ reason
+ ", broadcastId = "
+ broadcastId);
}
@Override
public void onBroadcastStopFailed(int reason) {
Log.d(TAG, "onBroadcastStopFailed(), reason = " + reason);
AudioSharingUtils.toastMessage(
mContext, "Fail to stop broadcast, reason " + reason);
}
@Override
public void onBroadcastUpdated(int reason, int broadcastId) {}
@Override
public void onBroadcastUpdateFailed(int reason, int broadcastId) {}
@Override
public void onPlaybackStarted(int reason, int broadcastId) {
Log.d(
TAG,
"onPlaybackStarted(), reason = "
+ reason
+ ", broadcastId = "
+ broadcastId);
if (!mTargetSinks.isEmpty()) {
AudioSharingUtils.addSourceToTargetSinks(mTargetSinks, mLocalBtManager);
new SubSettingLauncher(mContext)
.setDestination(AudioSharingDashboardFragment.class.getName())
.setSourceMetricsCategory(
(mHostFragment != null
&& mHostFragment
instanceof DashboardFragment)
? ((DashboardFragment) mHostFragment)
.getMetricsCategory()
: SettingsEnums.PAGE_UNKNOWN)
.launch();
mTargetSinks = new ArrayList<>();
}
}
@Override
public void onPlaybackStopped(int reason, int broadcastId) {}
};
public AudioSharingDialogHandler(@NonNull Context context, @NonNull Fragment fragment) {
mContext = context;
mHostFragment = fragment;
mLocalBtManager = Utils.getLocalBluetoothManager(context);
mBroadcast =
mLocalBtManager != null
? mLocalBtManager.getProfileManager().getLeAudioBroadcastProfile()
: null;
mAssistant =
mLocalBtManager != null
? mLocalBtManager.getProfileManager().getLeAudioBroadcastAssistantProfile()
: null;
}
/** Register callbacks for dialog handler */
public void registerCallbacks(Executor executor) {
if (mBroadcast != null) {
mBroadcast.registerServiceCallBack(executor, mBroadcastCallback);
}
}
/** Unregister callbacks for dialog handler */
public void unregisterCallbacks() {
if (mBroadcast != null) {
mBroadcast.unregisterServiceCallBack(mBroadcastCallback);
}
}
/** Handle dialog pop-up logic when device is connected. */
public void handleDeviceConnected(
@NonNull CachedBluetoothDevice cachedDevice, boolean userTriggered) {
String anonymizedAddress = cachedDevice.getDevice().getAnonymizedAddress();
boolean isBroadcasting = isBroadcasting();
boolean isLeAudioSupported = AudioSharingUtils.isLeAudioSupported(cachedDevice);
if (!isLeAudioSupported) {
Log.d(TAG, "Handle non LE audio device connected, device = " + anonymizedAddress);
// Handle connected ineligible (non LE audio) remote device
handleNonLeAudioDeviceConnected(cachedDevice, isBroadcasting, userTriggered);
} else {
Log.d(TAG, "Handle LE audio device connected, device = " + anonymizedAddress);
// Handle connected eligible (LE audio) remote device
handleLeAudioDeviceConnected(cachedDevice, isBroadcasting, userTriggered);
}
}
private void handleNonLeAudioDeviceConnected(
@NonNull CachedBluetoothDevice cachedDevice,
boolean isBroadcasting,
boolean userTriggered) {
if (isBroadcasting) {
// Show stop audio sharing dialog when an ineligible (non LE audio) remote device
// connected during a sharing session.
Map<Integer, List<CachedBluetoothDevice>> groupedDevices =
AudioSharingUtils.fetchConnectedDevicesByGroupId(mLocalBtManager);
List<AudioSharingDeviceItem> deviceItemsInSharingSession =
AudioSharingUtils.buildOrderedConnectedLeadAudioSharingDeviceItem(
mLocalBtManager, groupedDevices, /* filterByInSharing= */ true);
postOnMainThread(
() -> {
closeOpeningDialogsOtherThan(AudioSharingStopDialogFragment.tag());
AudioSharingStopDialogFragment.show(
mHostFragment,
deviceItemsInSharingSession,
cachedDevice,
() -> {
cachedDevice.setActive();
AudioSharingUtils.stopBroadcasting(mLocalBtManager);
});
});
} else {
if (userTriggered) {
cachedDevice.setActive();
}
// Do nothing for ineligible (non LE audio) remote device when no sharing session.
Log.d(
TAG,
"Ignore onProfileConnectionStateChanged for non LE audio without"
+ " sharing session");
}
}
private void handleLeAudioDeviceConnected(
@NonNull CachedBluetoothDevice cachedDevice,
boolean isBroadcasting,
boolean userTriggered) {
Map<Integer, List<CachedBluetoothDevice>> groupedDevices =
AudioSharingUtils.fetchConnectedDevicesByGroupId(mLocalBtManager);
if (isBroadcasting) {
// If another device within the same is already in the sharing session, add source to
// the device automatically.
int groupId = AudioSharingUtils.getGroupId(cachedDevice);
if (groupedDevices.containsKey(groupId)
&& groupedDevices.get(groupId).stream()
.anyMatch(
device ->
BluetoothUtils.hasConnectedBroadcastSource(
device, mLocalBtManager))) {
Log.d(
TAG,
"Automatically add another device within the same group to the sharing: "
+ cachedDevice.getDevice().getAnonymizedAddress());
if (mAssistant != null && mBroadcast != null) {
mAssistant.addSource(
cachedDevice.getDevice(),
mBroadcast.getLatestBluetoothLeBroadcastMetadata(),
/* isGroupOp= */ false);
}
return;
}
// Show audio sharing switch or join dialog according to device count in the sharing
// session.
List<AudioSharingDeviceItem> deviceItemsInSharingSession =
AudioSharingUtils.buildOrderedConnectedLeadAudioSharingDeviceItem(
mLocalBtManager, groupedDevices, /* filterByInSharing= */ true);
// Show audio sharing switch dialog when the third eligible (LE audio) remote device
// connected during a sharing session.
if (deviceItemsInSharingSession.size() >= 2) {
postOnMainThread(
() -> {
closeOpeningDialogsOtherThan(
AudioSharingDisconnectDialogFragment.tag());
AudioSharingDisconnectDialogFragment.show(
mHostFragment,
deviceItemsInSharingSession,
cachedDevice,
(AudioSharingDeviceItem item) -> {
// Remove all sources from the device user clicked
removeSourceForGroup(item.getGroupId(), groupedDevices);
// Add current broadcast to the latest connected device
addSourceForGroup(groupId, groupedDevices);
});
});
} else {
// Show audio sharing join dialog when the first or second eligible (LE audio)
// remote device connected during a sharing session.
postOnMainThread(
() -> {
closeOpeningDialogsOtherThan(AudioSharingJoinDialogFragment.tag());
AudioSharingJoinDialogFragment.show(
mHostFragment,
deviceItemsInSharingSession,
cachedDevice,
new AudioSharingJoinDialogFragment.DialogEventListener() {
@Override
public void onShareClick() {
addSourceForGroup(groupId, groupedDevices);
}
@Override
public void onCancelClick() {}
});
});
}
} else {
List<AudioSharingDeviceItem> deviceItems = new ArrayList<>();
for (List<CachedBluetoothDevice> devices : groupedDevices.values()) {
// Use random device in the group within the sharing session to represent the group.
CachedBluetoothDevice device = devices.get(0);
if (AudioSharingUtils.getGroupId(device)
== AudioSharingUtils.getGroupId(cachedDevice)) {
continue;
}
deviceItems.add(AudioSharingUtils.buildAudioSharingDeviceItem(device));
}
// Show audio sharing join dialog when the second eligible (LE audio) remote
// device connect and no sharing session.
if (deviceItems.size() == 1) {
postOnMainThread(
() -> {
closeOpeningDialogsOtherThan(AudioSharingJoinDialogFragment.tag());
AudioSharingJoinDialogFragment.show(
mHostFragment,
deviceItems,
cachedDevice,
new AudioSharingJoinDialogFragment.DialogEventListener() {
@Override
public void onShareClick() {
mTargetSinks = new ArrayList<>();
for (List<CachedBluetoothDevice> devices :
groupedDevices.values()) {
for (CachedBluetoothDevice device : devices) {
mTargetSinks.add(device.getDevice());
}
}
Log.d(
TAG,
"Start broadcast with sinks: "
+ mTargetSinks.size());
if (mBroadcast != null) {
mBroadcast.startPrivateBroadcast();
}
}
@Override
public void onCancelClick() {
if (userTriggered) {
cachedDevice.setActive();
}
}
});
});
} else if (userTriggered) {
cachedDevice.setActive();
}
}
}
private void closeOpeningDialogsOtherThan(String tag) {
if (mHostFragment == null) return;
List<Fragment> fragments = mHostFragment.getChildFragmentManager().getFragments();
for (Fragment fragment : fragments) {
if (fragment instanceof DialogFragment && !fragment.getTag().equals(tag)) {
Log.d(TAG, "Remove staled opening dialog " + fragment.getTag());
((DialogFragment) fragment).dismiss();
}
}
}
/** Close opening dialogs for le audio device */
public void closeOpeningDialogsForLeaDevice(@NonNull CachedBluetoothDevice cachedDevice) {
if (mHostFragment == null) return;
int groupId = AudioSharingUtils.getGroupId(cachedDevice);
List<Fragment> fragments = mHostFragment.getChildFragmentManager().getFragments();
for (Fragment fragment : fragments) {
CachedBluetoothDevice device = getCachedBluetoothDeviceFromDialog(fragment);
if (device != null
&& groupId != BluetoothCsipSetCoordinator.GROUP_ID_INVALID
&& AudioSharingUtils.getGroupId(device) == groupId) {
Log.d(TAG, "Remove staled opening dialog for group " + groupId);
((DialogFragment) fragment).dismiss();
}
}
}
/** Close opening dialogs for non le audio device */
public void closeOpeningDialogsForNonLeaDevice(@NonNull CachedBluetoothDevice cachedDevice) {
if (mHostFragment == null) return;
String address = cachedDevice.getAddress();
List<Fragment> fragments = mHostFragment.getChildFragmentManager().getFragments();
for (Fragment fragment : fragments) {
CachedBluetoothDevice device = getCachedBluetoothDeviceFromDialog(fragment);
if (device != null && address != null && address.equals(device.getAddress())) {
Log.d(
TAG,
"Remove staled opening dialog for device "
+ cachedDevice.getDevice().getAnonymizedAddress());
((DialogFragment) fragment).dismiss();
}
}
}
@Nullable
private CachedBluetoothDevice getCachedBluetoothDeviceFromDialog(Fragment fragment) {
CachedBluetoothDevice device = null;
if (fragment instanceof AudioSharingJoinDialogFragment) {
device = ((AudioSharingJoinDialogFragment) fragment).getDevice();
} else if (fragment instanceof AudioSharingStopDialogFragment) {
device = ((AudioSharingStopDialogFragment) fragment).getDevice();
} else if (fragment instanceof AudioSharingDisconnectDialogFragment) {
device = ((AudioSharingDisconnectDialogFragment) fragment).getDevice();
}
return device;
}
private void removeSourceForGroup(
int groupId, Map<Integer, List<CachedBluetoothDevice>> groupedDevices) {
if (mAssistant == null) {
Log.d(TAG, "Fail to add source due to null profiles, group = " + groupId);
return;
}
if (!groupedDevices.containsKey(groupId)) {
Log.d(TAG, "Fail to remove source for group " + groupId);
return;
}
groupedDevices.get(groupId).stream()
.map(CachedBluetoothDevice::getDevice)
.filter(device -> device != null)
.forEach(
device -> {
for (BluetoothLeBroadcastReceiveState source :
mAssistant.getAllSources(device)) {
mAssistant.removeSource(device, source.getSourceId());
}
});
}
private void addSourceForGroup(
int groupId, Map<Integer, List<CachedBluetoothDevice>> groupedDevices) {
if (mBroadcast == null || mAssistant == null) {
Log.d(TAG, "Fail to add source due to null profiles, group = " + groupId);
return;
}
if (!groupedDevices.containsKey(groupId)) {
Log.d(TAG, "Fail to add source due to invalid group id, group = " + groupId);
return;
}
groupedDevices.get(groupId).stream()
.map(CachedBluetoothDevice::getDevice)
.filter(device -> device != null)
.forEach(
device ->
mAssistant.addSource(
device,
mBroadcast.getLatestBluetoothLeBroadcastMetadata(),
/* isGroupOp= */ false));
}
private void postOnMainThread(@NonNull Runnable runnable) {
mContext.getMainExecutor().execute(runnable);
}
private boolean isBroadcasting() {
return mBroadcast != null && mBroadcast.isEnabled(null);
}
}

View File

@@ -0,0 +1,62 @@
/*
* Copyright (C) 2024 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.graphics.Typeface;
import android.util.Log;
import android.util.TypedValue;
import android.view.View;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.DialogFragment;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
public class AudioSharingDialogHelper {
private static final String TAG = "AudioSharingDialogHelper";
/** Updates the alert dialog message style. */
public static void updateMessageStyle(@NonNull AlertDialog dialog) {
TextView messageView = dialog.findViewById(android.R.id.message);
if (messageView != null) {
Typeface typeface = Typeface.create(Typeface.DEFAULT_FAMILY, Typeface.NORMAL);
messageView.setTypeface(typeface);
messageView.setTextDirection(View.TEXT_DIRECTION_LOCALE);
messageView.setTextAlignment(View.TEXT_ALIGNMENT_CENTER);
messageView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 14);
} else {
Log.w(TAG, "Fail to update dialog: message view is null");
}
}
/** Returns the alert dialog by tag if it is showing. */
@Nullable
public static AlertDialog getDialogIfShowing(
@NonNull FragmentManager manager, @NonNull String tag) {
Fragment dialog = manager.findFragmentByTag(tag);
return dialog != null
&& dialog instanceof DialogFragment
&& ((DialogFragment) dialog).getDialog() != null
&& ((DialogFragment) dialog).getDialog().isShowing()
&& ((DialogFragment) dialog).getDialog() instanceof AlertDialog
? (AlertDialog) ((DialogFragment) dialog).getDialog()
: null;
}
}

View File

@@ -0,0 +1,152 @@
/*
* Copyright (C) 2023 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.app.Dialog;
import android.app.settings.SettingsEnums;
import android.os.Bundle;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import com.android.settings.R;
import com.android.settings.core.instrumentation.InstrumentedDialogFragment;
import com.android.settingslib.bluetooth.CachedBluetoothDevice;
import java.util.List;
import java.util.Locale;
public class AudioSharingDisconnectDialogFragment extends InstrumentedDialogFragment {
private static final String TAG = "AudioSharingDisconnectDialog";
private static final String BUNDLE_KEY_DEVICE_TO_DISCONNECT_ITEMS =
"bundle_key_device_to_disconnect_items";
private static final String BUNDLE_KEY_NEW_DEVICE_NAME = "bundle_key_new_device_name";
// The host creates an instance of this dialog fragment must implement this interface to receive
// event callbacks.
public interface DialogEventListener {
/**
* Called when users click the device item to disconnect from the audio sharing in the
* dialog.
*
* @param item The device item clicked.
*/
void onItemClick(AudioSharingDeviceItem item);
}
@Nullable private static DialogEventListener sListener;
@Nullable private static CachedBluetoothDevice sNewDevice;
@Override
public int getMetricsCategory() {
return SettingsEnums.DIALOG_AUDIO_SHARING_SWITCH_DEVICE;
}
/**
* Display the {@link AudioSharingDisconnectDialogFragment} dialog.
*
* <p>If the dialog is showing for the same group, update the dialog event listener.
*
* @param host The Fragment this dialog will be hosted.
* @param deviceItems The existing connected device items in audio sharing session.
* @param newDevice The latest connected device triggered this dialog.
* @param listener The callback to handle the user action on this dialog.
*/
public static void show(
@NonNull Fragment host,
@NonNull List<AudioSharingDeviceItem> deviceItems,
@NonNull CachedBluetoothDevice newDevice,
@NonNull DialogEventListener listener) {
if (!AudioSharingUtils.isFeatureEnabled()) return;
FragmentManager manager = host.getChildFragmentManager();
AlertDialog dialog = AudioSharingDialogHelper.getDialogIfShowing(manager, TAG);
if (dialog != null) {
int newGroupId = AudioSharingUtils.getGroupId(newDevice);
if (sNewDevice != null && newGroupId == AudioSharingUtils.getGroupId(sNewDevice)) {
Log.d(
TAG,
String.format(
Locale.US,
"Dialog is showing for the same device group %d, "
+ "update the content.",
newGroupId));
sListener = listener;
sNewDevice = newDevice;
return;
} else {
Log.d(
TAG,
String.format(
Locale.US,
"Dialog is showing for new device group %d, "
+ "dismiss current dialog.",
newGroupId));
dialog.dismiss();
}
}
sListener = listener;
sNewDevice = newDevice;
Log.d(TAG, "Show up the dialog.");
final Bundle bundle = new Bundle();
bundle.putParcelableList(BUNDLE_KEY_DEVICE_TO_DISCONNECT_ITEMS, deviceItems);
bundle.putString(BUNDLE_KEY_NEW_DEVICE_NAME, newDevice.getName());
AudioSharingDisconnectDialogFragment dialogFrag =
new AudioSharingDisconnectDialogFragment();
dialogFrag.setArguments(bundle);
dialogFrag.show(manager, TAG);
}
/** Return the tag of {@link AudioSharingDisconnectDialogFragment} dialog. */
public static @NonNull String tag() {
return TAG;
}
/** Get the latest connected device which triggers the dialog. */
public @Nullable CachedBluetoothDevice getDevice() {
return sNewDevice;
}
@Override
public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
Bundle arguments = requireArguments();
List<AudioSharingDeviceItem> deviceItems =
arguments.getParcelable(BUNDLE_KEY_DEVICE_TO_DISCONNECT_ITEMS, List.class);
return AudioSharingDialogFactory.newBuilder(getActivity())
.setTitle(R.string.audio_sharing_disconnect_dialog_title)
.setTitleIcon(com.android.settingslib.R.drawable.ic_bt_le_audio_sharing)
.setIsCustomBodyEnabled(true)
.setCustomMessage(R.string.audio_sharing_dialog_disconnect_content)
.setCustomDeviceActions(
new AudioSharingDeviceAdapter(
getContext(),
deviceItems,
(AudioSharingDeviceItem item) -> {
if (sListener != null) {
sListener.onItemClick(item);
}
dismiss();
},
AudioSharingDeviceAdapter.ActionType.REMOVE))
.setCustomNegativeButton(com.android.settings.R.string.cancel, v -> dismiss())
.build();
}
}

View File

@@ -1,53 +0,0 @@
/*
* Copyright (C) 2024 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.annotation.Nullable;
import com.android.settings.dashboard.DashboardFragment;
import com.android.settingslib.bluetooth.CachedBluetoothDevice;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
import com.android.settingslib.core.AbstractPreferenceController;
import com.android.settingslib.core.lifecycle.Lifecycle;
/** Feature provider for the audio sharing related features, */
public interface AudioSharingFeatureProvider {
/** Create audio sharing device preference controller. */
@Nullable
AbstractPreferenceController createAudioSharingDevicePreferenceController(
@NonNull Context context,
@Nullable DashboardFragment fragment,
@Nullable Lifecycle lifecycle);
/** Create available media device preference controller. */
AbstractPreferenceController createAvailableMediaDeviceGroupController(
@NonNull Context context,
@Nullable DashboardFragment fragment,
@Nullable Lifecycle lifecycle);
/**
* Check if the device match the audio sharing filter.
*
* <p>The filter is used to filter device in "Media devices" section.
*/
boolean isAudioSharingFilterMatched(
@NonNull CachedBluetoothDevice cachedDevice, LocalBluetoothManager localBtManager);
}

View File

@@ -1,55 +0,0 @@
/*
* Copyright (C) 2024 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.annotation.Nullable;
import com.android.settings.connecteddevice.AvailableMediaDeviceGroupController;
import com.android.settings.dashboard.DashboardFragment;
import com.android.settingslib.bluetooth.CachedBluetoothDevice;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
import com.android.settingslib.core.AbstractPreferenceController;
import com.android.settingslib.core.lifecycle.Lifecycle;
public class AudioSharingFeatureProviderImpl implements AudioSharingFeatureProvider {
@Nullable
@Override
public AbstractPreferenceController createAudioSharingDevicePreferenceController(
@NonNull Context context,
@Nullable DashboardFragment fragment,
@Nullable Lifecycle lifecycle) {
return null;
}
@Override
public AbstractPreferenceController createAvailableMediaDeviceGroupController(
@NonNull Context context,
@Nullable DashboardFragment fragment,
@Nullable Lifecycle lifecycle) {
return new AvailableMediaDeviceGroupController(context, fragment, lifecycle);
}
@Override
public boolean isAudioSharingFilterMatched(
@NonNull CachedBluetoothDevice cachedDevice, LocalBluetoothManager localBtManager) {
return false;
}
}

View File

@@ -0,0 +1,159 @@
/*
* Copyright (C) 2023 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.app.Dialog;
import android.app.settings.SettingsEnums;
import android.os.Bundle;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import com.android.settings.R;
import com.android.settings.bluetooth.Utils;
import com.android.settings.core.instrumentation.InstrumentedDialogFragment;
import com.android.settingslib.bluetooth.CachedBluetoothDevice;
import java.util.List;
public class AudioSharingJoinDialogFragment extends InstrumentedDialogFragment {
private static final String TAG = "AudioSharingJoinDialog";
private static final String BUNDLE_KEY_DEVICE_ITEMS = "bundle_key_device_items";
private static final String BUNDLE_KEY_NEW_DEVICE_NAME = "bundle_key_new_device_name";
// The host creates an instance of this dialog fragment must implement this interface to receive
// event callbacks.
public interface DialogEventListener {
/** Called when users click the share audio button in the dialog. */
void onShareClick();
/** Called when users click the cancel button in the dialog. */
void onCancelClick();
}
@Nullable private static DialogEventListener sListener;
@Nullable private static CachedBluetoothDevice sNewDevice;
@Override
public int getMetricsCategory() {
return AudioSharingUtils.isBroadcasting(Utils.getLocalBtManager(getContext()))
? SettingsEnums.DIALOG_START_AUDIO_SHARING
: SettingsEnums.DIALOG_START_AUDIO_SHARING;
}
/**
* Display the {@link AudioSharingJoinDialogFragment} dialog.
*
* <p>If the dialog is showing, update the dialog message and event listener.
*
* @param host The Fragment this dialog will be hosted.
* @param deviceItems The existing connected device items eligible for audio sharing.
* @param newDevice The latest connected device triggered this dialog.
* @param listener The callback to handle the user action on this dialog.
*/
public static void show(
@NonNull Fragment host,
@NonNull List<AudioSharingDeviceItem> deviceItems,
@NonNull CachedBluetoothDevice newDevice,
@NonNull DialogEventListener listener) {
if (!AudioSharingUtils.isFeatureEnabled()) return;
final FragmentManager manager = host.getChildFragmentManager();
sListener = listener;
sNewDevice = newDevice;
AlertDialog dialog = AudioSharingDialogHelper.getDialogIfShowing(manager, TAG);
if (dialog != null) {
Log.d(TAG, "Dialog is showing, update the content.");
updateDialog(deviceItems, newDevice.getName(), dialog);
} else {
Log.d(TAG, "Show up the dialog.");
final Bundle bundle = new Bundle();
bundle.putParcelableList(BUNDLE_KEY_DEVICE_ITEMS, deviceItems);
bundle.putString(BUNDLE_KEY_NEW_DEVICE_NAME, newDevice.getName());
final AudioSharingJoinDialogFragment dialogFrag = new AudioSharingJoinDialogFragment();
dialogFrag.setArguments(bundle);
dialogFrag.show(manager, TAG);
}
}
/** Return the tag of {@link AudioSharingJoinDialogFragment} dialog. */
public static @NonNull String tag() {
return TAG;
}
/** Get the latest connected device which triggers the dialog. */
public @Nullable CachedBluetoothDevice getDevice() {
return sNewDevice;
}
@Override
public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
Bundle arguments = requireArguments();
List<AudioSharingDeviceItem> deviceItems =
arguments.getParcelable(BUNDLE_KEY_DEVICE_ITEMS, List.class);
String newDeviceName = arguments.getString(BUNDLE_KEY_NEW_DEVICE_NAME);
AlertDialog dialog =
AudioSharingDialogFactory.newBuilder(getActivity())
.setTitle(R.string.audio_sharing_share_dialog_title)
.setTitleIcon(com.android.settingslib.R.drawable.ic_bt_le_audio_sharing)
.setIsCustomBodyEnabled(true)
.setCustomMessage(R.string.audio_sharing_dialog_share_content)
.setCustomPositiveButton(
R.string.audio_sharing_share_button_label,
v -> {
if (sListener != null) {
sListener.onShareClick();
}
dismiss();
})
.setCustomNegativeButton(
R.string.audio_sharing_no_thanks_button_label,
v -> {
if (sListener != null) {
sListener.onCancelClick();
}
dismiss();
})
.build();
updateDialog(deviceItems, newDeviceName, dialog);
dialog.show();
AudioSharingDialogHelper.updateMessageStyle(dialog);
return dialog;
}
private static void updateDialog(
List<AudioSharingDeviceItem> deviceItems,
String newDeviceName,
@NonNull AlertDialog dialog) {
// Only dialog message can be updated when the dialog is showing.
// Thus we put the device name for sharing as the dialog message.
if (deviceItems.isEmpty()) {
dialog.setMessage(newDeviceName);
} else {
dialog.setMessage(
dialog.getContext()
.getString(
R.string.audio_sharing_share_dialog_subtitle,
deviceItems.get(0).getName(),
newDeviceName));
}
}
}

View File

@@ -0,0 +1,110 @@
/*
* Copyright (C) 2023 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.app.settings.SettingsEnums;
import android.content.Context;
import android.util.AttributeSet;
import android.util.Log;
import android.view.View;
import android.widget.ImageButton;
import androidx.preference.PreferenceViewHolder;
import com.android.settings.R;
import com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsQrCodeFragment;
import com.android.settings.core.SubSettingLauncher;
import com.android.settings.widget.ValidatedEditTextPreference;
public class AudioSharingNamePreference extends ValidatedEditTextPreference {
private static final String TAG = "AudioSharingNamePreference";
private boolean mShowQrCodeIcon = false;
public AudioSharingNamePreference(
Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
initialize();
}
public AudioSharingNamePreference(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initialize();
}
public AudioSharingNamePreference(Context context, AttributeSet attrs) {
super(context, attrs);
initialize();
}
public AudioSharingNamePreference(Context context) {
super(context);
initialize();
}
private void initialize() {
setLayoutResource(
com.android.settingslib.widget.preference.twotarget.R.layout.preference_two_target);
setWidgetLayoutResource(R.layout.preference_widget_qrcode);
}
void setShowQrCodeIcon(boolean show) {
mShowQrCodeIcon = show;
notifyChanged();
}
@Override
public void onBindViewHolder(PreferenceViewHolder holder) {
super.onBindViewHolder(holder);
ImageButton shareButton = (ImageButton) holder.findViewById(R.id.button_icon);
View divider =
holder.findViewById(
com.android.settingslib.widget.preference.twotarget.R.id
.two_target_divider);
if (shareButton != null && divider != null) {
if (mShowQrCodeIcon) {
configureVisibleStateForQrCodeIcon(shareButton, divider);
} else {
configureInvisibleStateForQrCodeIcon(shareButton, divider);
}
} else {
Log.w(TAG, "onBindViewHolder() : shareButton or divider is null!");
}
}
private void configureVisibleStateForQrCodeIcon(ImageButton shareButton, View divider) {
divider.setVisibility(View.VISIBLE);
shareButton.setVisibility(View.VISIBLE);
shareButton.setImageDrawable(getContext().getDrawable(R.drawable.ic_qrcode_24dp));
shareButton.setOnClickListener(unused -> launchAudioSharingQrCodeFragment());
}
private void configureInvisibleStateForQrCodeIcon(ImageButton shareButton, View divider) {
divider.setVisibility(View.INVISIBLE);
shareButton.setVisibility(View.INVISIBLE);
shareButton.setOnClickListener(null);
}
private void launchAudioSharingQrCodeFragment() {
new SubSettingLauncher(getContext())
.setTitleText(getContext().getString(R.string.audio_streams_qr_code_page_title))
.setDestination(AudioStreamsQrCodeFragment.class.getName())
.setSourceMetricsCategory(SettingsEnums.AUDIO_SHARING_SETTINGS)
.launch();
}
}

View File

@@ -0,0 +1,275 @@
/*
* Copyright (C) 2023 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.connecteddevice.audiosharing.AudioSharingUtils.isBroadcasting;
import android.bluetooth.BluetoothLeBroadcast;
import android.bluetooth.BluetoothLeBroadcastMetadata;
import android.content.Context;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.LifecycleOwner;
import androidx.preference.Preference;
import androidx.preference.PreferenceScreen;
import com.android.settings.bluetooth.Utils;
import com.android.settings.core.BasePreferenceController;
import com.android.settings.widget.ValidatedEditTextPreference;
import com.android.settingslib.bluetooth.BluetoothUtils;
import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
import com.android.settingslib.bluetooth.LocalBluetoothProfileManager;
import com.android.settingslib.utils.ThreadUtils;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
public class AudioSharingNamePreferenceController extends BasePreferenceController
implements ValidatedEditTextPreference.Validator,
Preference.OnPreferenceChangeListener,
DefaultLifecycleObserver,
LocalBluetoothProfileManager.ServiceListener {
private static final String TAG = "AudioSharingNamePreferenceController";
private static final boolean DEBUG = BluetoothUtils.D;
private static final String PREF_KEY = "audio_sharing_stream_name";
private final BluetoothLeBroadcast.Callback mBroadcastCallback =
new BluetoothLeBroadcast.Callback() {
@Override
public void onBroadcastMetadataChanged(
int broadcastId, BluetoothLeBroadcastMetadata metadata) {
if (DEBUG) {
Log.d(
TAG,
"onBroadcastMetadataChanged() broadcastId : "
+ broadcastId
+ " metadata: "
+ metadata);
}
updateQrCodeIcon(true);
}
@Override
public void onBroadcastStartFailed(int reason) {}
@Override
public void onBroadcastStarted(int reason, int broadcastId) {}
@Override
public void onBroadcastStopFailed(int reason) {}
@Override
public void onBroadcastStopped(int reason, int broadcastId) {
if (DEBUG) {
Log.d(
TAG,
"onBroadcastStopped() reason : "
+ reason
+ " broadcastId: "
+ broadcastId);
}
updateQrCodeIcon(false);
}
@Override
public void onBroadcastUpdateFailed(int reason, int broadcastId) {
Log.w(TAG, "onBroadcastUpdateFailed() reason : " + reason);
}
@Override
public void onBroadcastUpdated(int reason, int broadcastId) {
if (DEBUG) {
Log.d(TAG, "onBroadcastUpdated() reason : " + reason);
}
}
@Override
public void onPlaybackStarted(int reason, int broadcastId) {}
@Override
public void onPlaybackStopped(int reason, int broadcastId) {}
};
@Nullable private final LocalBluetoothManager mBtManager;
@Nullable private final LocalBluetoothProfileManager mProfileManager;
@Nullable private final LocalBluetoothLeBroadcast mBroadcast;
@Nullable private AudioSharingNamePreference mPreference;
private final Executor mExecutor;
private final AudioSharingNameTextValidator mAudioSharingNameTextValidator;
private AtomicBoolean mCallbacksRegistered = new AtomicBoolean(false);
public AudioSharingNamePreferenceController(Context context, String preferenceKey) {
super(context, preferenceKey);
mBtManager = Utils.getLocalBluetoothManager(context);
mProfileManager = mBtManager == null ? null : mBtManager.getProfileManager();
mBroadcast =
(mProfileManager != null) ? mProfileManager.getLeAudioBroadcastProfile() : null;
mAudioSharingNameTextValidator = new AudioSharingNameTextValidator();
mExecutor = Executors.newSingleThreadExecutor();
}
@Override
public void onStart(@NonNull LifecycleOwner owner) {
if (!isAvailable()) {
Log.d(TAG, "Skip register callbacks, feature not support");
return;
}
if (!AudioSharingUtils.isAudioSharingProfileReady(mProfileManager)) {
Log.d(TAG, "Skip register callbacks, profile not ready");
if (mProfileManager != null) {
mProfileManager.addServiceListener(this);
}
return;
}
registerCallbacks();
}
@Override
public void onStop(@NonNull LifecycleOwner owner) {
if (!isAvailable()) {
Log.d(TAG, "Skip unregister callbacks, feature not support");
return;
}
if (mProfileManager != null) {
mProfileManager.removeServiceListener(this);
}
if (mBroadcast == null || !AudioSharingUtils.isAudioSharingProfileReady(mProfileManager)) {
Log.d(TAG, "Skip unregister callbacks, profile not ready");
return;
}
if (mCallbacksRegistered.get()) {
Log.d(TAG, "Unregister callbacks");
mBroadcast.unregisterServiceCallBack(mBroadcastCallback);
mCallbacksRegistered.set(false);
}
}
@Override
public int getAvailabilityStatus() {
return AudioSharingUtils.isFeatureEnabled() ? AVAILABLE : UNSUPPORTED_ON_DEVICE;
}
@Override
public void displayPreference(PreferenceScreen screen) {
super.displayPreference(screen);
mPreference = screen.findPreference(getPreferenceKey());
if (mPreference != null) {
mPreference.setValidator(this);
updateBroadcastName();
updateQrCodeIcon(isBroadcasting(mBtManager));
}
}
@Override
public void onServiceConnected() {
if (AudioSharingUtils.isAudioSharingProfileReady(mProfileManager)) {
registerCallbacks();
updateBroadcastName();
updateQrCodeIcon(isBroadcasting(mBtManager));
if (mProfileManager != null) {
mProfileManager.removeServiceListener(this);
}
}
}
@Override
public void onServiceDisconnected() {
// Do nothing
}
@Override
public String getPreferenceKey() {
return PREF_KEY;
}
@Override
public boolean onPreferenceChange(Preference preference, Object newValue) {
if (mPreference != null
&& mPreference.getSummary() != null
&& ((String) newValue).contentEquals(mPreference.getSummary())) {
return false;
}
var unused =
ThreadUtils.postOnBackgroundThread(
() -> {
if (mBroadcast != null) {
mBroadcast.setProgramInfo((String) newValue);
if (isBroadcasting(mBtManager)) {
mBroadcast.updateBroadcast();
}
updateBroadcastName();
}
});
return true;
}
private void registerCallbacks() {
if (mBroadcast == null) {
Log.d(TAG, "Skip register callbacks, profile not ready");
return;
}
if (!mCallbacksRegistered.get()) {
Log.d(TAG, "Register callbacks");
mBroadcast.registerServiceCallBack(mExecutor, mBroadcastCallback);
mCallbacksRegistered.set(true);
}
}
private void updateBroadcastName() {
if (mPreference != null) {
var unused =
ThreadUtils.postOnBackgroundThread(
() -> {
if (mBroadcast != null) {
String name = mBroadcast.getProgramInfo();
AudioSharingUtils.postOnMainThread(
mContext,
() -> {
if (mPreference != null) {
mPreference.setText(name);
mPreference.setSummary(name);
}
});
}
});
}
}
private void updateQrCodeIcon(boolean show) {
if (mPreference != null) {
AudioSharingUtils.postOnMainThread(
mContext,
() -> {
if (mPreference != null) {
mPreference.setShowQrCodeIcon(show);
}
});
}
}
@Override
public boolean isTextValid(String value) {
return mAudioSharingNameTextValidator.isTextValid(value);
}
}

View File

@@ -0,0 +1,44 @@
/*
* Copyright (C) 2023 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.widget.ValidatedEditTextPreference;
import java.nio.charset.StandardCharsets;
/**
* Validator for Audio Sharing Name, which should be a UTF-8 encoded string containing a minimum of
* 4 characters and a maximum of 32 human-readable characters.
*/
public class AudioSharingNameTextValidator implements ValidatedEditTextPreference.Validator {
private static final int MIN_LENGTH = 4;
private static final int MAX_LENGTH = 32;
@Override
public boolean isTextValid(String value) {
if (value == null || value.length() < MIN_LENGTH || value.length() > MAX_LENGTH) {
return false;
}
return isValidUTF8(value);
}
private static boolean isValidUTF8(String value) {
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
String reconstructedString = new String(bytes, StandardCharsets.UTF_8);
return value.equals(reconstructedString);
}
}

View File

@@ -0,0 +1,142 @@
/*
* Copyright (C) 2024 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 android.view.View.GONE;
import static android.view.View.VISIBLE;
import android.content.Context;
import android.content.DialogInterface;
import android.util.AttributeSet;
import android.util.Log;
import android.view.View;
import android.widget.CheckBox;
import android.widget.EditText;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import com.android.settings.R;
import com.android.settings.widget.ValidatedEditTextPreference;
import com.android.settingslib.utils.ColorUtil;
public class AudioSharingPasswordPreference extends ValidatedEditTextPreference {
private static final String TAG = "AudioSharingPasswordPreference";
@Nullable private OnDialogEventListener mOnDialogEventListener;
@Nullable private EditText mEditText;
@Nullable private CheckBox mCheckBox;
@Nullable private View mDialogMessage;
private boolean mEditable = true;
interface OnDialogEventListener {
void onBindDialogView();
void onPreferenceDataChanged(@NonNull String editTextValue, boolean checkBoxValue);
}
void setOnDialogEventListener(OnDialogEventListener listener) {
mOnDialogEventListener = listener;
}
public AudioSharingPasswordPreference(
Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
public AudioSharingPasswordPreference(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public AudioSharingPasswordPreference(Context context, AttributeSet attrs) {
super(context, attrs);
}
public AudioSharingPasswordPreference(Context context) {
super(context);
}
@Override
protected void onBindDialogView(View view) {
super.onBindDialogView(view);
mEditText = view.findViewById(android.R.id.edit);
mCheckBox = view.findViewById(R.id.audio_sharing_stream_password_checkbox);
mDialogMessage = view.findViewById(android.R.id.message);
if (mEditText == null || mCheckBox == null || mDialogMessage == null) {
Log.w(TAG, "onBindDialogView() : Invalid layout");
return;
}
mCheckBox.setOnCheckedChangeListener((unused, checked) -> setEditTextEnabled(!checked));
if (mOnDialogEventListener != null) {
mOnDialogEventListener.onBindDialogView();
}
}
@Override
protected void onPrepareDialogBuilder(
AlertDialog.Builder builder, DialogInterface.OnClickListener listener) {
if (!mEditable) {
builder.setPositiveButton(null, null);
}
}
@Override
protected void onClick(DialogInterface dialog, int which) {
if (mEditText == null || mCheckBox == null) {
Log.w(TAG, "onClick() : Invalid layout");
return;
}
if (mOnDialogEventListener != null
&& which == DialogInterface.BUTTON_POSITIVE
&& mEditText.getText() != null) {
mOnDialogEventListener.onPreferenceDataChanged(
mEditText.getText().toString(), mCheckBox.isChecked());
}
}
void setEditable(boolean editable) {
if (mEditText == null || mCheckBox == null || mDialogMessage == null) {
Log.w(TAG, "setEditable() : Invalid layout");
return;
}
mEditable = editable;
setEditTextEnabled(editable);
mCheckBox.setEnabled(editable);
mDialogMessage.setVisibility(editable ? GONE : VISIBLE);
}
void setChecked(boolean checked) {
if (mCheckBox == null) {
Log.w(TAG, "setChecked() : Invalid layout");
return;
}
mCheckBox.setChecked(checked);
}
private void setEditTextEnabled(boolean enabled) {
if (mEditText == null) {
Log.w(TAG, "setEditTextEnabled() : Invalid layout");
return;
}
mEditText.setEnabled(enabled);
mEditText.setAlpha(enabled ? 1.0f : ColorUtil.getDisabledAlpha(getContext()));
}
}

View File

@@ -0,0 +1,178 @@
/*
* Copyright (C) 2023 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.connecteddevice.audiosharing.AudioSharingUtils.isBroadcasting;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.preference.PreferenceScreen;
import com.android.settings.R;
import com.android.settings.bluetooth.Utils;
import com.android.settings.core.BasePreferenceController;
import com.android.settings.widget.ValidatedEditTextPreference;
import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
import com.android.settingslib.utils.ThreadUtils;
import java.nio.charset.StandardCharsets;
public class AudioSharingPasswordPreferenceController extends BasePreferenceController
implements ValidatedEditTextPreference.Validator,
AudioSharingPasswordPreference.OnDialogEventListener {
private static final String TAG = "AudioSharingPasswordPreferenceController";
private static final String PREF_KEY = "audio_sharing_stream_password";
private static final String SHARED_PREF_NAME = "audio_sharing_settings";
private static final String SHARED_PREF_KEY = "default_password";
@Nullable private final LocalBluetoothManager mBtManager;
@Nullable private final LocalBluetoothLeBroadcast mBroadcast;
@Nullable private AudioSharingPasswordPreference mPreference;
private final AudioSharingPasswordValidator mAudioSharingPasswordValidator;
public AudioSharingPasswordPreferenceController(Context context, String preferenceKey) {
super(context, preferenceKey);
mBtManager = Utils.getLocalBluetoothManager(context);
mBroadcast =
mBtManager != null
? mBtManager.getProfileManager().getLeAudioBroadcastProfile()
: null;
mAudioSharingPasswordValidator = new AudioSharingPasswordValidator();
}
@Override
public int getAvailabilityStatus() {
return AudioSharingUtils.isFeatureEnabled() ? AVAILABLE : UNSUPPORTED_ON_DEVICE;
}
@Override
public void displayPreference(PreferenceScreen screen) {
super.displayPreference(screen);
mPreference = screen.findPreference(getPreferenceKey());
if (mPreference != null) {
mPreference.setValidator(this);
mPreference.setIsPassword(true);
mPreference.setDialogLayoutResource(R.layout.audio_sharing_password_dialog);
mPreference.setOnDialogEventListener(this);
updatePreference();
}
}
@Override
public String getPreferenceKey() {
return PREF_KEY;
}
@Override
public boolean isTextValid(String value) {
return mAudioSharingPasswordValidator.isTextValid(value);
}
@Override
public void onBindDialogView() {
if (mPreference == null || mBroadcast == null) {
return;
}
mPreference.setEditable(!isBroadcasting(mBtManager));
var password = mBroadcast.getBroadcastCode();
mPreference.setChecked(password == null || password.length == 0);
}
@Override
public void onPreferenceDataChanged(@NonNull String password, boolean isPublicBroadcast) {
if (mBroadcast == null || isBroadcasting(mBtManager)) {
Log.w(TAG, "onPreferenceDataChanged() changing password when broadcasting or null!");
return;
}
persistDefaultPassword(mContext, password);
mBroadcast.setBroadcastCode(isPublicBroadcast ? new byte[0] : password.getBytes());
updatePreference();
}
private void updatePreference() {
if (mBroadcast == null || mPreference == null) {
return;
}
var unused =
ThreadUtils.postOnBackgroundThread(
() -> {
byte[] password = mBroadcast.getBroadcastCode();
boolean noPassword = (password == null || password.length == 0);
String passwordToDisplay =
noPassword
? getDefaultPassword(mContext)
: new String(password, StandardCharsets.UTF_8);
String passwordSummary =
noPassword
? mContext.getString(
R.string.audio_streams_no_password_summary)
: "********";
AudioSharingUtils.postOnMainThread(
mContext,
() -> {
// Check nullability to pass NullAway check
if (mPreference != null) {
mPreference.setText(passwordToDisplay);
mPreference.setSummary(passwordSummary);
}
});
});
}
private static void persistDefaultPassword(Context context, String defaultPassword) {
var unused =
ThreadUtils.postOnBackgroundThread(
() -> {
if (getDefaultPassword(context).equals(defaultPassword)) {
return;
}
SharedPreferences sharedPref =
context.getSharedPreferences(
SHARED_PREF_NAME, Context.MODE_PRIVATE);
if (sharedPref == null) {
Log.w(TAG, "persistDefaultPassword(): sharedPref is empty!");
return;
}
SharedPreferences.Editor editor = sharedPref.edit();
editor.putString(SHARED_PREF_KEY, defaultPassword);
editor.apply();
});
}
private static String getDefaultPassword(Context context) {
SharedPreferences sharedPref =
context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE);
if (sharedPref == null) {
Log.w(TAG, "getDefaultPassword(): sharedPref is empty!");
return "";
}
String value = sharedPref.getString(SHARED_PREF_KEY, "");
if (value != null && value.isEmpty()) {
Log.w(TAG, "getDefaultPassword(): default password is empty!");
}
return value;
}
}

View File

@@ -0,0 +1,51 @@
/*
* Copyright (C) 2023 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.widget.ValidatedEditTextPreference;
import java.nio.charset.StandardCharsets;
/**
* Validator for Audio Sharing Password, which should be a UTF-8 string that has at least 4 octets
* and should not exceed 16 octets.
*/
public class AudioSharingPasswordValidator implements ValidatedEditTextPreference.Validator {
private static final int MIN_OCTETS = 4;
private static final int MAX_OCTETS = 16;
@Override
public boolean isTextValid(String value) {
if (value == null
|| getOctetsCount(value) < MIN_OCTETS
|| getOctetsCount(value) > MAX_OCTETS) {
return false;
}
return isValidUTF8(value);
}
private static int getOctetsCount(String value) {
return value.getBytes(StandardCharsets.UTF_8).length;
}
private static boolean isValidUTF8(String value) {
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
String reconstructedString = new String(bytes, StandardCharsets.UTF_8);
return value.equals(reconstructedString);
}
}

View File

@@ -0,0 +1,112 @@
/*
* Copyright (C) 2023 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.ContentResolver;
import android.content.Context;
import android.media.AudioAttributes;
import android.media.AudioManager;
import android.media.Ringtone;
import android.media.RingtoneManager;
import android.net.Uri;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import androidx.lifecycle.LifecycleOwner;
import androidx.preference.PreferenceScreen;
import com.android.settings.R;
public class AudioSharingPlaySoundPreferenceController
extends AudioSharingBasePreferenceController {
private static final String TAG = "AudioSharingPlaySoundPreferenceController";
private static final String PREF_KEY = "audio_sharing_play_sound";
private Ringtone mRingtone;
public AudioSharingPlaySoundPreferenceController(Context context) {
super(context, PREF_KEY);
mRingtone = RingtoneManager.getRingtone(context, getMediaVolumeUri());
if (mRingtone != null) {
mRingtone.setStreamType(AudioManager.STREAM_MUSIC);
}
}
@Override
public int getAvailabilityStatus() {
return (mRingtone != null && AudioSharingUtils.isFeatureEnabled())
? AVAILABLE
: UNSUPPORTED_ON_DEVICE;
}
@Override
public void displayPreference(PreferenceScreen screen) {
super.displayPreference(screen);
if (mPreference != null) {
mPreference.setOnPreferenceClickListener(
(v) -> {
if (mRingtone == null) {
Log.d(TAG, "Skip onClick due to ringtone is null");
return true;
}
try {
mRingtone.setAudioAttributes(
new AudioAttributes.Builder(mRingtone.getAudioAttributes())
.setFlags(AudioAttributes.FLAG_BYPASS_MUTE)
.addTag("VX_AOSP_SAMPLESOUND")
.build());
if (!mRingtone.isPlaying()) {
mRingtone.play();
}
} catch (Throwable e) {
Log.w(TAG, "Fail to play sample, error = " + e);
}
return true;
});
}
}
@Override
public void onStop(@NonNull LifecycleOwner owner) {
super.onStop(owner);
if (mRingtone != null && mRingtone.isPlaying()) {
mRingtone.stop();
}
}
@Override
public String getPreferenceKey() {
return PREF_KEY;
}
@VisibleForTesting
protected void setRingtone(Ringtone ringtone) {
mRingtone = ringtone;
}
private Uri getMediaVolumeUri() {
return Uri.parse(
ContentResolver.SCHEME_ANDROID_RESOURCE
+ "://"
+ mContext.getPackageName()
+ "/"
+ R.raw.media_volume);
}
}

View File

@@ -0,0 +1,168 @@
/*
* Copyright (C) 2023 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.bluetooth.BluetoothLeBroadcast;
import android.bluetooth.BluetoothLeBroadcastMetadata;
import android.content.Context;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.LifecycleOwner;
import androidx.preference.Preference;
import androidx.preference.PreferenceScreen;
import com.android.settings.R;
import com.android.settings.bluetooth.Utils;
import com.android.settings.core.BasePreferenceController;
import com.android.settingslib.bluetooth.BluetoothCallback;
import com.android.settingslib.bluetooth.BluetoothEventManager;
import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
import com.android.settingslib.utils.ThreadUtils;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
public class AudioSharingPreferenceController extends BasePreferenceController
implements DefaultLifecycleObserver, BluetoothCallback {
private static final String TAG = "AudioSharingPreferenceController";
@Nullable private final LocalBluetoothManager mBtManager;
@Nullable private final BluetoothEventManager mEventManager;
@Nullable private final LocalBluetoothLeBroadcast mBroadcast;
@Nullable private Preference mPreference;
private final Executor mExecutor;
private final BluetoothLeBroadcast.Callback mBroadcastCallback =
new BluetoothLeBroadcast.Callback() {
@Override
public void onBroadcastStarted(int reason, int broadcastId) {
refreshSummary();
}
@Override
public void onBroadcastStartFailed(int reason) {}
@Override
public void onBroadcastMetadataChanged(
int broadcastId, @NonNull BluetoothLeBroadcastMetadata metadata) {}
@Override
public void onBroadcastStopped(int reason, int broadcastId) {
refreshSummary();
}
@Override
public void onBroadcastStopFailed(int reason) {}
@Override
public void onBroadcastUpdated(int reason, int broadcastId) {}
@Override
public void onBroadcastUpdateFailed(int reason, int broadcastId) {}
@Override
public void onPlaybackStarted(int reason, int broadcastId) {}
@Override
public void onPlaybackStopped(int reason, int broadcastId) {}
};
public AudioSharingPreferenceController(Context context, String preferenceKey) {
super(context, preferenceKey);
mBtManager = Utils.getLocalBtManager(context);
mEventManager = mBtManager == null ? null : mBtManager.getEventManager();
mBroadcast =
mBtManager == null
? null
: mBtManager.getProfileManager().getLeAudioBroadcastProfile();
mExecutor = Executors.newSingleThreadExecutor();
}
@Override
public void onStart(@NonNull LifecycleOwner owner) {
if (!isAvailable()) {
Log.d(TAG, "Skip register callbacks, feature not support");
return;
}
if (mEventManager == null || mBroadcast == null) {
Log.d(TAG, "Skip register callbacks, profile not ready");
return;
}
mEventManager.registerCallback(this);
mBroadcast.registerServiceCallBack(mExecutor, mBroadcastCallback);
}
@Override
public void onStop(@NonNull LifecycleOwner owner) {
if (!isAvailable()) {
Log.d(TAG, "Skip unregister callbacks, feature not support");
return;
}
if (mEventManager == null || mBroadcast == null) {
Log.d(TAG, "Skip register callbacks, profile not ready");
return;
}
mEventManager.unregisterCallback(this);
mBroadcast.unregisterServiceCallBack(mBroadcastCallback);
}
@Override
public void displayPreference(@NonNull PreferenceScreen screen) {
super.displayPreference(screen);
mPreference = screen.findPreference(getPreferenceKey());
}
@Override
public int getAvailabilityStatus() {
return AudioSharingUtils.isFeatureEnabled() ? AVAILABLE : UNSUPPORTED_ON_DEVICE;
}
@Override
public CharSequence getSummary() {
return AudioSharingUtils.isBroadcasting(mBtManager)
? mContext.getString(R.string.audio_sharing_summary_on)
: mContext.getString(R.string.audio_sharing_summary_off);
}
@Override
public void onBluetoothStateChanged(@AdapterState int bluetoothState) {
refreshSummary();
}
private void refreshSummary() {
if (mPreference == null) {
return;
}
var unused =
ThreadUtils.postOnBackgroundThread(
() -> {
final CharSequence summary = getSummary();
AudioSharingUtils.postOnMainThread(
mContext,
() -> {
// Check nullability to pass NullAway check
if (mPreference != null) {
mPreference.setSummary(summary);
}
});
});
}
}

View File

@@ -0,0 +1,149 @@
/*
* Copyright (C) 2024 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.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.util.Log;
import androidx.core.app.NotificationCompat;
import com.android.settings.R;
import com.android.settings.bluetooth.Utils;
import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
public class AudioSharingReceiver extends BroadcastReceiver {
private static final String TAG = "AudioSharingNotification";
private static final String ACTION_LE_AUDIO_SHARING_SETTINGS =
"com.android.settings.BLUETOOTH_AUDIO_SHARING_SETTINGS";
private static final String ACTION_LE_AUDIO_SHARING_STOP =
"com.android.settings.action.BLUETOOTH_LE_AUDIO_SHARING_STOP";
private static final String CHANNEL_ID = "bluetooth_notification_channel";
private static final int NOTIFICATION_ID =
com.android.settingslib.R.drawable.ic_bt_le_audio_sharing;
@Override
public void onReceive(Context context, Intent intent) {
if (!AudioSharingUtils.isFeatureEnabled()) {
Log.w(TAG, "Skip handling received intent, flag is off.");
return;
}
String action = intent.getAction();
if (action == null) {
Log.w(TAG, "Received unexpected intent with null action.");
return;
}
switch (action) {
case LocalBluetoothLeBroadcast.ACTION_LE_AUDIO_SHARING_STATE_CHANGE:
int state =
intent.getIntExtra(
LocalBluetoothLeBroadcast.EXTRA_LE_AUDIO_SHARING_STATE, -1);
if (state == LocalBluetoothLeBroadcast.BROADCAST_STATE_ON) {
showSharingNotification(context);
} else if (state == LocalBluetoothLeBroadcast.BROADCAST_STATE_OFF) {
cancelSharingNotification(context);
} else {
Log.w(
TAG,
"Skip handling ACTION_LE_AUDIO_SHARING_STATE_CHANGE, invalid extras.");
}
break;
case ACTION_LE_AUDIO_SHARING_STOP:
LocalBluetoothManager manager = Utils.getLocalBtManager(context);
AudioSharingUtils.stopBroadcasting(manager);
break;
default:
Log.w(TAG, "Received unexpected intent " + intent.getAction());
}
}
private void showSharingNotification(Context context) {
NotificationManager nm = context.getSystemService(NotificationManager.class);
if (nm.getNotificationChannel(CHANNEL_ID) == null) {
Log.d(TAG, "Create bluetooth notification channel");
NotificationChannel notificationChannel =
new NotificationChannel(
CHANNEL_ID,
context.getString(com.android.settings.R.string.bluetooth),
NotificationManager.IMPORTANCE_HIGH);
nm.createNotificationChannel(notificationChannel);
}
Intent stopIntent =
new Intent(ACTION_LE_AUDIO_SHARING_STOP).setPackage(context.getPackageName());
PendingIntent stopPendingIntent =
PendingIntent.getBroadcast(
context,
R.string.audio_sharing_stop_button_label,
stopIntent,
PendingIntent.FLAG_IMMUTABLE);
Intent settingsIntent =
new Intent(ACTION_LE_AUDIO_SHARING_SETTINGS).setPackage(context.getPackageName());
PendingIntent settingsPendingIntent =
PendingIntent.getActivity(
context,
R.string.audio_sharing_settings_button_label,
settingsIntent,
PendingIntent.FLAG_IMMUTABLE);
NotificationCompat.Action stopAction =
new NotificationCompat.Action.Builder(
0,
context.getString(R.string.audio_sharing_stop_button_label),
stopPendingIntent)
.build();
NotificationCompat.Action settingsAction =
new NotificationCompat.Action.Builder(
0,
context.getString(R.string.audio_sharing_settings_button_label),
settingsPendingIntent)
.build();
final Bundle extras = new Bundle();
extras.putString(
Notification.EXTRA_SUBSTITUTE_APP_NAME,
context.getString(R.string.audio_sharing_title));
NotificationCompat.Builder builder =
new NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(com.android.settingslib.R.drawable.ic_bt_le_audio_sharing)
.setLocalOnly(true)
.setContentTitle(
context.getString(R.string.audio_sharing_notification_title))
.setContentText(
context.getString(R.string.audio_sharing_notification_content))
.setOngoing(true)
.setSilent(true)
.setColor(
context.getColor(
com.android.internal.R.color
.system_notification_accent_color))
.setContentIntent(settingsPendingIntent)
.addAction(stopAction)
.addAction(settingsAction)
.addExtras(extras);
nm.notify(NOTIFICATION_ID, builder.build());
}
private void cancelSharingNotification(Context context) {
NotificationManager nm = context.getSystemService(NotificationManager.class);
nm.cancel(NOTIFICATION_ID);
}
}

View File

@@ -0,0 +1,162 @@
/*
* Copyright (C) 2023 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.app.Dialog;
import android.app.settings.SettingsEnums;
import android.os.Bundle;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import com.android.settings.R;
import com.android.settings.core.instrumentation.InstrumentedDialogFragment;
import com.android.settingslib.bluetooth.CachedBluetoothDevice;
import com.google.common.collect.Iterables;
import java.util.List;
import java.util.Locale;
public class AudioSharingStopDialogFragment extends InstrumentedDialogFragment {
private static final String TAG = "AudioSharingStopDialog";
private static final String BUNDLE_KEY_DEVICE_TO_DISCONNECT_ITEMS =
"bundle_key_device_to_disconnect_items";
private static final String BUNDLE_KEY_NEW_DEVICE_NAME = "bundle_key_new_device_name";
// The host creates an instance of this dialog fragment must implement this interface to receive
// event callbacks.
public interface DialogEventListener {
/** Called when users click the stop sharing button in the dialog. */
void onStopSharingClick();
}
@Nullable private static DialogEventListener sListener;
@Nullable private static CachedBluetoothDevice sCachedDevice;
@Override
public int getMetricsCategory() {
return SettingsEnums.DIALOG_STOP_AUDIO_SHARING;
}
/**
* Display the {@link AudioSharingStopDialogFragment} dialog.
*
* <p>If the dialog is showing, update the dialog message and event listener.
*
* @param host The Fragment this dialog will be hosted.
* @param deviceItems The existing connected device items in audio sharing session.
* @param newDevice The latest connected device triggered this dialog.
* @param listener The callback to handle the user action on this dialog.
*/
public static void show(
@NonNull Fragment host,
@NonNull List<AudioSharingDeviceItem> deviceItems,
@NonNull CachedBluetoothDevice newDevice,
@NonNull DialogEventListener listener) {
if (!AudioSharingUtils.isFeatureEnabled()) return;
final FragmentManager manager = host.getChildFragmentManager();
AlertDialog dialog = AudioSharingDialogHelper.getDialogIfShowing(manager, TAG);
if (dialog != null) {
int newGroupId = AudioSharingUtils.getGroupId(newDevice);
if (sCachedDevice != null
&& newGroupId == AudioSharingUtils.getGroupId(sCachedDevice)) {
Log.d(
TAG,
String.format(
Locale.US,
"Dialog is showing for the same device group %d, return.",
newGroupId));
sListener = listener;
sCachedDevice = newDevice;
return;
} else {
Log.d(
TAG,
String.format(
Locale.US,
"Dialog is showing for new device group %d, "
+ "dismiss current dialog.",
newGroupId));
dialog.dismiss();
}
}
sListener = listener;
sCachedDevice = newDevice;
Log.d(TAG, "Show up the dialog.");
final Bundle bundle = new Bundle();
bundle.putParcelableList(BUNDLE_KEY_DEVICE_TO_DISCONNECT_ITEMS, deviceItems);
bundle.putString(BUNDLE_KEY_NEW_DEVICE_NAME, newDevice.getName());
AudioSharingStopDialogFragment dialogFrag = new AudioSharingStopDialogFragment();
dialogFrag.setArguments(bundle);
dialogFrag.show(manager, TAG);
}
/** Return the tag of {@link AudioSharingStopDialogFragment} dialog. */
public static @NonNull String tag() {
return TAG;
}
/** Get the latest connected device which triggers the dialog. */
public @Nullable CachedBluetoothDevice getDevice() {
return sCachedDevice;
}
@Override
public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
Bundle arguments = requireArguments();
List<AudioSharingDeviceItem> deviceItems =
arguments.getParcelable(BUNDLE_KEY_DEVICE_TO_DISCONNECT_ITEMS, List.class);
String newDeviceName = arguments.getString(BUNDLE_KEY_NEW_DEVICE_NAME);
String customMessage =
deviceItems.size() == 1
? getString(
R.string.audio_sharing_stop_dialog_content,
Iterables.getOnlyElement(deviceItems).getName())
: (deviceItems.size() == 2
? getString(
R.string.audio_sharing_stop_dialog_with_two_content,
deviceItems.get(0).getName(),
deviceItems.get(1).getName())
: getString(R.string.audio_sharing_stop_dialog_with_more_content));
AlertDialog dialog =
AudioSharingDialogFactory.newBuilder(getActivity())
.setTitle(
getString(R.string.audio_sharing_stop_dialog_title, newDeviceName))
.setTitleIcon(com.android.settings.R.drawable.ic_warning_24dp)
.setIsCustomBodyEnabled(true)
.setCustomMessage(customMessage)
.setPositiveButton(
R.string.audio_sharing_connect_button_label,
(dlg, which) -> {
if (sListener != null) {
sListener.onStopSharingClick();
}
})
.setNegativeButton(
com.android.settings.R.string.cancel, (dlg, which) -> dismiss())
.build();
dialog.show();
AudioSharingDialogHelper.updateMessageStyle(dialog);
return dialog;
}
}

View File

@@ -0,0 +1,522 @@
/*
* Copyright (C) 2023 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.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothLeBroadcast;
import android.bluetooth.BluetoothLeBroadcastAssistant;
import android.bluetooth.BluetoothLeBroadcastMetadata;
import android.bluetooth.BluetoothLeBroadcastReceiveState;
import android.bluetooth.BluetoothProfile;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.util.Log;
import android.widget.CompoundButton;
import android.widget.CompoundButton.OnCheckedChangeListener;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.LifecycleOwner;
import com.android.settings.bluetooth.Utils;
import com.android.settings.core.BasePreferenceController;
import com.android.settings.dashboard.DashboardFragment;
import com.android.settings.widget.SettingsMainSwitchBar;
import com.android.settingslib.bluetooth.CachedBluetoothDevice;
import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast;
import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
import com.android.settingslib.bluetooth.LocalBluetoothProfileManager;
import com.android.settingslib.utils.ThreadUtils;
import com.google.common.collect.ImmutableList;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors;
public class AudioSharingSwitchBarController extends BasePreferenceController
implements DefaultLifecycleObserver,
OnCheckedChangeListener,
LocalBluetoothProfileManager.ServiceListener {
private static final String TAG = "AudioSharingSwitchBarCtl";
private static final String PREF_KEY = "audio_sharing_main_switch";
interface OnAudioSharingStateChangedListener {
/**
* The callback which will be triggered when:
*
* <p>1. Bluetooth on/off state changes. 2. Broadcast and assistant profile
* connect/disconnect state changes. 3. Audio sharing start/stop state changes.
*/
void onAudioSharingStateChanged();
/**
* The callback which will be triggered when:
*
* <p>Broadcast and assistant profile connected.
*/
void onAudioSharingProfilesConnected();
}
private final SettingsMainSwitchBar mSwitchBar;
private final BluetoothAdapter mBluetoothAdapter;
@Nullable private final LocalBluetoothManager mBtManager;
@Nullable private final LocalBluetoothProfileManager mProfileManager;
@Nullable private final LocalBluetoothLeBroadcast mBroadcast;
@Nullable private final LocalBluetoothLeBroadcastAssistant mAssistant;
@Nullable private DashboardFragment mFragment;
private final Executor mExecutor;
private final OnAudioSharingStateChangedListener mListener;
private Map<Integer, List<CachedBluetoothDevice>> mGroupedConnectedDevices = new HashMap<>();
private List<BluetoothDevice> mTargetActiveSinks = new ArrayList<>();
private List<AudioSharingDeviceItem> mDeviceItemsForSharing = new ArrayList<>();
@VisibleForTesting IntentFilter mIntentFilter;
private AtomicBoolean mCallbacksRegistered = new AtomicBoolean(false);
@VisibleForTesting
BroadcastReceiver mReceiver =
new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
updateSwitch();
mListener.onAudioSharingStateChanged();
}
};
private final BluetoothLeBroadcast.Callback mBroadcastCallback =
new BluetoothLeBroadcast.Callback() {
@Override
public void onBroadcastStarted(int reason, int broadcastId) {
Log.d(
TAG,
"onBroadcastStarted(), reason = "
+ reason
+ ", broadcastId = "
+ broadcastId);
updateSwitch();
mListener.onAudioSharingStateChanged();
}
@Override
public void onBroadcastStartFailed(int reason) {
Log.d(TAG, "onBroadcastStartFailed(), reason = " + reason);
// TODO: handle broadcast start fail
updateSwitch();
}
@Override
public void onBroadcastMetadataChanged(
int broadcastId, @NonNull BluetoothLeBroadcastMetadata metadata) {
Log.d(
TAG,
"onBroadcastMetadataChanged(), broadcastId = "
+ broadcastId
+ ", metadata = "
+ metadata.getBroadcastName());
}
@Override
public void onBroadcastStopped(int reason, int broadcastId) {
Log.d(
TAG,
"onBroadcastStopped(), reason = "
+ reason
+ ", broadcastId = "
+ broadcastId);
updateSwitch();
mListener.onAudioSharingStateChanged();
}
@Override
public void onBroadcastStopFailed(int reason) {
Log.d(TAG, "onBroadcastStopFailed(), reason = " + reason);
// TODO: handle broadcast stop fail
updateSwitch();
}
@Override
public void onBroadcastUpdated(int reason, int broadcastId) {}
@Override
public void onBroadcastUpdateFailed(int reason, int broadcastId) {}
@Override
public void onPlaybackStarted(int reason, int broadcastId) {
Log.d(
TAG,
"onPlaybackStarted(), reason = "
+ reason
+ ", broadcastId = "
+ broadcastId);
handleOnBroadcastReady();
}
@Override
public void onPlaybackStopped(int reason, int broadcastId) {}
};
private BluetoothLeBroadcastAssistant.Callback mBroadcastAssistantCallback =
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(), sink = "
+ sink
+ ", sourceId = "
+ sourceId
+ ", reason = "
+ reason);
}
@Override
public void onSourceAddFailed(
@NonNull BluetoothDevice sink,
@NonNull BluetoothLeBroadcastMetadata source,
int reason) {
Log.d(
TAG,
"onSourceAddFailed(), sink = "
+ sink
+ ", source = "
+ source
+ ", reason = "
+ reason);
AudioSharingUtils.toastMessage(
mContext,
String.format(
Locale.US,
"Fail to add source to %s reason %d",
sink.getAddress(),
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(
BluetoothDevice sink,
int sourceId,
BluetoothLeBroadcastReceiveState state) {}
};
AudioSharingSwitchBarController(
Context context,
SettingsMainSwitchBar switchBar,
OnAudioSharingStateChangedListener listener) {
super(context, PREF_KEY);
mSwitchBar = switchBar;
mListener = listener;
mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
mIntentFilter = new IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED);
mBtManager = Utils.getLocalBtManager(context);
mProfileManager = mBtManager == null ? null : mBtManager.getProfileManager();
mBroadcast = mProfileManager == null ? null : mProfileManager.getLeAudioBroadcastProfile();
mAssistant =
mProfileManager == null
? null
: mProfileManager.getLeAudioBroadcastAssistantProfile();
mExecutor = Executors.newSingleThreadExecutor();
}
@Override
public void onStart(@NonNull LifecycleOwner owner) {
if (!isAvailable()) {
Log.d(TAG, "Skip register callbacks. Feature is not available.");
return;
}
mContext.registerReceiver(mReceiver, mIntentFilter, Context.RECEIVER_EXPORTED_UNAUDITED);
updateSwitch();
if (!AudioSharingUtils.isAudioSharingProfileReady(mProfileManager)) {
if (mProfileManager != null) {
mProfileManager.addServiceListener(this);
}
Log.d(TAG, "Skip register callbacks. Profile is not ready.");
return;
}
registerCallbacks();
}
@Override
public void onStop(@NonNull LifecycleOwner owner) {
if (!isAvailable()) {
Log.d(TAG, "Skip unregister callbacks. Feature is not available.");
return;
}
mContext.unregisterReceiver(mReceiver);
if (mProfileManager != null) {
mProfileManager.removeServiceListener(this);
}
unregisterCallbacks();
}
@Override
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
// Filter out unnecessary callbacks when switch is disabled.
if (!buttonView.isEnabled()) return;
if (isChecked) {
mSwitchBar.setEnabled(false);
boolean isBroadcasting = AudioSharingUtils.isBroadcasting(mBtManager);
if (mAssistant == null || mBroadcast == null || isBroadcasting) {
Log.d(TAG, "Skip startAudioSharing, already broadcasting or not support.");
mSwitchBar.setEnabled(true);
if (!isBroadcasting) {
mSwitchBar.setChecked(false);
}
return;
}
if (mAssistant
.getDevicesMatchingConnectionStates(
new int[] {BluetoothProfile.STATE_CONNECTED})
.isEmpty()) {
// Pop up dialog to ask users to connect at least one lea buds before audio sharing.
AudioSharingUtils.postOnMainThread(
mContext,
() -> {
mSwitchBar.setEnabled(true);
mSwitchBar.setChecked(false);
if (mFragment != null) {
AudioSharingConfirmDialogFragment.show(mFragment);
}
});
return;
}
startAudioSharing();
} else {
stopAudioSharing();
}
}
@Override
public int getAvailabilityStatus() {
return AudioSharingUtils.isFeatureEnabled() ? AVAILABLE : UNSUPPORTED_ON_DEVICE;
}
@Override
public void onServiceConnected() {
Log.d(TAG, "onServiceConnected()");
if (AudioSharingUtils.isAudioSharingProfileReady(mProfileManager)) {
registerCallbacks();
updateSwitch();
mListener.onAudioSharingProfilesConnected();
mListener.onAudioSharingStateChanged();
if (mProfileManager != null) {
mProfileManager.removeServiceListener(this);
}
}
}
@Override
public void onServiceDisconnected() {
Log.d(TAG, "onServiceDisconnected()");
// Do nothing.
}
/**
* Initialize the controller.
*
* @param fragment The fragment to host the {@link AudioSharingSwitchBarController} dialog.
*/
public void init(DashboardFragment fragment) {
this.mFragment = fragment;
}
/** Test only: set callback registration status in tests. */
@VisibleForTesting
public void setCallbacksRegistered(boolean registered) {
mCallbacksRegistered.set(registered);
}
private void registerCallbacks() {
if (!isAvailable()) {
Log.d(TAG, "Skip registerCallbacks(). Feature is not available.");
return;
}
if (mBroadcast == null || mAssistant == null) {
Log.d(TAG, "Skip registerCallbacks(). Profile not support on this device.");
return;
}
if (!mCallbacksRegistered.get()) {
Log.d(TAG, "registerCallbacks()");
mSwitchBar.addOnSwitchChangeListener(this);
mBroadcast.registerServiceCallBack(mExecutor, mBroadcastCallback);
mAssistant.registerServiceCallBack(mExecutor, mBroadcastAssistantCallback);
mCallbacksRegistered.set(true);
}
}
private void unregisterCallbacks() {
if (!isAvailable() || !AudioSharingUtils.isAudioSharingProfileReady(mProfileManager)) {
Log.d(TAG, "Skip unregisterCallbacks(). Feature is not available.");
return;
}
if (mBroadcast == null || mAssistant == null) {
Log.d(TAG, "Skip unregisterCallbacks(). Profile not support on this device.");
return;
}
if (mCallbacksRegistered.get()) {
Log.d(TAG, "unregisterCallbacks()");
mSwitchBar.removeOnSwitchChangeListener(this);
mBroadcast.unregisterServiceCallBack(mBroadcastCallback);
mAssistant.unregisterServiceCallBack(mBroadcastAssistantCallback);
mCallbacksRegistered.set(false);
}
}
private void startAudioSharing() {
// Compute the device connection state before start audio sharing since the devices will
// be set to inactive after the broadcast started.
mGroupedConnectedDevices = AudioSharingUtils.fetchConnectedDevicesByGroupId(mBtManager);
List<AudioSharingDeviceItem> deviceItems =
AudioSharingUtils.buildOrderedConnectedLeadAudioSharingDeviceItem(
mBtManager, mGroupedConnectedDevices, /* filterByInSharing= */ false);
// deviceItems is ordered. The active device is the first place if exits.
mDeviceItemsForSharing = new ArrayList<>(deviceItems);
mTargetActiveSinks = new ArrayList<>();
if (!deviceItems.isEmpty() && deviceItems.get(0).isActive()) {
for (CachedBluetoothDevice device :
mGroupedConnectedDevices.getOrDefault(
deviceItems.get(0).getGroupId(), ImmutableList.of())) {
// If active device exists for audio sharing, share to it
// automatically once the broadcast is started.
mTargetActiveSinks.add(device.getDevice());
}
mDeviceItemsForSharing.remove(0);
}
if (mBroadcast != null) {
mBroadcast.startPrivateBroadcast();
}
}
private void stopAudioSharing() {
mSwitchBar.setEnabled(false);
if (!AudioSharingUtils.isBroadcasting(mBtManager)) {
Log.d(TAG, "Skip stopAudioSharing, already not broadcasting or broadcast not support.");
mSwitchBar.setEnabled(true);
return;
}
if (mBroadcast != null) {
mBroadcast.stopBroadcast(mBroadcast.getLatestBroadcastId());
}
}
private void updateSwitch() {
var unused =
ThreadUtils.postOnBackgroundThread(
() -> {
boolean isBroadcasting = AudioSharingUtils.isBroadcasting(mBtManager);
boolean isStateReady =
isBluetoothOn()
&& AudioSharingUtils.isAudioSharingProfileReady(
mProfileManager);
AudioSharingUtils.postOnMainThread(
mContext,
() -> {
if (mSwitchBar.isChecked() != isBroadcasting) {
mSwitchBar.setChecked(isBroadcasting);
}
if (mSwitchBar.isEnabled() != isStateReady) {
mSwitchBar.setEnabled(isStateReady);
}
Log.d(
TAG,
"updateSwitch, checked = "
+ isBroadcasting
+ ", enabled = "
+ isStateReady);
});
});
}
private boolean isBluetoothOn() {
return mBluetoothAdapter != null && mBluetoothAdapter.isEnabled();
}
private void handleOnBroadcastReady() {
AudioSharingUtils.addSourceToTargetSinks(mTargetActiveSinks, mBtManager);
mTargetActiveSinks.clear();
if (mFragment == null) {
Log.w(TAG, "Dialog fail to show due to null fragment.");
mGroupedConnectedDevices.clear();
mDeviceItemsForSharing.clear();
return;
}
AudioSharingUtils.postOnMainThread(
mContext,
() -> {
// Check nullability to pass NullAway check
if (mFragment != null) {
AudioSharingDialogFragment.show(
mFragment,
mDeviceItemsForSharing,
item -> {
AudioSharingUtils.addSourceToTargetSinks(
mGroupedConnectedDevices
.getOrDefault(
item.getGroupId(), ImmutableList.of())
.stream()
.map(CachedBluetoothDevice::getDevice)
.collect(Collectors.toList()),
mBtManager);
mGroupedConnectedDevices.clear();
mDeviceItemsForSharing.clear();
});
}
});
}
}

View File

@@ -0,0 +1,386 @@
/*
* Copyright (C) 2023 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.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothCsipSetCoordinator;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothLeBroadcastMetadata;
import android.bluetooth.BluetoothProfile;
import android.bluetooth.BluetoothStatusCodes;
import android.content.Context;
import android.provider.Settings;
import android.util.Log;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.android.settingslib.bluetooth.BluetoothUtils;
import com.android.settingslib.bluetooth.CachedBluetoothDevice;
import com.android.settingslib.bluetooth.CachedBluetoothDeviceManager;
import com.android.settingslib.bluetooth.LeAudioProfile;
import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast;
import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
import com.android.settingslib.bluetooth.LocalBluetoothProfile;
import com.android.settingslib.bluetooth.LocalBluetoothProfileManager;
import com.android.settingslib.bluetooth.VolumeControlProfile;
import com.android.settingslib.flags.Flags;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class AudioSharingUtils {
public static final String SETTINGS_KEY_FALLBACK_DEVICE_GROUP_ID =
"bluetooth_le_broadcast_fallback_active_group_id";
private static final String TAG = "AudioSharingUtils";
private static final boolean DEBUG = BluetoothUtils.D;
/**
* Fetch {@link CachedBluetoothDevice}s connected to the broadcast assistant. The devices are
* grouped by CSIP group id.
*
* @param localBtManager The BT manager to provide BT functions.
* @return A map of connected devices grouped by CSIP group id.
*/
public static Map<Integer, List<CachedBluetoothDevice>> fetchConnectedDevicesByGroupId(
@Nullable LocalBluetoothManager localBtManager) {
Map<Integer, List<CachedBluetoothDevice>> groupedDevices = new HashMap<>();
if (localBtManager == null) {
Log.d(TAG, "Skip fetchConnectedDevicesByGroupId due to bt manager is null");
return groupedDevices;
}
LocalBluetoothLeBroadcastAssistant assistant =
localBtManager.getProfileManager().getLeAudioBroadcastAssistantProfile();
if (assistant == null) {
Log.d(TAG, "Skip fetchConnectedDevicesByGroupId due to assistant profile is null");
return groupedDevices;
}
List<BluetoothDevice> connectedDevices =
assistant.getDevicesMatchingConnectionStates(
new int[] {BluetoothProfile.STATE_CONNECTED});
CachedBluetoothDeviceManager cacheManager = localBtManager.getCachedDeviceManager();
for (BluetoothDevice device : connectedDevices) {
CachedBluetoothDevice cachedDevice = cacheManager.findDevice(device);
if (cachedDevice == null) {
Log.d(TAG, "Skip device due to not being cached: " + device.getAnonymizedAddress());
continue;
}
int groupId = getGroupId(cachedDevice);
if (groupId == BluetoothCsipSetCoordinator.GROUP_ID_INVALID) {
Log.d(
TAG,
"Skip device due to no valid group id: " + device.getAnonymizedAddress());
continue;
}
if (!groupedDevices.containsKey(groupId)) {
groupedDevices.put(groupId, new ArrayList<>());
}
groupedDevices.get(groupId).add(cachedDevice);
}
if (DEBUG) {
Log.d(TAG, "fetchConnectedDevicesByGroupId: " + groupedDevices);
}
return groupedDevices;
}
/**
* Fetch a list of ordered connected lead {@link CachedBluetoothDevice}s eligible for audio
* sharing. The active device is placed in the first place if it exists. The devices can be
* filtered by whether it is already in the audio sharing session.
*
* @param localBtManager The BT manager to provide BT functions. *
* @param groupedConnectedDevices devices connected to broadcast assistant grouped by CSIP group
* id.
* @param filterByInSharing Whether to filter the device by if is already in the sharing
* session.
* @return A list of ordered connected devices eligible for the audio sharing. The active device
* is placed in the first place if it exists.
*/
public static List<CachedBluetoothDevice> buildOrderedConnectedLeadDevices(
@Nullable LocalBluetoothManager localBtManager,
Map<Integer, List<CachedBluetoothDevice>> groupedConnectedDevices,
boolean filterByInSharing) {
List<CachedBluetoothDevice> orderedDevices = new ArrayList<>();
for (List<CachedBluetoothDevice> devices : groupedConnectedDevices.values()) {
@Nullable CachedBluetoothDevice leadDevice = getLeadDevice(devices);
if (leadDevice == null) {
Log.d(TAG, "Skip due to no lead device");
continue;
}
if (filterByInSharing
&& !BluetoothUtils.hasConnectedBroadcastSource(leadDevice, localBtManager)) {
Log.d(
TAG,
"Filtered the device due to not in sharing session: "
+ leadDevice.getDevice().getAnonymizedAddress());
continue;
}
orderedDevices.add(leadDevice);
}
orderedDevices.sort(
(CachedBluetoothDevice d1, CachedBluetoothDevice d2) -> {
// Active above not inactive
int comparison =
(isActiveLeAudioDevice(d2) ? 1 : 0)
- (isActiveLeAudioDevice(d1) ? 1 : 0);
if (comparison != 0) return comparison;
// Bonded above not bonded
comparison =
(d2.getBondState() == BluetoothDevice.BOND_BONDED ? 1 : 0)
- (d1.getBondState() == BluetoothDevice.BOND_BONDED ? 1 : 0);
if (comparison != 0) return comparison;
// Bond timestamp available above unavailable
comparison =
(d2.getBondTimestamp() != null ? 1 : 0)
- (d1.getBondTimestamp() != null ? 1 : 0);
if (comparison != 0) return comparison;
// Order by bond timestamp if it is available
// Otherwise order by device name
return d1.getBondTimestamp() != null
? d1.getBondTimestamp().compareTo(d2.getBondTimestamp())
: d1.getName().compareTo(d2.getName());
});
return orderedDevices;
}
/**
* Get the lead device from a list of devices with same group id.
*
* @param devices A list of devices with same group id.
* @return The lead device
*/
@Nullable
public static CachedBluetoothDevice getLeadDevice(
@NonNull List<CachedBluetoothDevice> devices) {
if (devices.isEmpty()) return null;
for (CachedBluetoothDevice device : devices) {
if (!device.getMemberDevice().isEmpty()) {
return device;
}
}
CachedBluetoothDevice leadDevice = devices.get(0);
Log.d(
TAG,
"No lead device in the group, pick arbitrary device as the lead: "
+ leadDevice.getDevice().getAnonymizedAddress());
return leadDevice;
}
/**
* Fetch a list of ordered connected lead {@link AudioSharingDeviceItem}s eligible for audio
* sharing. The active device is placed in the first place if it exists. The devices can be
* filtered by whether it is already in the audio sharing session.
*
* @param localBtManager The BT manager to provide BT functions. *
* @param groupedConnectedDevices devices connected to broadcast assistant grouped by CSIP group
* id.
* @param filterByInSharing Whether to filter the device by if is already in the sharing
* session.
* @return A list of ordered connected devices eligible for the audio sharing. The active device
* is placed in the first place if it exists.
*/
@NonNull
public static List<AudioSharingDeviceItem> buildOrderedConnectedLeadAudioSharingDeviceItem(
@Nullable LocalBluetoothManager localBtManager,
Map<Integer, List<CachedBluetoothDevice>> groupedConnectedDevices,
boolean filterByInSharing) {
return buildOrderedConnectedLeadDevices(
localBtManager, groupedConnectedDevices, filterByInSharing)
.stream()
.map(device -> buildAudioSharingDeviceItem(device))
.collect(Collectors.toList());
}
/** Build {@link AudioSharingDeviceItem} from {@link CachedBluetoothDevice}. */
public static AudioSharingDeviceItem buildAudioSharingDeviceItem(
CachedBluetoothDevice cachedDevice) {
return new AudioSharingDeviceItem(
cachedDevice.getName(),
getGroupId(cachedDevice),
isActiveLeAudioDevice(cachedDevice));
}
/**
* Check if {@link CachedBluetoothDevice} is an active le audio device.
*
* @param cachedDevice The cached bluetooth device to check.
* @return Whether the device is an active le audio device.
*/
public static boolean isActiveLeAudioDevice(CachedBluetoothDevice cachedDevice) {
return BluetoothUtils.isActiveLeAudioDevice(cachedDevice);
}
/** Toast message on main thread. */
public static void toastMessage(Context context, String message) {
context.getMainExecutor()
.execute(() -> Toast.makeText(context, message, Toast.LENGTH_LONG).show());
}
/** Returns if the le audio sharing is enabled. */
public static boolean isFeatureEnabled() {
BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
return Flags.enableLeAudioSharing()
&& adapter.isLeAudioBroadcastSourceSupported()
== BluetoothStatusCodes.FEATURE_SUPPORTED
&& adapter.isLeAudioBroadcastAssistantSupported()
== BluetoothStatusCodes.FEATURE_SUPPORTED;
}
/** Add source to target sinks. */
public static void addSourceToTargetSinks(
List<BluetoothDevice> sinks, @Nullable LocalBluetoothManager localBtManager) {
if (localBtManager == null) {
Log.d(TAG, "skip addSourceToTargetDevices: LocalBluetoothManager is null!");
return;
}
if (sinks.isEmpty()) {
Log.d(TAG, "Skip addSourceToTargetDevices. No sinks.");
return;
}
LocalBluetoothLeBroadcast broadcast =
localBtManager.getProfileManager().getLeAudioBroadcastProfile();
if (broadcast == null) {
Log.d(TAG, "skip addSourceToTargetDevices. Broadcast profile is null.");
return;
}
LocalBluetoothLeBroadcastAssistant assistant =
localBtManager.getProfileManager().getLeAudioBroadcastAssistantProfile();
if (assistant == null) {
Log.d(TAG, "skip addSourceToTargetDevices. Assistant profile is null.");
return;
}
BluetoothLeBroadcastMetadata broadcastMetadata =
broadcast.getLatestBluetoothLeBroadcastMetadata();
if (broadcastMetadata == null) {
Log.d(TAG, "skip addSourceToTargetDevices: There is no broadcastMetadata.");
return;
}
List<BluetoothDevice> connectedDevices =
assistant.getDevicesMatchingConnectionStates(
new int[] {BluetoothProfile.STATE_CONNECTED});
for (BluetoothDevice sink : sinks) {
if (connectedDevices.contains(sink)) {
Log.d(
TAG,
"Add broadcast with broadcastId: "
+ broadcastMetadata.getBroadcastId()
+ " to the device: "
+ sink.getAnonymizedAddress());
assistant.addSource(sink, broadcastMetadata, /* isGroupOp= */ false);
} else {
Log.d(
TAG,
"Skip add broadcast with broadcastId: "
+ broadcastMetadata.getBroadcastId()
+ " to the not connected device: "
+ sink.getAnonymizedAddress());
}
}
}
/** Returns if the broadcast is on-going. */
public static boolean isBroadcasting(@Nullable LocalBluetoothManager manager) {
if (manager == null) return false;
LocalBluetoothLeBroadcast broadcast =
manager.getProfileManager().getLeAudioBroadcastProfile();
return broadcast != null && broadcast.isEnabled(null);
}
/** Stops the latest broadcast. */
public static void stopBroadcasting(@Nullable LocalBluetoothManager manager) {
if (manager == null) {
Log.d(TAG, "Skip stop broadcasting due to bt manager is null");
return;
}
LocalBluetoothLeBroadcast broadcast =
manager.getProfileManager().getLeAudioBroadcastProfile();
if (broadcast == null) {
Log.d(TAG, "Skip stop broadcasting due to broadcast profile is null");
}
broadcast.stopBroadcast(broadcast.getLatestBroadcastId());
}
/**
* Get CSIP group id for {@link CachedBluetoothDevice}.
*
* <p>If CachedBluetoothDevice#getGroupId is invalid, fetch group id from
* LeAudioProfile#getGroupId.
*/
public static int getGroupId(CachedBluetoothDevice cachedDevice) {
int groupId = cachedDevice.getGroupId();
String anonymizedAddress = cachedDevice.getDevice().getAnonymizedAddress();
if (groupId != BluetoothCsipSetCoordinator.GROUP_ID_INVALID) {
Log.d(TAG, "getGroupId by CSIP profile for device: " + anonymizedAddress);
return groupId;
}
for (LocalBluetoothProfile profile : cachedDevice.getProfiles()) {
if (profile instanceof LeAudioProfile) {
Log.d(TAG, "getGroupId by LEA profile for device: " + anonymizedAddress);
return ((LeAudioProfile) profile).getGroupId(cachedDevice.getDevice());
}
}
Log.d(TAG, "getGroupId return invalid id for device: " + anonymizedAddress);
return BluetoothCsipSetCoordinator.GROUP_ID_INVALID;
}
/** Get the fallback active group id from SettingsProvider. */
public static int getFallbackActiveGroupId(@NonNull Context context) {
return Settings.Secure.getInt(
context.getContentResolver(),
SETTINGS_KEY_FALLBACK_DEVICE_GROUP_ID,
BluetoothCsipSetCoordinator.GROUP_ID_INVALID);
}
/** Post the runnable to main thread. */
public static void postOnMainThread(@NonNull Context context, @NonNull Runnable runnable) {
context.getMainExecutor().execute(runnable);
}
/** Check if the {@link CachedBluetoothDevice} supports LE Audio profile */
public static boolean isLeAudioSupported(CachedBluetoothDevice cachedDevice) {
return cachedDevice.getProfiles().stream()
.anyMatch(
profile ->
profile instanceof LeAudioProfile
&& profile.isEnabled(cachedDevice.getDevice()));
}
/** Check if the LE Audio related profiles ready */
public static boolean isAudioSharingProfileReady(
@Nullable LocalBluetoothProfileManager profileManager) {
if (profileManager == null) return false;
LocalBluetoothLeBroadcast broadcast = profileManager.getLeAudioBroadcastProfile();
if (broadcast == null || !broadcast.isProfileReady()) {
return false;
}
LocalBluetoothLeBroadcastAssistant assistant =
profileManager.getLeAudioBroadcastAssistantProfile();
if (assistant == null || !assistant.isProfileReady()) {
return false;
}
VolumeControlProfile vc = profileManager.getVolumeControlProfile();
if (vc == null || !vc.isProfileReady()) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,107 @@
/*
* Copyright (C) 2023 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.app.Dialog;
import android.app.settings.SettingsEnums;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import com.android.settings.R;
import com.android.settings.core.instrumentation.InstrumentedDialogFragment;
import java.util.List;
/** Provides a dialog to choose the active device for calls and alarms. */
public class CallsAndAlarmsDialogFragment extends InstrumentedDialogFragment {
private static final String TAG = "CallsAndAlarmsDialog";
private static final String BUNDLE_KEY_DEVICE_ITEMS = "bundle_key_device_items";
// The host creates an instance of this dialog fragment must implement this interface to receive
// event callbacks.
public interface DialogEventListener {
/**
* Called when users click the device item to set active for calls and alarms in the dialog.
*
* @param item The device item clicked.
*/
void onItemClick(AudioSharingDeviceItem item);
}
@Nullable private static DialogEventListener sListener;
@Override
public int getMetricsCategory() {
return SettingsEnums.DIALOG_AUDIO_SHARING_SWITCH_ACTIVE;
}
/**
* Display the {@link CallsAndAlarmsDialogFragment} dialog.
*
* @param host The Fragment this dialog will be hosted.
* @param deviceItems The connected device items in audio sharing session.
* @param listener The callback to handle the user action on this dialog.
*/
public static void show(
@NonNull Fragment host,
@NonNull List<AudioSharingDeviceItem> deviceItems,
@NonNull DialogEventListener listener) {
if (!AudioSharingUtils.isFeatureEnabled()) return;
final FragmentManager manager = host.getChildFragmentManager();
sListener = listener;
if (manager.findFragmentByTag(TAG) == null) {
final Bundle bundle = new Bundle();
bundle.putParcelableList(BUNDLE_KEY_DEVICE_ITEMS, deviceItems);
final CallsAndAlarmsDialogFragment dialog = new CallsAndAlarmsDialogFragment();
dialog.setArguments(bundle);
dialog.show(manager, TAG);
}
}
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
Bundle arguments = requireArguments();
List<AudioSharingDeviceItem> deviceItems =
arguments.getParcelable(BUNDLE_KEY_DEVICE_ITEMS, List.class);
int checkedItem = -1;
for (AudioSharingDeviceItem item : deviceItems) {
int fallbackActiveGroupId = AudioSharingUtils.getFallbackActiveGroupId(getContext());
if (item.getGroupId() == fallbackActiveGroupId) {
checkedItem = deviceItems.indexOf(item);
}
}
String[] choices =
deviceItems.stream().map(AudioSharingDeviceItem::getName).toArray(String[]::new);
AlertDialog.Builder builder =
new AlertDialog.Builder(getActivity())
.setTitle(R.string.audio_sharing_call_audio_title)
.setSingleChoiceItems(
choices,
checkedItem,
(dialog, which) -> {
if (sListener != null) {
sListener.onItemClick(deviceItems.get(which));
}
});
return builder.create();
}
}

View File

@@ -0,0 +1,361 @@
/*
* Copyright (C) 2023 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.connecteddevice.audiosharing.AudioSharingUtils.SETTINGS_KEY_FALLBACK_DEVICE_GROUP_ID;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothCsipSetCoordinator;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothLeBroadcastAssistant;
import android.bluetooth.BluetoothLeBroadcastMetadata;
import android.bluetooth.BluetoothLeBroadcastReceiveState;
import android.bluetooth.BluetoothProfile;
import android.content.ContentResolver;
import android.content.Context;
import android.database.ContentObserver;
import android.os.Handler;
import android.os.Looper;
import android.provider.Settings;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.lifecycle.LifecycleOwner;
import androidx.preference.PreferenceScreen;
import com.android.settings.R;
import com.android.settings.bluetooth.Utils;
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.LocalBluetoothLeBroadcastAssistant;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
import com.android.settingslib.bluetooth.LocalBluetoothProfileManager;
import com.android.settingslib.utils.ThreadUtils;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
/** PreferenceController to control the dialog to choose the active device for calls and alarms */
public class CallsAndAlarmsPreferenceController extends AudioSharingBasePreferenceController
implements BluetoothCallback {
private static final String TAG = "CallsAndAlarmsPreferenceController";
private static final String PREF_KEY = "calls_and_alarms";
@Nullable private final LocalBluetoothManager mBtManager;
@Nullable private final LocalBluetoothProfileManager mProfileManager;
@Nullable private final BluetoothEventManager mEventManager;
@Nullable private final ContentResolver mContentResolver;
@Nullable private final LocalBluetoothLeBroadcastAssistant mAssistant;
private final Executor mExecutor;
private final ContentObserver mSettingsObserver;
@Nullable private DashboardFragment mFragment;
Map<Integer, List<CachedBluetoothDevice>> mGroupedConnectedDevices = new HashMap<>();
private List<AudioSharingDeviceItem> mDeviceItemsInSharingSession = new ArrayList<>();
private AtomicBoolean mCallbacksRegistered = new AtomicBoolean(false);
private BluetoothLeBroadcastAssistant.Callback mBroadcastAssistantCallback =
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) {}
@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) {
if (BluetoothUtils.isConnected(state)) {
Log.d(TAG, "onReceiveStateChanged: synced, updateSummary");
updateSummary();
}
}
};
public CallsAndAlarmsPreferenceController(Context context) {
super(context, PREF_KEY);
mBtManager = Utils.getLocalBtManager(mContext);
mProfileManager = mBtManager == null ? null : mBtManager.getProfileManager();
mEventManager = mBtManager == null ? null : mBtManager.getEventManager();
mAssistant =
mProfileManager == null
? null
: mProfileManager.getLeAudioBroadcastAssistantProfile();
mExecutor = Executors.newSingleThreadExecutor();
mContentResolver = context.getContentResolver();
mSettingsObserver = new FallbackDeviceGroupIdSettingsObserver();
}
private class FallbackDeviceGroupIdSettingsObserver extends ContentObserver {
FallbackDeviceGroupIdSettingsObserver() {
super(new Handler(Looper.getMainLooper()));
}
@Override
public void onChange(boolean selfChange) {
Log.d(TAG, "onChange, fallback device group id has been changed");
var unused = ThreadUtils.postOnBackgroundThread(() -> updateSummary());
}
}
@Override
public String getPreferenceKey() {
return PREF_KEY;
}
@Override
public void displayPreference(@NonNull PreferenceScreen screen) {
super.displayPreference(screen);
if (mPreference != null) {
mPreference.setVisible(false);
updateSummary();
mPreference.setOnPreferenceClickListener(
preference -> {
if (mFragment == null) {
Log.w(TAG, "Dialog fail to show due to null host.");
return true;
}
updateDeviceItemsInSharingSession();
if (mDeviceItemsInSharingSession.size() >= 1) {
CallsAndAlarmsDialogFragment.show(
mFragment,
mDeviceItemsInSharingSession,
(AudioSharingDeviceItem item) -> {
if (!mGroupedConnectedDevices.containsKey(
item.getGroupId())) {
return;
}
List<CachedBluetoothDevice> devices =
mGroupedConnectedDevices.get(item.getGroupId());
@Nullable
CachedBluetoothDevice lead =
AudioSharingUtils.getLeadDevice(devices);
if (lead != null) {
Log.d(
TAG,
"Set fallback active device: "
+ lead.getDevice()
.getAnonymizedAddress());
lead.setActive();
} else {
Log.w(
TAG,
"Fail to set fallback active device: no lead"
+ " device");
}
});
}
return true;
});
}
}
@Override
public void onStart(@NonNull LifecycleOwner owner) {
super.onStart(owner);
registerCallbacks();
}
@Override
public void onStop(@NonNull LifecycleOwner owner) {
super.onStop(owner);
unregisterCallbacks();
}
@Override
public void onProfileConnectionStateChanged(
@NonNull CachedBluetoothDevice cachedDevice,
@ConnectionState int state,
int bluetoothProfile) {
if (state == BluetoothAdapter.STATE_DISCONNECTED
&& bluetoothProfile == BluetoothProfile.LE_AUDIO_BROADCAST_ASSISTANT) {
Log.d(TAG, "updatePreference, LE_AUDIO_BROADCAST_ASSISTANT is disconnected.");
// The fallback active device could be updated if the previous fallback device is
// disconnected.
updateSummary();
}
}
/**
* Initialize the controller.
*
* @param fragment The fragment to host the {@link CallsAndAlarmsDialogFragment} dialog.
*/
public void init(DashboardFragment fragment) {
this.mFragment = fragment;
}
@VisibleForTesting
ContentObserver getSettingsObserver() {
return mSettingsObserver;
}
/** Test only: set callback registration status in tests. */
@VisibleForTesting
public void setCallbacksRegistered(boolean registered) {
mCallbacksRegistered.set(registered);
}
private void registerCallbacks() {
if (!isAvailable()) {
Log.d(TAG, "Skip registerCallbacks(). Feature is not available.");
return;
}
if (mEventManager == null || mContentResolver == null || mAssistant == null) {
Log.d(
TAG,
"Skip registerCallbacks(). Init is not ready: eventManager = "
+ (mEventManager == null)
+ ", contentResolver"
+ (mContentResolver == null));
return;
}
if (!mCallbacksRegistered.get()) {
Log.d(TAG, "registerCallbacks()");
mEventManager.registerCallback(this);
mContentResolver.registerContentObserver(
Settings.Secure.getUriFor(SETTINGS_KEY_FALLBACK_DEVICE_GROUP_ID),
false,
mSettingsObserver);
mAssistant.registerServiceCallBack(mExecutor, mBroadcastAssistantCallback);
mCallbacksRegistered.set(true);
}
}
private void unregisterCallbacks() {
if (!isAvailable()) {
Log.d(TAG, "Skip unregisterCallbacks(). Feature is not available.");
return;
}
if (mEventManager == null || mContentResolver == null || mAssistant == null) {
Log.d(TAG, "Skip unregisterCallbacks(). Init is not ready.");
return;
}
if (mCallbacksRegistered.get()) {
Log.d(TAG, "unregisterCallbacks()");
mEventManager.unregisterCallback(this);
mContentResolver.unregisterContentObserver(mSettingsObserver);
mAssistant.unregisterServiceCallBack(mBroadcastAssistantCallback);
mCallbacksRegistered.set(false);
}
}
/**
* Update the preference summary: current headset for call audio.
*
* <p>The summary should be updated when:
*
* <p>1. displayPreference.
*
* <p>2. ContentObserver#onChange: the fallback device value in SettingsProvider is changed.
*
* <p>3. onProfileConnectionStateChanged: the assistant profile of fallback device disconnected.
* When the last headset in audio sharing disconnected, both Settings and bluetooth framework
* won't set the SettingsProvider, so no ContentObserver#onChange.
*
* <p>4. onReceiveStateChanged: new headset join the audio sharing. If the headset has already
* been set as fallback device in SettingsProvider by bluetooth framework when the broadcast is
* started, Settings won't set the SettingsProvider again when the headset join the audio
* sharing, so there won't be ContentObserver#onChange. We need listen to onReceiveStateChanged
* to handle this scenario.
*/
private void updateSummary() {
updateDeviceItemsInSharingSession();
int fallbackActiveGroupId = AudioSharingUtils.getFallbackActiveGroupId(mContext);
if (fallbackActiveGroupId != BluetoothCsipSetCoordinator.GROUP_ID_INVALID) {
for (AudioSharingDeviceItem item : mDeviceItemsInSharingSession) {
if (item.getGroupId() == fallbackActiveGroupId) {
Log.d(
TAG,
"updatePreference: set summary tp fallback group "
+ fallbackActiveGroupId);
AudioSharingUtils.postOnMainThread(
mContext,
() -> {
if (mPreference != null) {
mPreference.setSummary(
mContext.getString(
R.string.audio_sharing_call_audio_description,
item.getName()));
}
});
return;
}
}
}
Log.d(TAG, "updatePreference: set empty summary");
AudioSharingUtils.postOnMainThread(
mContext,
() -> {
if (mPreference != null) {
mPreference.setSummary("");
}
});
}
private void updateDeviceItemsInSharingSession() {
mGroupedConnectedDevices = AudioSharingUtils.fetchConnectedDevicesByGroupId(mBtManager);
mDeviceItemsInSharingSession =
AudioSharingUtils.buildOrderedConnectedLeadAudioSharingDeviceItem(
mBtManager, mGroupedConnectedDevices, /* filterByInSharing= */ true);
}
}

View File

@@ -0,0 +1,144 @@
/*
* Copyright (C) 2024 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.bluetooth.BluetoothAdapter;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.LifecycleOwner;
import androidx.preference.Preference;
import androidx.preference.PreferenceScreen;
import com.android.settings.bluetooth.Utils;
import com.android.settings.core.BasePreferenceController;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
import com.android.settingslib.bluetooth.LocalBluetoothProfileManager;
public class StreamSettingsCategoryController extends BasePreferenceController
implements DefaultLifecycleObserver, LocalBluetoothProfileManager.ServiceListener {
private static final String TAG = "StreamSettingsCategoryController";
private final BluetoothAdapter mBluetoothAdapter;
@Nullable private final LocalBluetoothManager mBtManager;
@Nullable private final LocalBluetoothProfileManager mProfileManager;
@Nullable private Preference mPreference;
@VisibleForTesting final IntentFilter mIntentFilter;
@VisibleForTesting
BroadcastReceiver mReceiver =
new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
if (!BluetoothAdapter.ACTION_STATE_CHANGED.equals(intent.getAction())) return;
updateVisibility();
}
};
public StreamSettingsCategoryController(Context context, String key) {
super(context, key);
mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
mBtManager = Utils.getLocalBtManager(context);
mProfileManager = mBtManager == null ? null : mBtManager.getProfileManager();
mIntentFilter = new IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED);
}
@Override
public void onStart(@NonNull LifecycleOwner owner) {
if (!isAvailable()) return;
mContext.registerReceiver(mReceiver, mIntentFilter, Context.RECEIVER_EXPORTED_UNAUDITED);
if (!isProfileReady() && mProfileManager != null) {
mProfileManager.addServiceListener(this);
}
}
@Override
public void onStop(@NonNull LifecycleOwner owner) {
if (!isAvailable()) return;
mContext.unregisterReceiver(mReceiver);
if (mProfileManager != null) {
mProfileManager.removeServiceListener(this);
}
}
@Override
public void displayPreference(PreferenceScreen screen) {
super.displayPreference(screen);
mPreference = screen.findPreference(getPreferenceKey());
updateVisibility();
}
@Override
public int getAvailabilityStatus() {
return AudioSharingUtils.isFeatureEnabled() ? AVAILABLE : UNSUPPORTED_ON_DEVICE;
}
@Override
public void onServiceConnected() {
if (isAvailable() && isProfileReady()) {
updateVisibility();
if (mProfileManager != null) {
mProfileManager.removeServiceListener(this);
}
}
}
@Override
public void onServiceDisconnected() {
// Do nothing
}
private void updateVisibility() {
if (mPreference == null) {
Log.w(TAG, "Skip updateVisibility, null preference");
return;
}
if (!isAvailable()) {
Log.w(TAG, "Skip updateVisibility, unavailable preference");
AudioSharingUtils.postOnMainThread(
mContext,
() -> { // Check nullability to pass NullAway check
if (mPreference != null) {
mPreference.setVisible(false);
}
});
return;
}
boolean visible = isBluetoothOn() && isProfileReady();
AudioSharingUtils.postOnMainThread(
mContext,
() -> { // Check nullability to pass NullAway check
if (mPreference != null) {
mPreference.setVisible(visible);
}
});
}
private boolean isBluetoothOn() {
return mBluetoothAdapter != null && mBluetoothAdapter.isEnabled();
}
private boolean isProfileReady() {
return AudioSharingUtils.isAudioSharingProfileReady(mProfileManager);
}
}

View File

@@ -0,0 +1,49 @@
/*
* Copyright (C) 2024 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.audiostreams;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import com.android.settings.R;
class AddSourceBadCodeState extends SyncedState {
@VisibleForTesting
static final int AUDIO_STREAM_ADD_SOURCE_BAD_CODE_STATE_SUMMARY =
R.string.audio_streams_add_source_bad_code_state_summary;
@Nullable private static AddSourceBadCodeState sInstance = null;
AddSourceBadCodeState() {}
static AddSourceBadCodeState getInstance() {
if (sInstance == null) {
sInstance = new AddSourceBadCodeState();
}
return sInstance;
}
@Override
int getSummary() {
return AUDIO_STREAM_ADD_SOURCE_BAD_CODE_STATE_SUMMARY;
}
@Override
AudioStreamsProgressCategoryController.AudioStreamState getStateEnum() {
return AudioStreamsProgressCategoryController.AudioStreamState.ADD_SOURCE_BAD_CODE;
}
}

View File

@@ -0,0 +1,49 @@
/*
* Copyright (C) 2024 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.audiostreams;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import com.android.settings.R;
class AddSourceFailedState extends SyncedState {
@VisibleForTesting
static final int AUDIO_STREAM_ADD_SOURCE_FAILED_STATE_SUMMARY =
R.string.audio_streams_add_source_failed_state_summary;
@Nullable private static AddSourceFailedState sInstance = null;
AddSourceFailedState() {}
static AddSourceFailedState getInstance() {
if (sInstance == null) {
sInstance = new AddSourceFailedState();
}
return sInstance;
}
@Override
int getSummary() {
return AUDIO_STREAM_ADD_SOURCE_FAILED_STATE_SUMMARY;
}
@Override
AudioStreamsProgressCategoryController.AudioStreamState getStateEnum() {
return AudioStreamsProgressCategoryController.AudioStreamState.ADD_SOURCE_FAILED;
}
}

View File

@@ -0,0 +1,106 @@
/*
* Copyright (C) 2024 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.audiostreams;
import android.app.AlertDialog;
import android.content.Context;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import com.android.settings.R;
import com.android.settingslib.utils.ThreadUtils;
class AddSourceWaitForResponseState extends AudioStreamStateHandler {
@VisibleForTesting
static final int AUDIO_STREAM_ADD_SOURCE_WAIT_FOR_RESPONSE_STATE_SUMMARY =
R.string.audio_streams_add_source_wait_for_response_summary;
@VisibleForTesting static final int ADD_SOURCE_WAIT_FOR_RESPONSE_TIMEOUT_MILLIS = 20000;
@Nullable private static AddSourceWaitForResponseState sInstance = null;
private AddSourceWaitForResponseState() {}
static AddSourceWaitForResponseState getInstance() {
if (sInstance == null) {
sInstance = new AddSourceWaitForResponseState();
}
return sInstance;
}
@Override
void performAction(
AudioStreamPreference preference,
AudioStreamsProgressCategoryController controller,
AudioStreamsHelper helper) {
mHandler.removeCallbacksAndMessages(preference);
var metadata = preference.getAudioStreamMetadata();
if (metadata != null) {
helper.addSource(metadata);
// Cache the metadata that used for add source, if source is added successfully, we
// will save it persistently.
mAudioStreamsRepository.cacheMetadata(metadata);
// It's possible that onSourceLost() is not notified even if the source is no longer
// valid. When calling addSource() for a source that's already lost, no callback
// will be sent back. So we remove the preference and pop up a dialog if it's state
// has not been changed after waiting for a certain time.
mHandler.postDelayed(
() -> {
if (preference.isShown()
&& preference.getAudioStreamState() == getStateEnum()) {
controller.handleSourceFailedToConnect(
preference.getAudioStreamBroadcastId());
ThreadUtils.postOnMainThread(
() -> {
if (controller.getFragment() != null) {
AudioStreamsDialogFragment.show(
controller.getFragment(),
getBroadcastUnavailableNoRetryDialog(
preference.getContext(),
AudioStreamsHelper.getBroadcastName(
metadata)));
}
});
}
},
preference,
ADD_SOURCE_WAIT_FOR_RESPONSE_TIMEOUT_MILLIS);
}
}
@Override
int getSummary() {
return AUDIO_STREAM_ADD_SOURCE_WAIT_FOR_RESPONSE_STATE_SUMMARY;
}
@Override
AudioStreamsProgressCategoryController.AudioStreamState getStateEnum() {
return AudioStreamsProgressCategoryController.AudioStreamState.ADD_SOURCE_WAIT_FOR_RESPONSE;
}
private AudioStreamsDialogFragment.DialogBuilder getBroadcastUnavailableNoRetryDialog(
Context context, String broadcastName) {
return new AudioStreamsDialogFragment.DialogBuilder(context)
.setTitle(context.getString(R.string.audio_streams_dialog_stream_is_not_available))
.setSubTitle1(broadcastName)
.setSubTitle2(context.getString(R.string.audio_streams_is_not_playing))
.setRightButtonText(context.getString(R.string.audio_streams_dialog_close))
.setRightButtonOnClickListener(AlertDialog::dismiss);
}
}

View File

@@ -0,0 +1,197 @@
/*
* Copyright (C) 2023 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.audiostreams;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothLeBroadcastAssistant;
import android.bluetooth.BluetoothLeBroadcastMetadata;
import android.bluetooth.BluetoothLeBroadcastReceiveState;
import android.content.Context;
import android.util.Log;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.LifecycleOwner;
import androidx.preference.PreferenceScreen;
import com.android.settings.R;
import com.android.settings.bluetooth.Utils;
import com.android.settings.core.BasePreferenceController;
import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant;
import com.android.settingslib.utils.ThreadUtils;
import com.android.settingslib.widget.ActionButtonsPreference;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
public class AudioStreamButtonController extends BasePreferenceController
implements DefaultLifecycleObserver {
private static final String TAG = "AudioStreamButtonController";
private static final String KEY = "audio_stream_button";
private final BluetoothLeBroadcastAssistant.Callback mBroadcastAssistantCallback =
new AudioStreamsBroadcastAssistantCallback() {
@Override
public void onSourceRemoved(BluetoothDevice sink, int sourceId, int reason) {
super.onSourceRemoved(sink, sourceId, reason);
updateButton();
}
@Override
public void onSourceRemoveFailed(BluetoothDevice sink, int sourceId, int reason) {
super.onSourceRemoveFailed(sink, sourceId, reason);
updateButton();
}
@Override
public void onReceiveStateChanged(
BluetoothDevice sink,
int sourceId,
BluetoothLeBroadcastReceiveState state) {
super.onReceiveStateChanged(sink, sourceId, state);
if (AudioStreamsHelper.isConnected(state)) {
updateButton();
}
}
@Override
public void onSourceAddFailed(
BluetoothDevice sink, BluetoothLeBroadcastMetadata source, int reason) {
super.onSourceAddFailed(sink, source, reason);
updateButton();
}
@Override
public void onSourceLost(int broadcastId) {
super.onSourceLost(broadcastId);
updateButton();
}
};
private final AudioStreamsRepository mAudioStreamsRepository =
AudioStreamsRepository.getInstance();
private final Executor mExecutor;
private final AudioStreamsHelper mAudioStreamsHelper;
private final @Nullable LocalBluetoothLeBroadcastAssistant mLeBroadcastAssistant;
private @Nullable ActionButtonsPreference mPreference;
private int mBroadcastId = -1;
public AudioStreamButtonController(Context context, String preferenceKey) {
super(context, preferenceKey);
mExecutor = Executors.newSingleThreadExecutor();
mAudioStreamsHelper = new AudioStreamsHelper(Utils.getLocalBtManager(context));
mLeBroadcastAssistant = mAudioStreamsHelper.getLeBroadcastAssistant();
}
@Override
public void onStart(@NonNull LifecycleOwner owner) {
if (mLeBroadcastAssistant == null) {
Log.w(TAG, "onStart(): LeBroadcastAssistant is null!");
return;
}
mLeBroadcastAssistant.registerServiceCallBack(mExecutor, mBroadcastAssistantCallback);
}
@Override
public void onStop(@NonNull LifecycleOwner owner) {
if (mLeBroadcastAssistant == null) {
Log.w(TAG, "onStop(): LeBroadcastAssistant is null!");
return;
}
mLeBroadcastAssistant.unregisterServiceCallBack(mBroadcastAssistantCallback);
}
@Override
public final void displayPreference(PreferenceScreen screen) {
mPreference = screen.findPreference(getPreferenceKey());
updateButton();
super.displayPreference(screen);
}
private void updateButton() {
if (mPreference != null) {
if (mAudioStreamsHelper.getAllConnectedSources().stream()
.map(BluetoothLeBroadcastReceiveState::getBroadcastId)
.anyMatch(connectedBroadcastId -> connectedBroadcastId == mBroadcastId)) {
ThreadUtils.postOnMainThread(
() -> {
if (mPreference != null) {
mPreference.setButton1Enabled(true);
mPreference
.setButton1Text(R.string.audio_streams_disconnect)
.setButton1Icon(
com.android.settings.R.drawable.ic_settings_close)
.setButton1OnClickListener(
unused -> {
if (mPreference != null) {
mPreference.setButton1Enabled(false);
}
mAudioStreamsHelper.removeSource(mBroadcastId);
});
}
});
} else {
View.OnClickListener clickToRejoin =
unused ->
ThreadUtils.postOnBackgroundThread(
() -> {
var metadata =
mAudioStreamsRepository.getSavedMetadata(
mContext, mBroadcastId);
if (metadata != null) {
mAudioStreamsHelper.addSource(metadata);
ThreadUtils.postOnMainThread(
() -> {
if (mPreference != null) {
mPreference.setButton1Enabled(
false);
}
});
}
});
ThreadUtils.postOnMainThread(
() -> {
if (mPreference != null) {
mPreference.setButton1Enabled(true);
mPreference
.setButton1Text(R.string.audio_streams_connect)
.setButton1Icon(com.android.settings.R.drawable.ic_add_24dp)
.setButton1OnClickListener(clickToRejoin);
}
});
}
} else {
Log.w(TAG, "updateButton(): preference is null!");
}
}
@Override
public int getAvailabilityStatus() {
return AVAILABLE;
}
@Override
public String getPreferenceKey() {
return KEY;
}
/** Initialize with broadcast id */
void init(int broadcastId) {
mBroadcastId = broadcastId;
}
}

View File

@@ -0,0 +1,195 @@
/*
* Copyright (C) 2024 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.audiostreams;
import android.app.Activity;
import android.app.Dialog;
import android.app.settings.SettingsEnums;
import android.bluetooth.BluetoothLeBroadcastMetadata;
import android.content.Intent;
import android.os.Bundle;
import android.provider.Settings;
import android.util.Log;
import androidx.annotation.Nullable;
import com.android.settings.R;
import com.android.settings.bluetooth.Utils;
import com.android.settings.connecteddevice.audiosharing.AudioSharingUtils;
import com.android.settings.connecteddevice.audiosharing.audiostreams.qrcode.QrCodeScanModeFragment;
import com.android.settings.core.SubSettingLauncher;
import com.android.settings.core.instrumentation.InstrumentedDialogFragment;
import com.android.settingslib.bluetooth.BluetoothLeBroadcastMetadataExt;
import com.android.settingslib.bluetooth.CachedBluetoothDevice;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
import com.android.settingslib.bluetooth.LocalBluetoothProfileManager;
import com.google.common.base.Strings;
public class AudioStreamConfirmDialog extends InstrumentedDialogFragment {
public static final String KEY_BROADCAST_METADATA = "key_broadcast_metadata";
private static final String TAG = "AudioStreamConfirmDialog";
private static final int DEFAULT_DEVICE_NAME = R.string.audio_streams_dialog_default_device;
@Nullable private LocalBluetoothManager mLocalBluetoothManager;
@Nullable private LocalBluetoothProfileManager mProfileManager;
@Nullable private Activity mActivity;
@Nullable private String mBroadcastMetadataStr;
@Nullable private BluetoothLeBroadcastMetadata mBroadcastMetadata;
private boolean mIsRequestValid = false;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (!AudioSharingUtils.isFeatureEnabled()) {
return;
}
setShowsDialog(true);
mActivity = getActivity();
if (mActivity == null) {
Log.w(TAG, "onCreate() mActivity is null!");
return;
}
mLocalBluetoothManager = Utils.getLocalBluetoothManager(mActivity);
mProfileManager =
mLocalBluetoothManager == null ? null : mLocalBluetoothManager.getProfileManager();
mBroadcastMetadataStr =
mActivity.getIntent().getStringExtra(QrCodeScanModeFragment.KEY_BROADCAST_METADATA);
if (Strings.isNullOrEmpty(mBroadcastMetadataStr)) {
Log.w(TAG, "onCreate() mBroadcastMetadataStr is null or empty!");
return;
}
mBroadcastMetadata =
BluetoothLeBroadcastMetadataExt.INSTANCE.convertToBroadcastMetadata(
mBroadcastMetadataStr);
if (mBroadcastMetadata == null) {
Log.w(TAG, "onCreate() mBroadcastMetadata is null!");
} else {
mIsRequestValid = true;
}
}
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
if (AudioSharingUtils.isAudioSharingProfileReady(mProfileManager)) {
CachedBluetoothDevice connectedLeDevice =
AudioStreamsHelper.getCachedBluetoothDeviceInSharingOrLeConnected(
mLocalBluetoothManager)
.orElse(null);
if (connectedLeDevice == null) {
return getNoLeDeviceDialog();
}
String deviceName = connectedLeDevice.getName();
return mIsRequestValid ? getConfirmDialog(deviceName) : getErrorDialog(deviceName);
}
Log.d(TAG, "onCreateDialog() : profile not ready!");
String defaultDeviceName =
mActivity != null ? mActivity.getString(DEFAULT_DEVICE_NAME) : "";
return mIsRequestValid
? getConfirmDialog(defaultDeviceName)
: getErrorDialog(defaultDeviceName);
}
@Override
public int getMetricsCategory() {
// TODO(chelseahao): update metrics id
return 0;
}
private Dialog getConfirmDialog(String name) {
return new AudioStreamsDialogFragment.DialogBuilder(getActivity())
.setTitle(getString(R.string.audio_streams_dialog_listen_to_audio_stream))
.setSubTitle1(
mBroadcastMetadata != null
? AudioStreamsHelper.getBroadcastName(mBroadcastMetadata)
: "")
.setSubTitle2(getString(R.string.audio_streams_dialog_control_volume, name))
.setLeftButtonText(getString(com.android.settings.R.string.cancel))
.setLeftButtonOnClickListener(
unused -> {
dismiss();
if (mActivity != null) {
mActivity.finish();
}
})
.setRightButtonText(getString(R.string.audio_streams_dialog_listen))
.setRightButtonOnClickListener(
unused -> {
launchAudioStreamsActivity();
dismiss();
if (mActivity != null) {
mActivity.finish();
}
})
.build();
}
private Dialog getErrorDialog(String name) {
return new AudioStreamsDialogFragment.DialogBuilder(getActivity())
.setTitle(getString(R.string.audio_streams_dialog_cannot_listen))
.setSubTitle2(getString(R.string.audio_streams_dialog_cannot_play, name))
.setRightButtonText(getString(R.string.audio_streams_dialog_close))
.setRightButtonOnClickListener(
unused -> {
dismiss();
if (mActivity != null) {
mActivity.finish();
}
})
.build();
}
private Dialog getNoLeDeviceDialog() {
return new AudioStreamsDialogFragment.DialogBuilder(getActivity())
.setTitle(getString(R.string.audio_streams_dialog_no_le_device_title))
.setSubTitle2(getString(R.string.audio_streams_dialog_no_le_device_subtitle))
.setLeftButtonText(getString(R.string.audio_streams_dialog_close))
.setLeftButtonOnClickListener(
unused -> {
dismiss();
if (mActivity != null) {
mActivity.finish();
}
})
.setRightButtonText(getString(R.string.audio_streams_dialog_no_le_device_button))
.setRightButtonOnClickListener(
dialog -> {
if (mActivity != null) {
mActivity.startActivity(
new Intent(Settings.ACTION_BLUETOOTH_SETTINGS)
.setPackage(mActivity.getPackageName()));
}
dismiss();
if (mActivity != null) {
mActivity.finish();
}
})
.build();
}
private void launchAudioStreamsActivity() {
Bundle bundle = new Bundle();
bundle.putString(KEY_BROADCAST_METADATA, mBroadcastMetadataStr);
if (mActivity != null) {
new SubSettingLauncher(getActivity())
.setTitleText(getString(R.string.audio_streams_activity_title))
.setDestination(AudioStreamsDashboardFragment.class.getName())
.setArguments(bundle)
.setSourceMetricsCategory(SettingsEnums.PAGE_UNKNOWN)
.launch();
}
}
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright (C) 2024 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.audiostreams;
import android.os.Bundle;
import com.android.settings.SettingsActivity;
import com.android.settings.connecteddevice.audiosharing.AudioSharingUtils;
public class AudioStreamConfirmDialogActivity extends SettingsActivity {
@Override
protected void onCreate(Bundle savedState) {
super.onCreate(savedState);
if (!AudioSharingUtils.isFeatureEnabled()) {
finish();
}
}
@Override
protected boolean isValidFragment(String fragmentName) {
return AudioStreamConfirmDialog.class.getName().equals(fragmentName);
}
}

View File

@@ -0,0 +1,59 @@
/*
* Copyright (C) 2023 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.audiostreams;
import android.content.Context;
import android.os.Bundle;
import com.android.settings.R;
import com.android.settings.dashboard.DashboardFragment;
public class AudioStreamDetailsFragment extends DashboardFragment {
static final String BROADCAST_NAME_ARG = "broadcast_name";
static final String BROADCAST_ID_ARG = "broadcast_id";
private static final String TAG = "AudioStreamDetailsFragment";
@Override
public void onAttach(Context context) {
super.onAttach(context);
Bundle arguments = getArguments();
if (arguments != null) {
use(AudioStreamHeaderController.class)
.init(
this,
arguments.getString(BROADCAST_NAME_ARG),
arguments.getInt(BROADCAST_ID_ARG));
use(AudioStreamButtonController.class).init(arguments.getInt(BROADCAST_ID_ARG));
}
}
@Override
public int getMetricsCategory() {
// TODO(chelseahao): update metrics id
return 0;
}
@Override
protected int getPreferenceScreenResId() {
return R.xml.bluetooth_le_audio_stream_details_fragment;
}
@Override
protected String getLogTag() {
return TAG;
}
}

View File

@@ -0,0 +1,182 @@
/*
* Copyright (C) 2023 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.audiostreams;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothLeBroadcastAssistant;
import android.bluetooth.BluetoothLeBroadcastReceiveState;
import android.content.Context;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import androidx.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.LifecycleOwner;
import androidx.preference.PreferenceScreen;
import com.android.settings.R;
import com.android.settings.bluetooth.Utils;
import com.android.settings.core.BasePreferenceController;
import com.android.settings.dashboard.DashboardFragment;
import com.android.settings.widget.EntityHeaderController;
import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant;
import com.android.settingslib.utils.ThreadUtils;
import com.android.settingslib.widget.LayoutPreference;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import javax.annotation.Nullable;
public class AudioStreamHeaderController extends BasePreferenceController
implements DefaultLifecycleObserver {
@VisibleForTesting
static final int AUDIO_STREAM_HEADER_LISTENING_NOW_SUMMARY =
R.string.audio_streams_listening_now;
@VisibleForTesting static final String AUDIO_STREAM_HEADER_NOT_LISTENING_SUMMARY = "";
private static final String TAG = "AudioStreamHeaderController";
private static final String KEY = "audio_stream_header";
private final Executor mExecutor;
private final AudioStreamsHelper mAudioStreamsHelper;
@Nullable private final LocalBluetoothLeBroadcastAssistant mLeBroadcastAssistant;
private final BluetoothLeBroadcastAssistant.Callback mBroadcastAssistantCallback =
new AudioStreamsBroadcastAssistantCallback() {
@Override
public void onSourceRemoved(BluetoothDevice sink, int sourceId, int reason) {
super.onSourceRemoved(sink, sourceId, reason);
updateSummary();
}
@Override
public void onSourceLost(int broadcastId) {
super.onSourceLost(broadcastId);
updateSummary();
}
@Override
public void onReceiveStateChanged(
BluetoothDevice sink,
int sourceId,
BluetoothLeBroadcastReceiveState state) {
super.onReceiveStateChanged(sink, sourceId, state);
if (AudioStreamsHelper.isConnected(state)) {
updateSummary();
mAudioStreamsHelper.startMediaService(
mContext, mBroadcastId, mBroadcastName);
}
}
};
private @Nullable EntityHeaderController mHeaderController;
private @Nullable DashboardFragment mFragment;
private String mBroadcastName = "";
private int mBroadcastId = -1;
public AudioStreamHeaderController(Context context, String preferenceKey) {
super(context, preferenceKey);
mExecutor = Executors.newSingleThreadExecutor();
mAudioStreamsHelper = new AudioStreamsHelper(Utils.getLocalBtManager(context));
mLeBroadcastAssistant = mAudioStreamsHelper.getLeBroadcastAssistant();
}
@Override
public void onStart(@NonNull LifecycleOwner owner) {
if (mLeBroadcastAssistant == null) {
Log.w(TAG, "onStart(): LeBroadcastAssistant is null!");
return;
}
mLeBroadcastAssistant.registerServiceCallBack(mExecutor, mBroadcastAssistantCallback);
}
@Override
public void onStop(@NonNull LifecycleOwner owner) {
if (mLeBroadcastAssistant == null) {
Log.w(TAG, "onStop(): LeBroadcastAssistant is null!");
return;
}
mLeBroadcastAssistant.unregisterServiceCallBack(mBroadcastAssistantCallback);
}
@Override
public final void displayPreference(PreferenceScreen screen) {
LayoutPreference headerPreference = screen.findPreference(KEY);
if (headerPreference != null && mFragment != null) {
mHeaderController =
EntityHeaderController.newInstance(
mFragment.getActivity(),
mFragment,
headerPreference.findViewById(com.android.settings.R.id.entity_header));
if (mBroadcastName != null) {
mHeaderController.setLabel(mBroadcastName);
}
mHeaderController.setIcon(
screen.getContext()
.getDrawable(
com.android.settingslib.R.drawable.ic_bt_le_audio_sharing));
screen.addPreference(headerPreference);
updateSummary();
}
super.displayPreference(screen);
}
private void updateSummary() {
var unused =
ThreadUtils.postOnBackgroundThread(
() -> {
var latestSummary =
mAudioStreamsHelper.getAllConnectedSources().stream()
.map(
BluetoothLeBroadcastReceiveState
::getBroadcastId)
.anyMatch(
connectedBroadcastId ->
connectedBroadcastId
== mBroadcastId)
? mContext.getString(
AUDIO_STREAM_HEADER_LISTENING_NOW_SUMMARY)
: AUDIO_STREAM_HEADER_NOT_LISTENING_SUMMARY;
ThreadUtils.postOnMainThread(
() -> {
if (mHeaderController != null) {
mHeaderController.setSummary(latestSummary);
mHeaderController.done(true);
}
});
});
}
@Override
public int getAvailabilityStatus() {
return AVAILABLE;
}
@Override
public String getPreferenceKey() {
return KEY;
}
/** Initialize with {@link AudioStreamDetailsFragment} and broadcast name and id */
void init(
AudioStreamDetailsFragment audioStreamDetailsFragment,
String broadcastName,
int broadcastId) {
mFragment = audioStreamDetailsFragment;
mBroadcastName = broadcastName;
mBroadcastId = broadcastId;
}
}

View File

@@ -0,0 +1,378 @@
/*
* Copyright (C) 2024 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.audiostreams;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.Service;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothLeBroadcastReceiveState;
import android.bluetooth.BluetoothProfile;
import android.bluetooth.BluetoothVolumeControl;
import android.content.Intent;
import android.media.MediaMetadata;
import android.media.session.MediaSession;
import android.media.session.PlaybackState;
import android.os.Bundle;
import android.os.IBinder;
import android.util.Log;
import androidx.annotation.IntRange;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.android.settings.R;
import com.android.settings.bluetooth.Utils;
import com.android.settings.connecteddevice.audiosharing.AudioSharingUtils;
import com.android.settingslib.bluetooth.BluetoothCallback;
import com.android.settingslib.bluetooth.CachedBluetoothDevice;
import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
import com.android.settingslib.bluetooth.VolumeControlProfile;
import java.util.ArrayList;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class AudioStreamMediaService extends Service {
static final String BROADCAST_ID = "audio_stream_media_service_broadcast_id";
static final String BROADCAST_TITLE = "audio_stream_media_service_broadcast_title";
static final String DEVICES = "audio_stream_media_service_devices";
private static final String TAG = "AudioStreamMediaService";
private static final int NOTIFICATION_ID = 1;
private static final int BROADCAST_CONTENT_TEXT = R.string.audio_streams_listening_now;
private static final String LEAVE_BROADCAST_ACTION = "leave_broadcast_action";
private static final String LEAVE_BROADCAST_TEXT = "Leave Broadcast";
private static final String CHANNEL_ID = "bluetooth_notification_channel";
private static final int STATIC_PLAYBACK_DURATION = 100;
private static final int STATIC_PLAYBACK_POSITION = 30;
private static final int ZERO_PLAYBACK_SPEED = 0;
private final AudioStreamsBroadcastAssistantCallback mBroadcastAssistantCallback =
new AudioStreamsBroadcastAssistantCallback() {
@Override
public void onSourceLost(int broadcastId) {
super.onSourceLost(broadcastId);
if (broadcastId == mBroadcastId) {
stopSelf();
}
}
@Override
public void onSourceRemoved(BluetoothDevice sink, int sourceId, int reason) {
super.onSourceRemoved(sink, sourceId, reason);
if (mAudioStreamsHelper != null
&& mAudioStreamsHelper.getAllConnectedSources().stream()
.map(BluetoothLeBroadcastReceiveState::getBroadcastId)
.noneMatch(id -> id == mBroadcastId)) {
stopSelf();
}
}
};
private final BluetoothCallback mBluetoothCallback =
new BluetoothCallback() {
@Override
public void onBluetoothStateChanged(int bluetoothState) {
if (BluetoothAdapter.STATE_OFF == bluetoothState) {
stopSelf();
}
}
@Override
public void onProfileConnectionStateChanged(
@NonNull CachedBluetoothDevice cachedDevice,
@ConnectionState int state,
int bluetoothProfile) {
if (state == BluetoothAdapter.STATE_DISCONNECTED
&& bluetoothProfile == BluetoothProfile.LE_AUDIO_BROADCAST_ASSISTANT
&& mDevices != null) {
mDevices.remove(cachedDevice.getDevice());
cachedDevice
.getMemberDevice()
.forEach(
m -> {
// Check nullability to pass NullAway check
if (mDevices != null) {
mDevices.remove(m.getDevice());
}
});
}
if (mDevices == null || mDevices.isEmpty()) {
stopSelf();
}
}
};
private final BluetoothVolumeControl.Callback mVolumeControlCallback =
new BluetoothVolumeControl.Callback() {
@Override
public void onDeviceVolumeChanged(
@NonNull BluetoothDevice device,
@IntRange(from = -255, to = 255) int volume) {
if (mDevices == null || mDevices.isEmpty()) {
Log.w(TAG, "active device or device has source is null!");
return;
}
if (mDevices.contains(device)) {
Log.d(
TAG,
"onDeviceVolumeChanged() bluetoothDevice : "
+ device
+ " volume: "
+ volume);
if (volume == 0) {
mIsMuted = true;
} else {
mIsMuted = false;
mLatestPositiveVolume = volume;
}
if (mLocalSession != null) {
mLocalSession.setPlaybackState(getPlaybackState());
if (mNotificationManager != null) {
mNotificationManager.notify(NOTIFICATION_ID, buildNotification());
}
}
}
}
};
private final PlaybackState.Builder mPlayStatePlayingBuilder =
new PlaybackState.Builder()
.setActions(PlaybackState.ACTION_PAUSE | PlaybackState.ACTION_SEEK_TO)
.setState(
PlaybackState.STATE_PLAYING,
STATIC_PLAYBACK_POSITION,
ZERO_PLAYBACK_SPEED)
.addCustomAction(
LEAVE_BROADCAST_ACTION,
LEAVE_BROADCAST_TEXT,
com.android.settings.R.drawable.ic_clear);
private final PlaybackState.Builder mPlayStatePausingBuilder =
new PlaybackState.Builder()
.setActions(PlaybackState.ACTION_PLAY | PlaybackState.ACTION_SEEK_TO)
.setState(
PlaybackState.STATE_PAUSED,
STATIC_PLAYBACK_POSITION,
ZERO_PLAYBACK_SPEED)
.addCustomAction(
LEAVE_BROADCAST_ACTION,
LEAVE_BROADCAST_TEXT,
com.android.settings.R.drawable.ic_clear);
private final ExecutorService mExecutor = Executors.newSingleThreadExecutor();
private int mBroadcastId;
@Nullable private ArrayList<BluetoothDevice> mDevices;
@Nullable private LocalBluetoothManager mLocalBtManager;
@Nullable private AudioStreamsHelper mAudioStreamsHelper;
@Nullable private LocalBluetoothLeBroadcastAssistant mLeBroadcastAssistant;
@Nullable private VolumeControlProfile mVolumeControl;
@Nullable private NotificationManager mNotificationManager;
// Set 25 as default as the volume range from `VolumeControlProfile` is from 0 to 255.
// If the initial volume from `onDeviceVolumeChanged` is larger than zero (not muted), we will
// override this value. Otherwise, we raise the volume to 25 when the play button is clicked.
private int mLatestPositiveVolume = 25;
private boolean mIsMuted = false;
@Nullable private MediaSession mLocalSession;
@Override
public void onCreate() {
if (!AudioSharingUtils.isFeatureEnabled()) {
return;
}
super.onCreate();
mLocalBtManager = Utils.getLocalBtManager(this);
if (mLocalBtManager == null) {
Log.w(TAG, "onCreate() : mLocalBtManager is null!");
return;
}
mAudioStreamsHelper = new AudioStreamsHelper(mLocalBtManager);
mLeBroadcastAssistant = mAudioStreamsHelper.getLeBroadcastAssistant();
if (mLeBroadcastAssistant == null) {
Log.w(TAG, "onCreate() : mLeBroadcastAssistant is null!");
return;
}
mNotificationManager = getSystemService(NotificationManager.class);
if (mNotificationManager == null) {
Log.w(TAG, "onCreate() : notificationManager is null!");
return;
}
if (mNotificationManager.getNotificationChannel(CHANNEL_ID) == null) {
NotificationChannel notificationChannel =
new NotificationChannel(
CHANNEL_ID,
this.getString(com.android.settings.R.string.bluetooth),
NotificationManager.IMPORTANCE_HIGH);
mNotificationManager.createNotificationChannel(notificationChannel);
}
mLocalBtManager.getEventManager().registerCallback(mBluetoothCallback);
mVolumeControl = mLocalBtManager.getProfileManager().getVolumeControlProfile();
if (mVolumeControl != null) {
mVolumeControl.registerCallback(mExecutor, mVolumeControlCallback);
}
mLeBroadcastAssistant.registerServiceCallBack(mExecutor, mBroadcastAssistantCallback);
}
@Override
public void onDestroy() {
super.onDestroy();
if (!AudioSharingUtils.isFeatureEnabled()) {
return;
}
if (mLocalBtManager != null) {
mLocalBtManager.getEventManager().unregisterCallback(mBluetoothCallback);
}
if (mLeBroadcastAssistant != null) {
mLeBroadcastAssistant.unregisterServiceCallBack(mBroadcastAssistantCallback);
}
if (mVolumeControl != null) {
mVolumeControl.unregisterCallback(mVolumeControlCallback);
}
if (mLocalSession != null) {
mLocalSession.release();
mLocalSession = null;
}
}
@Override
public int onStartCommand(@Nullable Intent intent, int flags, int startId) {
Log.d(TAG, "onStartCommand()");
mBroadcastId = intent != null ? intent.getIntExtra(BROADCAST_ID, -1) : -1;
if (mBroadcastId == -1) {
Log.w(TAG, "Invalid broadcast ID. Service will not start.");
stopSelf();
return START_NOT_STICKY;
}
if (intent != null) {
mDevices = intent.getParcelableArrayListExtra(DEVICES, BluetoothDevice.class);
}
if (mDevices == null || mDevices.isEmpty()) {
Log.w(TAG, "No device. Service will not start.");
stopSelf();
return START_NOT_STICKY;
}
if (intent != null) {
createLocalMediaSession(intent.getStringExtra(BROADCAST_TITLE));
startForeground(NOTIFICATION_ID, buildNotification());
}
return START_NOT_STICKY;
}
private void createLocalMediaSession(String title) {
mLocalSession = new MediaSession(this, TAG);
mLocalSession.setMetadata(
new MediaMetadata.Builder()
.putString(MediaMetadata.METADATA_KEY_TITLE, title)
.putLong(MediaMetadata.METADATA_KEY_DURATION, STATIC_PLAYBACK_DURATION)
.build());
mLocalSession.setActive(true);
mLocalSession.setPlaybackState(getPlaybackState());
mLocalSession.setCallback(
new MediaSession.Callback() {
public void onSeekTo(long pos) {
Log.d(TAG, "onSeekTo: " + pos);
if (mLocalSession != null) {
mLocalSession.setPlaybackState(getPlaybackState());
if (mNotificationManager != null) {
mNotificationManager.notify(NOTIFICATION_ID, buildNotification());
}
}
}
@Override
public void onPause() {
if (mDevices == null || mDevices.isEmpty()) {
Log.w(TAG, "active device or device has source is null!");
return;
}
Log.d(
TAG,
"onPause() setting volume for device : "
+ mDevices.get(0)
+ " volume: "
+ 0);
if (mVolumeControl != null) {
mVolumeControl.setDeviceVolume(mDevices.get(0), 0, true);
}
}
@Override
public void onPlay() {
if (mDevices == null || mDevices.isEmpty()) {
Log.w(TAG, "active device or device has source is null!");
return;
}
Log.d(
TAG,
"onPlay() setting volume for device : "
+ mDevices.get(0)
+ " volume: "
+ mLatestPositiveVolume);
if (mVolumeControl != null) {
mVolumeControl.setDeviceVolume(
mDevices.get(0), mLatestPositiveVolume, true);
}
}
@Override
public void onCustomAction(@NonNull String action, Bundle extras) {
Log.d(TAG, "onCustomAction: " + action);
if (action.equals(LEAVE_BROADCAST_ACTION) && mAudioStreamsHelper != null) {
mAudioStreamsHelper.removeSource(mBroadcastId);
}
}
});
}
private PlaybackState getPlaybackState() {
return mIsMuted ? mPlayStatePausingBuilder.build() : mPlayStatePlayingBuilder.build();
}
private Notification buildNotification() {
Notification.Builder notificationBuilder =
new Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(com.android.settingslib.R.drawable.ic_bt_le_audio_sharing)
.setStyle(
new Notification.MediaStyle()
.setMediaSession(
mLocalSession != null
? mLocalSession.getSessionToken()
: null))
.setContentText(this.getString(BROADCAST_CONTENT_TEXT))
.setSilent(true);
return notificationBuilder.build();
}
@Nullable
@Override
public IBinder onBind(Intent intent) {
return null;
}
}

View File

@@ -0,0 +1,201 @@
/*
* Copyright (C) 2023 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.audiostreams;
import android.bluetooth.BluetoothLeBroadcastMetadata;
import android.bluetooth.BluetoothLeBroadcastReceiveState;
import android.content.Context;
import android.util.AttributeSet;
import android.view.View;
import androidx.annotation.Nullable;
import androidx.preference.PreferenceViewHolder;
import com.android.settings.R;
import com.android.settingslib.widget.TwoTargetPreference;
/**
* Custom preference class for managing audio stream preferences with an optional lock icon. Extends
* {@link TwoTargetPreference}.
*/
class AudioStreamPreference extends TwoTargetPreference {
private boolean mIsConnected = false;
private boolean mIsEncrypted = true;
@Nullable private AudioStream mAudioStream;
/**
* Update preference UI based on connection status
*
* @param isConnected Is this stream connected
* @param summary Summary text
* @param onPreferenceClickListener Click listener for the preference
*/
void setIsConnected(
boolean isConnected,
String summary,
@Nullable OnPreferenceClickListener onPreferenceClickListener) {
if (mIsConnected == isConnected
&& getSummary() == summary
&& getOnPreferenceClickListener() == onPreferenceClickListener) {
// Nothing to update.
return;
}
mIsConnected = isConnected;
setSummary(summary);
setOnPreferenceClickListener(onPreferenceClickListener);
notifyChanged();
}
private AudioStreamPreference(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
setIcon(com.android.settingslib.R.drawable.ic_bt_le_audio_sharing);
}
void setAudioStreamState(AudioStreamsProgressCategoryController.AudioStreamState state) {
if (mAudioStream != null) {
mAudioStream.setState(state);
}
}
void setAudioStreamMetadata(BluetoothLeBroadcastMetadata metadata) {
if (mAudioStream != null) {
mAudioStream.setMetadata(metadata);
}
}
int getAudioStreamBroadcastId() {
return mAudioStream != null ? mAudioStream.getBroadcastId() : -1;
}
@Nullable
String getAudioStreamBroadcastName() {
return mAudioStream != null ? mAudioStream.getBroadcastName() : null;
}
int getAudioStreamRssi() {
return mAudioStream != null ? mAudioStream.getRssi() : -1;
}
@Nullable
BluetoothLeBroadcastMetadata getAudioStreamMetadata() {
return mAudioStream != null ? mAudioStream.getMetadata() : null;
}
AudioStreamsProgressCategoryController.AudioStreamState getAudioStreamState() {
return mAudioStream != null
? mAudioStream.getState()
: AudioStreamsProgressCategoryController.AudioStreamState.UNKNOWN;
}
@Override
protected boolean shouldHideSecondTarget() {
return mIsConnected || !mIsEncrypted;
}
@Override
protected int getSecondTargetResId() {
return R.layout.preference_widget_lock;
}
@Override
public void onBindViewHolder(PreferenceViewHolder holder) {
super.onBindViewHolder(holder);
View divider =
holder.findViewById(
com.android.settingslib.widget.preference.twotarget.R.id
.two_target_divider);
if (divider != null) {
divider.setVisibility(View.GONE);
}
}
static AudioStreamPreference fromMetadata(
Context context, BluetoothLeBroadcastMetadata source) {
AudioStreamPreference preference = new AudioStreamPreference(context, /* attrs= */ null);
preference.setIsEncrypted(source.isEncrypted());
preference.setTitle(AudioStreamsHelper.getBroadcastName(source));
preference.setAudioStream(new AudioStream(source));
return preference;
}
static AudioStreamPreference fromReceiveState(
Context context, BluetoothLeBroadcastReceiveState receiveState) {
AudioStreamPreference preference = new AudioStreamPreference(context, /* attrs= */ null);
preference.setTitle(AudioStreamsHelper.getBroadcastName(receiveState));
preference.setAudioStream(new AudioStream(receiveState));
return preference;
}
private void setAudioStream(AudioStream audioStream) {
mAudioStream = audioStream;
}
private void setIsEncrypted(boolean isEncrypted) {
mIsEncrypted = isEncrypted;
}
private static final class AudioStream {
private static final int UNAVAILABLE = -1;
@Nullable private BluetoothLeBroadcastMetadata mMetadata;
@Nullable private BluetoothLeBroadcastReceiveState mReceiveState;
private AudioStreamsProgressCategoryController.AudioStreamState mState =
AudioStreamsProgressCategoryController.AudioStreamState.UNKNOWN;
private AudioStream(BluetoothLeBroadcastMetadata metadata) {
mMetadata = metadata;
}
private AudioStream(BluetoothLeBroadcastReceiveState receiveState) {
mReceiveState = receiveState;
}
private int getBroadcastId() {
return mMetadata != null
? mMetadata.getBroadcastId()
: mReceiveState != null ? mReceiveState.getBroadcastId() : UNAVAILABLE;
}
private @Nullable String getBroadcastName() {
return mMetadata != null
? AudioStreamsHelper.getBroadcastName(mMetadata)
: mReceiveState != null
? AudioStreamsHelper.getBroadcastName(mReceiveState)
: null;
}
private int getRssi() {
return mMetadata != null ? mMetadata.getRssi() : Integer.MAX_VALUE;
}
private AudioStreamsProgressCategoryController.AudioStreamState getState() {
return mState;
}
@Nullable
private BluetoothLeBroadcastMetadata getMetadata() {
return mMetadata;
}
private void setState(AudioStreamsProgressCategoryController.AudioStreamState state) {
mState = state;
}
private void setMetadata(BluetoothLeBroadcastMetadata metadata) {
mMetadata = metadata;
}
}
}

View File

@@ -0,0 +1,111 @@
/*
* Copyright (C) 2024 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.audiostreams;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.annotation.VisibleForTesting;
import androidx.preference.Preference;
import com.android.settingslib.bluetooth.BluetoothUtils;
import com.android.settingslib.utils.ThreadUtils;
class AudioStreamStateHandler {
private static final String TAG = "AudioStreamStateHandler";
private static final boolean DEBUG = BluetoothUtils.D;
@VisibleForTesting static final int EMPTY_STRING_RES = 0;
final AudioStreamsRepository mAudioStreamsRepository = AudioStreamsRepository.getInstance();
final Handler mHandler = new Handler(Looper.getMainLooper());
AudioStreamStateHandler() {}
void handleStateChange(
AudioStreamPreference preference,
AudioStreamsProgressCategoryController controller,
AudioStreamsHelper helper) {
var newState = getStateEnum();
if (preference.getAudioStreamState() == newState) {
return;
}
if (DEBUG) {
Log.d(
TAG,
"moveToState() : moving preference : ["
+ preference.getAudioStreamBroadcastId()
+ ", "
+ preference.getAudioStreamBroadcastName()
+ "] from state : "
+ preference.getAudioStreamState()
+ " to state : "
+ newState);
}
preference.setAudioStreamState(newState);
performAction(preference, controller, helper);
// Update UI
ThreadUtils.postOnMainThread(
() ->
preference.setIsConnected(
newState
== AudioStreamsProgressCategoryController.AudioStreamState
.SOURCE_ADDED,
getSummary() != EMPTY_STRING_RES
? preference.getContext().getString(getSummary())
: "",
getOnClickListener(controller)));
}
/**
* Perform action related to the audio stream state (e.g, addSource) This method is intended to
* be optionally overridden by subclasses to provide custom behavior based on the audio stream
* state change.
*/
void performAction(
AudioStreamPreference preference,
AudioStreamsProgressCategoryController controller,
AudioStreamsHelper helper) {}
/**
* The preference summary for the audio stream state (e.g, Scanning...) This method is intended
* to be optionally overridden.
*/
@StringRes
int getSummary() {
return EMPTY_STRING_RES;
}
/**
* The preference on click event for the audio stream state (e.g, open up a dialog) This method
* is intended to be optionally overridden.
*/
@Nullable
Preference.OnPreferenceClickListener getOnClickListener(
AudioStreamsProgressCategoryController controller) {
return null;
}
/** Subclasses should always override. */
AudioStreamsProgressCategoryController.AudioStreamState getStateEnum() {
return AudioStreamsProgressCategoryController.AudioStreamState.UNKNOWN;
}
}

View File

@@ -0,0 +1,70 @@
/*
* Copyright (C) 2023 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.audiostreams;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.LifecycleOwner;
import androidx.preference.Preference;
import androidx.preference.PreferenceScreen;
import com.android.settings.core.BasePreferenceController;
public class AudioStreamsActiveDeviceController extends BasePreferenceController
implements AudioStreamsActiveDeviceSummaryUpdater.OnSummaryChangeListener,
DefaultLifecycleObserver {
public static final String KEY = "audio_streams_active_device";
private final AudioStreamsActiveDeviceSummaryUpdater mSummaryHelper;
@Nullable private Preference mPreference;
public AudioStreamsActiveDeviceController(Context context, String preferenceKey) {
super(context, preferenceKey);
mSummaryHelper = new AudioStreamsActiveDeviceSummaryUpdater(mContext, this);
}
@Override
public void displayPreference(PreferenceScreen screen) {
super.displayPreference(screen);
mPreference = screen.findPreference(KEY);
}
@Override
public int getAvailabilityStatus() {
return AVAILABLE;
}
@Override
public void onSummaryChanged(String summary) {
if (mPreference != null) {
mPreference.setSummary(summary);
}
}
@Override
public void onResume(@NonNull LifecycleOwner owner) {
mSummaryHelper.register(true);
}
@Override
public void onStop(@NonNull LifecycleOwner owner) {
mSummaryHelper.register(false);
}
}

View File

@@ -0,0 +1,106 @@
/*
* Copyright (C) 2023 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.audiostreams;
import android.bluetooth.BluetoothProfile;
import android.content.Context;
import android.text.TextUtils;
import android.util.Log;
import androidx.annotation.Nullable;
import com.android.settings.R;
import com.android.settings.bluetooth.Utils;
import com.android.settingslib.bluetooth.BluetoothCallback;
import com.android.settingslib.bluetooth.BluetoothUtils;
import com.android.settingslib.bluetooth.CachedBluetoothDevice;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
import com.android.settingslib.utils.ThreadUtils;
public class AudioStreamsActiveDeviceSummaryUpdater implements BluetoothCallback {
private static final String TAG = "AudioStreamsActiveDeviceSummaryUpdater";
private static final boolean DEBUG = BluetoothUtils.D;
private final LocalBluetoothManager mBluetoothManager;
private Context mContext;
@Nullable private String mSummary;
private OnSummaryChangeListener mListener;
public AudioStreamsActiveDeviceSummaryUpdater(
Context context, OnSummaryChangeListener listener) {
mContext = context;
mBluetoothManager = Utils.getLocalBluetoothManager(context);
mListener = listener;
}
@Override
public void onActiveDeviceChanged(
@Nullable CachedBluetoothDevice activeDevice, int bluetoothProfile) {
if (DEBUG) {
Log.d(
TAG,
"onActiveDeviceChanged() with activeDevice : "
+ (activeDevice == null ? "null" : activeDevice.getAddress())
+ " on profile : "
+ bluetoothProfile);
}
if (bluetoothProfile == BluetoothProfile.LE_AUDIO) {
notifyChangeIfNeeded();
}
}
void register(boolean register) {
if (register) {
notifyChangeIfNeeded();
mBluetoothManager.getEventManager().registerCallback(this);
} else {
mBluetoothManager.getEventManager().unregisterCallback(this);
}
}
private void notifyChangeIfNeeded() {
var unused =
ThreadUtils.postOnBackgroundThread(
() -> {
String summary = getSummary();
if (!TextUtils.equals(mSummary, summary)) {
mSummary = summary;
ThreadUtils.postOnMainThread(
() -> mListener.onSummaryChanged(summary));
}
});
}
private String getSummary() {
var connectedSink =
AudioStreamsHelper.getCachedBluetoothDeviceInSharingOrLeConnected(
mBluetoothManager);
if (connectedSink.isEmpty()) {
return mContext.getString(R.string.audio_streams_dialog_no_le_device_title);
}
return connectedSink.get().getName();
}
/** Interface definition for a callback to be invoked when the summary has been changed. */
interface OnSummaryChangeListener {
/**
* Called when summary has changed.
*
* @param summary The new summary.
*/
void onSummaryChanged(String summary);
}
}

View File

@@ -0,0 +1,132 @@
/*
* Copyright (C) 2023 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.audiostreams;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothLeBroadcastAssistant;
import android.bluetooth.BluetoothLeBroadcastMetadata;
import android.bluetooth.BluetoothLeBroadcastReceiveState;
import android.util.Log;
import com.android.settingslib.bluetooth.BluetoothUtils;
public class AudioStreamsBroadcastAssistantCallback
implements BluetoothLeBroadcastAssistant.Callback {
private static final String TAG = "AudioStreamsBroadcastAssistantCallback";
private static final boolean DEBUG = BluetoothUtils.D;
@Override
public void onReceiveStateChanged(
BluetoothDevice sink, int sourceId, BluetoothLeBroadcastReceiveState state) {
if (DEBUG) {
Log.d(
TAG,
"onReceiveStateChanged() sink : "
+ sink.getAddress()
+ " sourceId: "
+ sourceId
+ " state: "
+ state);
}
}
@Override
public void onSearchStartFailed(int reason) {
Log.w(TAG, "onSearchStartFailed() reason : " + reason);
}
@Override
public void onSearchStarted(int reason) {
if (DEBUG) {
Log.d(TAG, "onSearchStarted() reason : " + reason);
}
}
@Override
public void onSearchStopFailed(int reason) {
Log.w(TAG, "onSearchStopFailed() reason : " + reason);
}
@Override
public void onSearchStopped(int reason) {
if (DEBUG) {
Log.d(TAG, "onSearchStopped() reason : " + reason);
}
}
@Override
public void onSourceAddFailed(
BluetoothDevice sink, BluetoothLeBroadcastMetadata source, int reason) {
if (DEBUG) {
Log.d(
TAG,
"onSourceAddFailed() sink : "
+ sink.getAddress()
+ " source: "
+ source
+ " reason: "
+ reason);
}
}
@Override
public void onSourceAdded(BluetoothDevice sink, int sourceId, int reason) {
if (DEBUG) {
Log.d(
TAG,
"onSourceAdded() sink : "
+ sink.getAddress()
+ " sourceId: "
+ sourceId
+ " reason: "
+ reason);
}
}
@Override
public void onSourceFound(BluetoothLeBroadcastMetadata source) {
if (DEBUG) {
Log.d(TAG, "onSourceFound() broadcastId : " + source.getBroadcastId());
}
}
@Override
public void onSourceLost(int broadcastId) {
if (DEBUG) {
Log.d(TAG, "onSourceLost() broadcastId : " + broadcastId);
}
}
@Override
public void onSourceModified(BluetoothDevice sink, int sourceId, int reason) {}
@Override
public void onSourceModifyFailed(BluetoothDevice sink, int sourceId, int reason) {}
@Override
public void onSourceRemoveFailed(BluetoothDevice sink, int sourceId, int reason) {
Log.w(TAG, "onSourceRemoveFailed() sourceId : " + sourceId + " reason : " + reason);
}
@Override
public void onSourceRemoved(BluetoothDevice sink, int sourceId, int reason) {
if (DEBUG) {
Log.d(TAG, "onSourceRemoved() sourceId : " + sourceId + " reason : " + reason);
}
}
}

View File

@@ -0,0 +1,134 @@
/*
* Copyright (C) 2023 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.audiostreams;
import android.bluetooth.BluetoothProfile;
import android.content.Context;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.LifecycleOwner;
import com.android.settings.bluetooth.Utils;
import com.android.settings.connecteddevice.audiosharing.AudioSharingBasePreferenceController;
import com.android.settings.connecteddevice.audiosharing.AudioSharingUtils;
import com.android.settingslib.bluetooth.BluetoothCallback;
import com.android.settingslib.bluetooth.BluetoothUtils;
import com.android.settingslib.bluetooth.CachedBluetoothDevice;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
import com.android.settingslib.flags.Flags;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
public class AudioStreamsCategoryController extends AudioSharingBasePreferenceController {
private static final String TAG = "AudioStreamsCategoryController";
private static final boolean DEBUG = BluetoothUtils.D;
private final LocalBluetoothManager mLocalBtManager;
private final Executor mExecutor;
private final BluetoothCallback mBluetoothCallback =
new BluetoothCallback() {
@Override
public void onActiveDeviceChanged(
@Nullable CachedBluetoothDevice activeDevice, int bluetoothProfile) {
if (bluetoothProfile == BluetoothProfile.LE_AUDIO) {
updateVisibility();
}
}
};
public AudioStreamsCategoryController(Context context, String key) {
super(context, key);
mLocalBtManager = Utils.getLocalBtManager(mContext);
mExecutor = Executors.newSingleThreadExecutor();
}
@Override
public void onStart(@NonNull LifecycleOwner owner) {
super.onStart(owner);
if (mLocalBtManager != null) {
mLocalBtManager.getEventManager().registerCallback(mBluetoothCallback);
}
}
@Override
public void onStop(@NonNull LifecycleOwner owner) {
super.onStop(owner);
if (mLocalBtManager != null) {
mLocalBtManager.getEventManager().unregisterCallback(mBluetoothCallback);
}
}
@Override
public int getAvailabilityStatus() {
return Flags.enableLeAudioQrCodePrivateBroadcastSharing()
? AVAILABLE
: UNSUPPORTED_ON_DEVICE;
}
@Override
public void updateVisibility() {
if (mPreference == null) return;
mExecutor.execute(
() -> {
if (!isAvailable()) {
Log.d(TAG, "skip updateVisibility, unavailable preference");
AudioSharingUtils.postOnMainThread(
mContext,
() -> { // Check nullability to pass NullAway check
if (mPreference != null) {
mPreference.setVisible(false);
}
});
return;
}
boolean hasConnectedLe =
AudioStreamsHelper.getCachedBluetoothDeviceInSharingOrLeConnected(
mLocalBtManager)
.isPresent();
boolean isProfileReady =
AudioSharingUtils.isAudioSharingProfileReady(
mLocalBtManager.getProfileManager());
boolean isBroadcasting = isBroadcasting();
boolean isBluetoothOn = isBluetoothStateOn();
if (DEBUG) {
Log.d(
TAG,
"updateVisibility() isBroadcasting : "
+ isBroadcasting
+ " hasConnectedLe : "
+ hasConnectedLe
+ " is BT on : "
+ isBluetoothOn
+ " is profile ready : "
+ isProfileReady);
}
AudioSharingUtils.postOnMainThread(
mContext,
() -> { // Check nullability to pass NullAway check
if (mPreference != null) {
mPreference.setVisible(
isProfileReady
&& isBluetoothOn
&& hasConnectedLe
&& !isBroadcasting);
}
});
});
}
}

View File

@@ -0,0 +1,138 @@
/*
* Copyright (C) 2023 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.audiostreams;
import static com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsScanQrCodeController.REQUEST_SCAN_BT_BROADCAST_QR_CODE;
import android.app.Activity;
import android.bluetooth.BluetoothLeBroadcastMetadata;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.util.Log;
import androidx.annotation.Nullable;
import com.android.settings.R;
import com.android.settings.connecteddevice.audiosharing.audiostreams.qrcode.QrCodeScanModeFragment;
import com.android.settings.dashboard.DashboardFragment;
import com.android.settingslib.bluetooth.BluetoothLeBroadcastMetadataExt;
import com.android.settingslib.bluetooth.BluetoothUtils;
import com.google.common.base.Strings;
public class AudioStreamsDashboardFragment extends DashboardFragment {
private static final String TAG = "AudioStreamsDashboardFrag";
private static final boolean DEBUG = BluetoothUtils.D;
private AudioStreamsProgressCategoryController mAudioStreamsProgressCategoryController;
public AudioStreamsDashboardFragment() {
super();
}
@Override
public int getMetricsCategory() {
// TODO: update category id.
return 0;
}
@Override
protected String getLogTag() {
return TAG;
}
@Override
public int getHelpResource() {
return R.string.help_url_audio_sharing;
}
@Override
protected int getPreferenceScreenResId() {
return R.xml.bluetooth_le_audio_streams;
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
}
@Override
public void onAttach(Context context) {
super.onAttach(context);
use(AudioStreamsScanQrCodeController.class).setFragment(this);
mAudioStreamsProgressCategoryController = use(AudioStreamsProgressCategoryController.class);
mAudioStreamsProgressCategoryController.setFragment(this);
if (getArguments() != null) {
String broadcastMetadataStr =
getArguments().getString(AudioStreamConfirmDialog.KEY_BROADCAST_METADATA);
if (!Strings.isNullOrEmpty(broadcastMetadataStr)) {
BluetoothLeBroadcastMetadata broadcastMetadata =
BluetoothLeBroadcastMetadataExt.INSTANCE.convertToBroadcastMetadata(
broadcastMetadataStr);
if (broadcastMetadata == null) {
Log.w(TAG, "onAttach() broadcastMetadata is null!");
} else {
mAudioStreamsProgressCategoryController.setSourceFromQrCode(broadcastMetadata);
}
}
}
}
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
}
@Override
public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (DEBUG) {
Log.d(
TAG,
"onActivityResult() requestCode : "
+ requestCode
+ " resultCode : "
+ resultCode);
}
if (requestCode == REQUEST_SCAN_BT_BROADCAST_QR_CODE) {
if (resultCode == Activity.RESULT_OK) {
String broadcastMetadata =
data != null
? data.getStringExtra(QrCodeScanModeFragment.KEY_BROADCAST_METADATA)
: "";
BluetoothLeBroadcastMetadata source =
BluetoothLeBroadcastMetadataExt.INSTANCE.convertToBroadcastMetadata(
broadcastMetadata);
if (source == null) {
Log.w(TAG, "onActivityResult() source is null!");
return;
}
if (DEBUG) {
Log.d(TAG, "onActivityResult() broadcastId : " + source.getBroadcastId());
}
if (mAudioStreamsProgressCategoryController == null) {
Log.w(
TAG,
"onActivityResult() AudioStreamsProgressCategoryController is null!");
return;
}
mAudioStreamsProgressCategoryController.setSourceFromQrCode(source);
}
}
}
}

View File

@@ -0,0 +1,249 @@
/*
* Copyright (C) 2024 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.audiostreams;
import android.app.AlertDialog;
import android.app.Dialog;
import android.content.Context;
import android.os.Bundle;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.Button;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.DialogFragment;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import com.android.settings.R;
import com.android.settings.core.instrumentation.InstrumentedDialogFragment;
import com.google.common.base.Strings;
import java.util.function.Consumer;
/** A dialog fragment for constructing and showing audio stream dialogs. */
public class AudioStreamsDialogFragment extends InstrumentedDialogFragment {
private static final String TAG = "AudioStreamsDialogFragment";
private final DialogBuilder mDialogBuilder;
AudioStreamsDialogFragment(DialogBuilder dialogBuilder) {
mDialogBuilder = dialogBuilder;
}
@Override
public int getMetricsCategory() {
// TODO(chelseahao): update metrics id
return 0;
}
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
return mDialogBuilder.build();
}
/**
* Displays the audio stream dialog on the specified host fragment.
*
* @param host The fragment to host the dialog.
* @param dialogBuilder The builder for constructing the dialog.
*/
public static void show(Fragment host, DialogBuilder dialogBuilder) {
if (!host.isAdded()) {
Log.w(TAG, "The host fragment is not added to the activity!");
return;
}
FragmentManager manager = host.getChildFragmentManager();
(new AudioStreamsDialogFragment(dialogBuilder)).show(manager, TAG);
}
static void dismissAll(Fragment host) {
if (!host.isAdded()) {
Log.w(TAG, "The host fragment is not added to the activity!");
return;
}
FragmentManager manager = host.getChildFragmentManager();
Fragment dialog = manager.findFragmentByTag(TAG);
if (dialog != null
&& ((DialogFragment) dialog).getDialog() != null
&& ((DialogFragment) dialog).getDialog().isShowing()) {
((DialogFragment) dialog).dismiss();
}
}
@Override
public void show(@NonNull FragmentManager manager, @Nullable String tag) {
Fragment dialog = manager.findFragmentByTag(TAG);
if (dialog != null
&& ((DialogFragment) dialog).getDialog() != null
&& ((DialogFragment) dialog).getDialog().isShowing()) {
Log.w(TAG, "Dialog already showing, ignore");
return;
}
super.show(manager, tag);
}
/** A builder class for constructing the audio stream dialog. */
public static class DialogBuilder {
private final Context mContext;
private final AlertDialog.Builder mBuilder;
@Nullable private String mTitle;
@Nullable private String mSubTitle1;
@Nullable private String mSubTitle2;
@Nullable private String mLeftButtonText;
@Nullable private String mRightButtonText;
@Nullable private Consumer<AlertDialog> mLeftButtonOnClickListener;
@Nullable private Consumer<AlertDialog> mRightButtonOnClickListener;
/**
* Constructs a new instance of DialogBuilder.
*
* @param context The context used for building the dialog.
*/
public DialogBuilder(Context context) {
mContext = context;
mBuilder = new AlertDialog.Builder(context);
}
/**
* Sets the title of the dialog.
*
* @param title The title text.
* @return This DialogBuilder instance.
*/
public DialogBuilder setTitle(String title) {
mTitle = title;
return this;
}
/**
* Sets the first subtitle of the dialog.
*
* @param subTitle1 The text of the first subtitle.
* @return This DialogBuilder instance.
*/
public DialogBuilder setSubTitle1(String subTitle1) {
mSubTitle1 = subTitle1;
return this;
}
/**
* Sets the second subtitle of the dialog.
*
* @param subTitle2 The text of the second subtitle.
* @return This DialogBuilder instance.
*/
public DialogBuilder setSubTitle2(String subTitle2) {
mSubTitle2 = subTitle2;
return this;
}
/**
* Sets the text of the left button.
*
* @param text The text of the left button.
* @return This DialogBuilder instance.
*/
public DialogBuilder setLeftButtonText(String text) {
mLeftButtonText = text;
return this;
}
/**
* Sets the click listener of the left button.
*
* @param listener The click listener for the left button.
* @return This DialogBuilder instance.
*/
public DialogBuilder setLeftButtonOnClickListener(Consumer<AlertDialog> listener) {
mLeftButtonOnClickListener = listener;
return this;
}
/**
* Sets the text of the right button.
*
* @param text The text of the right button.
* @return This DialogBuilder instance.
*/
public DialogBuilder setRightButtonText(String text) {
mRightButtonText = text;
return this;
}
/**
* Sets the click listener of the right button.
*
* @param listener The click listener for the right button.
* @return This DialogBuilder instance.
*/
public DialogBuilder setRightButtonOnClickListener(Consumer<AlertDialog> listener) {
mRightButtonOnClickListener = listener;
return this;
}
AlertDialog build() {
View rootView =
LayoutInflater.from(mContext)
.inflate(R.xml.bluetooth_audio_streams_dialog, /* parent= */ null);
AlertDialog dialog = mBuilder.setView(rootView).setCancelable(false).create();
dialog.setCanceledOnTouchOutside(false);
TextView title = rootView.requireViewById(R.id.dialog_title);
title.setText(mTitle);
if (!Strings.isNullOrEmpty(mSubTitle1)) {
TextView subTitle1 = rootView.requireViewById(R.id.dialog_subtitle);
subTitle1.setText(mSubTitle1);
subTitle1.setVisibility(View.VISIBLE);
}
if (!Strings.isNullOrEmpty(mSubTitle2)) {
TextView subTitle2 = rootView.requireViewById(R.id.dialog_subtitle_2);
subTitle2.setText(mSubTitle2);
subTitle2.setVisibility(View.VISIBLE);
}
if (!Strings.isNullOrEmpty(mLeftButtonText)) {
Button leftButton = rootView.requireViewById(R.id.left_button);
leftButton.setText(mLeftButtonText);
leftButton.setVisibility(View.VISIBLE);
leftButton.setOnClickListener(
unused -> {
if (mLeftButtonOnClickListener != null) {
mLeftButtonOnClickListener.accept(dialog);
}
});
}
if (!Strings.isNullOrEmpty(mRightButtonText)) {
Button rightButton = rootView.requireViewById(R.id.right_button);
rightButton.setText(mRightButtonText);
rightButton.setVisibility(View.VISIBLE);
rightButton.setOnClickListener(
unused -> {
if (mRightButtonOnClickListener != null) {
mRightButtonOnClickListener.accept(dialog);
}
});
}
return dialog;
}
}
}

View File

@@ -0,0 +1,355 @@
/*
* Copyright (C) 2023 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.audiostreams;
import static com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamMediaService.BROADCAST_ID;
import static com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamMediaService.BROADCAST_TITLE;
import static com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamMediaService.DEVICES;
import static java.util.Collections.emptyList;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothLeAudioContentMetadata;
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.VisibleForTesting;
import com.android.settings.connecteddevice.audiosharing.AudioSharingUtils;
import com.android.settingslib.bluetooth.BluetoothUtils;
import com.android.settingslib.bluetooth.CachedBluetoothDevice;
import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
import com.android.settingslib.bluetooth.LocalBluetoothProfileManager;
import com.android.settingslib.utils.ThreadUtils;
import com.google.common.base.Strings;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.stream.Stream;
import javax.annotation.Nullable;
/**
* A helper class that adds, removes and retrieves LE broadcast sources for all active sink devices.
*/
public class AudioStreamsHelper {
private static final String TAG = "AudioStreamsHelper";
private static final boolean DEBUG = BluetoothUtils.D;
private final @Nullable LocalBluetoothManager mBluetoothManager;
private final @Nullable LocalBluetoothLeBroadcastAssistant mLeBroadcastAssistant;
AudioStreamsHelper(@Nullable LocalBluetoothManager bluetoothManager) {
mBluetoothManager = bluetoothManager;
mLeBroadcastAssistant = getLeBroadcastAssistant(mBluetoothManager);
}
/**
* Adds the specified LE broadcast source to all active sinks.
*
* @param source The LE broadcast metadata representing the audio source.
*/
void addSource(BluetoothLeBroadcastMetadata source) {
if (mLeBroadcastAssistant == null) {
Log.w(TAG, "addSource(): LeBroadcastAssistant is null!");
return;
}
var unused =
ThreadUtils.postOnBackgroundThread(
() -> {
for (var sink :
getConnectedBluetoothDevices(
mBluetoothManager, /* inSharingOnly= */ false)) {
if (DEBUG) {
Log.d(
TAG,
"addSource(): join broadcast broadcastId"
+ " : "
+ source.getBroadcastId()
+ " sink : "
+ sink.getAddress());
}
mLeBroadcastAssistant.addSource(sink, source, false);
}
});
}
/** Removes sources from LE broadcasts associated for all active sinks based on broadcast Id. */
void removeSource(int broadcastId) {
if (mLeBroadcastAssistant == null) {
Log.w(TAG, "removeSource(): LeBroadcastAssistant is null!");
return;
}
var unused =
ThreadUtils.postOnBackgroundThread(
() -> {
for (var sink :
getConnectedBluetoothDevices(
mBluetoothManager, /* inSharingOnly= */ true)) {
if (DEBUG) {
Log.d(
TAG,
"removeSource(): remove all sources with broadcast id :"
+ broadcastId
+ " from sink : "
+ sink.getAddress());
}
mLeBroadcastAssistant.getAllSources(sink).stream()
.filter(state -> state.getBroadcastId() == broadcastId)
.forEach(
state ->
mLeBroadcastAssistant.removeSource(
sink, state.getSourceId()));
}
});
}
/** Retrieves a list of all LE broadcast receive states from active sinks. */
@VisibleForTesting
public List<BluetoothLeBroadcastReceiveState> getAllConnectedSources() {
if (mLeBroadcastAssistant == null) {
Log.w(TAG, "getAllSources(): LeBroadcastAssistant is null!");
return emptyList();
}
return getConnectedBluetoothDevices(mBluetoothManager, /* inSharingOnly= */ true).stream()
.flatMap(sink -> mLeBroadcastAssistant.getAllSources(sink).stream())
.filter(AudioStreamsHelper::isConnected)
.toList();
}
@Nullable
LocalBluetoothLeBroadcastAssistant getLeBroadcastAssistant() {
return mLeBroadcastAssistant;
}
/** Checks the connectivity status based on the provided broadcast receive state. */
public static boolean isConnected(BluetoothLeBroadcastReceiveState state) {
return state.getBisSyncState().stream().anyMatch(bitmap -> bitmap != 0);
}
static boolean isBadCode(BluetoothLeBroadcastReceiveState state) {
return state.getPaSyncState() == BluetoothLeBroadcastReceiveState.PA_SYNC_STATE_SYNCHRONIZED
&& state.getBigEncryptionState()
== BluetoothLeBroadcastReceiveState.BIG_ENCRYPTION_STATE_BAD_CODE;
}
/**
* Returns a {@code CachedBluetoothDevice} that is either connected to a broadcast source or is
* a connected LE device.
*/
public static Optional<CachedBluetoothDevice> getCachedBluetoothDeviceInSharingOrLeConnected(
@androidx.annotation.Nullable LocalBluetoothManager manager) {
if (manager == null) {
Log.w(
TAG,
"getCachedBluetoothDeviceInSharingOrLeConnected(): LocalBluetoothManager is"
+ " null!");
return Optional.empty();
}
var groupedDevices = AudioSharingUtils.fetchConnectedDevicesByGroupId(manager);
var leadDevices =
AudioSharingUtils.buildOrderedConnectedLeadDevices(manager, groupedDevices, false);
if (leadDevices.isEmpty()) {
Log.w(TAG, "getCachedBluetoothDeviceInSharingOrLeConnected(): No lead device!");
return Optional.empty();
}
var deviceHasSource =
leadDevices.stream()
.filter(device -> hasConnectedBroadcastSource(device, manager))
.findFirst();
if (deviceHasSource.isPresent()) {
Log.d(
TAG,
"getCachedBluetoothDeviceInSharingOrLeConnected(): Device has connected source"
+ " found: "
+ deviceHasSource.get().getAddress());
return deviceHasSource;
}
Log.d(
TAG,
"getCachedBluetoothDeviceInSharingOrLeConnected(): Device connected found: "
+ leadDevices.get(0).getAddress());
return Optional.of(leadDevices.get(0));
}
/** Returns a {@code CachedBluetoothDevice} that has a connected broadcast source. */
static Optional<CachedBluetoothDevice> getCachedBluetoothDeviceInSharing(
@androidx.annotation.Nullable LocalBluetoothManager manager) {
if (manager == null) {
Log.w(TAG, "getCachedBluetoothDeviceInSharing(): LocalBluetoothManager is null!");
return Optional.empty();
}
var groupedDevices = AudioSharingUtils.fetchConnectedDevicesByGroupId(manager);
var leadDevices =
AudioSharingUtils.buildOrderedConnectedLeadDevices(manager, groupedDevices, false);
if (leadDevices.isEmpty()) {
Log.w(TAG, "getCachedBluetoothDeviceInSharing(): No lead device!");
return Optional.empty();
}
return leadDevices.stream()
.filter(device -> hasConnectedBroadcastSource(device, manager))
.findFirst();
}
/**
* Check if {@link CachedBluetoothDevice} has connected to a broadcast source.
*
* @param cachedDevice The cached bluetooth device to check.
* @param localBtManager The BT manager to provide BT functions.
* @return Whether the device has connected to a broadcast source.
*/
private static boolean hasConnectedBroadcastSource(
CachedBluetoothDevice cachedDevice, LocalBluetoothManager localBtManager) {
if (localBtManager == null) {
Log.d(TAG, "Skip check hasConnectedBroadcastSource due to bt manager is null");
return false;
}
LocalBluetoothLeBroadcastAssistant assistant =
localBtManager.getProfileManager().getLeAudioBroadcastAssistantProfile();
if (assistant == null) {
Log.d(TAG, "Skip check hasConnectedBroadcastSource due to assistant profile is null");
return false;
}
List<BluetoothLeBroadcastReceiveState> sourceList =
assistant.getAllSources(cachedDevice.getDevice());
if (!sourceList.isEmpty()
&& sourceList.stream().anyMatch(AudioStreamsHelper::isConnected)) {
Log.d(
TAG,
"Lead device has connected broadcast source, device = "
+ cachedDevice.getDevice().getAnonymizedAddress());
return true;
}
// Return true if member device is in broadcast.
for (CachedBluetoothDevice device : cachedDevice.getMemberDevice()) {
List<BluetoothLeBroadcastReceiveState> list =
assistant.getAllSources(device.getDevice());
if (!list.isEmpty() && list.stream().anyMatch(AudioStreamsHelper::isConnected)) {
Log.d(
TAG,
"Member device has connected broadcast source, device = "
+ device.getDevice().getAnonymizedAddress());
return true;
}
}
return false;
}
/**
* Retrieves a list of connected Bluetooth devices that belongs to one {@link
* CachedBluetoothDevice} that's either connected to a broadcast source or is a connected LE
* audio device.
*/
static List<BluetoothDevice> getConnectedBluetoothDevices(
@Nullable LocalBluetoothManager manager, boolean inSharingOnly) {
if (manager == null) {
Log.w(TAG, "getConnectedBluetoothDevices(): LocalBluetoothManager is null!");
return emptyList();
}
var leBroadcastAssistant = getLeBroadcastAssistant(manager);
if (leBroadcastAssistant == null) {
Log.w(TAG, "getConnectedBluetoothDevices(): LeBroadcastAssistant is null!");
return emptyList();
}
List<BluetoothDevice> connectedDevices =
leBroadcastAssistant.getDevicesMatchingConnectionStates(
new int[] {BluetoothProfile.STATE_CONNECTED});
Optional<CachedBluetoothDevice> cachedBluetoothDevice =
inSharingOnly
? getCachedBluetoothDeviceInSharing(manager)
: getCachedBluetoothDeviceInSharingOrLeConnected(manager);
List<BluetoothDevice> bluetoothDevices =
cachedBluetoothDevice
.map(
c ->
Stream.concat(
Stream.of(c.getDevice()),
c.getMemberDevice().stream()
.map(
CachedBluetoothDevice
::getDevice))
.filter(connectedDevices::contains)
.toList())
.orElse(emptyList());
Log.d(TAG, "getConnectedBluetoothDevices() devices: " + bluetoothDevices);
return bluetoothDevices;
}
private static @Nullable LocalBluetoothLeBroadcastAssistant getLeBroadcastAssistant(
@Nullable LocalBluetoothManager manager) {
if (manager == null) {
Log.w(TAG, "getLeBroadcastAssistant(): LocalBluetoothManager is null!");
return null;
}
LocalBluetoothProfileManager profileManager = manager.getProfileManager();
if (profileManager == null) {
Log.w(TAG, "getLeBroadcastAssistant(): LocalBluetoothProfileManager is null!");
return null;
}
return profileManager.getLeAudioBroadcastAssistantProfile();
}
static String getBroadcastName(BluetoothLeBroadcastMetadata source) {
// TODO(b/331547596): prioritize broadcastName
Optional<String> optionalProgramInfo =
source.getSubgroups().stream()
.map(subgroup -> subgroup.getContentMetadata().getProgramInfo())
.filter(programInfo -> !Strings.isNullOrEmpty(programInfo))
.findFirst();
return optionalProgramInfo.orElseGet(
() -> {
String broadcastName = source.getBroadcastName();
if (broadcastName != null && !broadcastName.isEmpty()) {
return broadcastName;
} else {
return "Broadcast Id: " + source.getBroadcastId();
}
});
}
static String getBroadcastName(BluetoothLeBroadcastReceiveState state) {
return state.getSubgroupMetadata().stream()
.map(BluetoothLeAudioContentMetadata::getProgramInfo)
.filter(i -> !Strings.isNullOrEmpty(i))
.findFirst()
.orElse("Broadcast Id: " + state.getBroadcastId());
}
void startMediaService(Context context, int audioStreamBroadcastId, String title) {
List<BluetoothDevice> devices =
getConnectedBluetoothDevices(mBluetoothManager, /* inSharingOnly= */ true);
if (devices.isEmpty()) {
return;
}
var intent = new Intent(context, AudioStreamMediaService.class);
intent.putExtra(BROADCAST_ID, audioStreamBroadcastId);
intent.putExtra(BROADCAST_TITLE, title);
intent.putParcelableArrayListExtra(DEVICES, new ArrayList<>(devices));
context.startService(intent);
}
}

View File

@@ -0,0 +1,122 @@
/*
* Copyright (C) 2024 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.audiostreams;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothLeBroadcastMetadata;
import android.bluetooth.BluetoothLeBroadcastReceiveState;
import android.util.Log;
import java.util.Locale;
public class AudioStreamsProgressCategoryCallback extends AudioStreamsBroadcastAssistantCallback {
private static final String TAG = "AudioStreamsProgressCategoryCallback";
private final AudioStreamsProgressCategoryController mCategoryController;
public AudioStreamsProgressCategoryCallback(
AudioStreamsProgressCategoryController audioStreamsProgressCategoryController) {
mCategoryController = audioStreamsProgressCategoryController;
}
@Override
public void onReceiveStateChanged(
BluetoothDevice sink, int sourceId, BluetoothLeBroadcastReceiveState state) {
super.onReceiveStateChanged(sink, sourceId, state);
if (AudioStreamsHelper.isConnected(state)) {
mCategoryController.handleSourceConnected(state);
} else if (AudioStreamsHelper.isBadCode(state)) {
mCategoryController.handleSourceConnectBadCode(state);
}
}
@Override
public void onSearchStartFailed(int reason) {
super.onSearchStartFailed(reason);
mCategoryController.showToast(
String.format(Locale.US, "Failed to start scanning, reason %d", reason));
mCategoryController.setScanning(false);
}
@Override
public void onSearchStarted(int reason) {
super.onSearchStarted(reason);
if (mCategoryController == null) {
Log.w(TAG, "onSearchStarted() : mCategoryController is null!");
return;
}
mCategoryController.setScanning(true);
}
@Override
public void onSearchStopFailed(int reason) {
super.onSearchStopFailed(reason);
mCategoryController.showToast(
String.format(Locale.US, "Failed to stop scanning, reason %d", reason));
}
@Override
public void onSearchStopped(int reason) {
super.onSearchStopped(reason);
if (mCategoryController == null) {
Log.w(TAG, "onSearchStopped() : mCategoryController is null!");
return;
}
mCategoryController.setScanning(false);
}
@Override
public void onSourceAddFailed(
BluetoothDevice sink, BluetoothLeBroadcastMetadata source, int reason) {
super.onSourceAddFailed(sink, source, reason);
mCategoryController.handleSourceFailedToConnect(source.getBroadcastId());
}
@Override
public void onSourceFound(BluetoothLeBroadcastMetadata source) {
super.onSourceFound(source);
if (mCategoryController == null) {
Log.w(TAG, "onSourceFound() : mCategoryController is null!");
return;
}
mCategoryController.handleSourceFound(source);
}
@Override
public void onSourceLost(int broadcastId) {
super.onSourceLost(broadcastId);
mCategoryController.handleSourceLost(broadcastId);
}
@Override
public void onSourceRemoveFailed(BluetoothDevice sink, int sourceId, int reason) {
super.onSourceRemoveFailed(sink, sourceId, reason);
mCategoryController.showToast(
String.format(
Locale.US,
"Failed to remove source %d for sink %s",
sourceId,
sink.getAddress()));
}
@Override
public void onSourceRemoved(BluetoothDevice sink, int sourceId, int reason) {
super.onSourceRemoved(sink, sourceId, reason);
mCategoryController.handleSourceRemoved();
}
}

View File

@@ -0,0 +1,575 @@
/*
* Copyright (C) 2023 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.audiostreams;
import static java.util.Collections.emptyList;
import android.app.AlertDialog;
import android.bluetooth.BluetoothLeBroadcastMetadata;
import android.bluetooth.BluetoothLeBroadcastReceiveState;
import android.bluetooth.BluetoothProfile;
import android.content.Context;
import android.content.Intent;
import android.provider.Settings;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.LifecycleOwner;
import androidx.preference.PreferenceScreen;
import com.android.settings.R;
import com.android.settings.bluetooth.Utils;
import com.android.settings.connecteddevice.audiosharing.AudioSharingUtils;
import com.android.settings.core.BasePreferenceController;
import com.android.settingslib.bluetooth.BluetoothCallback;
import com.android.settingslib.bluetooth.BluetoothUtils;
import com.android.settingslib.bluetooth.CachedBluetoothDevice;
import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
import com.android.settingslib.utils.ThreadUtils;
import java.util.Comparator;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import javax.annotation.Nullable;
public class AudioStreamsProgressCategoryController extends BasePreferenceController
implements DefaultLifecycleObserver {
private static final String TAG = "AudioStreamsProgressCategoryController";
private static final boolean DEBUG = BluetoothUtils.D;
private static final int UNSET_BROADCAST_ID = -1;
private final BluetoothCallback mBluetoothCallback =
new BluetoothCallback() {
@Override
public void onActiveDeviceChanged(
@Nullable CachedBluetoothDevice activeDevice, int bluetoothProfile) {
if (bluetoothProfile == BluetoothProfile.LE_AUDIO) {
mExecutor.execute(() -> init());
}
}
};
private final Comparator<AudioStreamPreference> mComparator =
Comparator.<AudioStreamPreference, Boolean>comparing(
p ->
p.getAudioStreamState()
== AudioStreamsProgressCategoryController
.AudioStreamState.SOURCE_ADDED)
.thenComparingInt(AudioStreamPreference::getAudioStreamRssi)
.reversed();
public enum AudioStreamState {
UNKNOWN,
// When mSourceFromQrCode is present and this source has not been synced.
WAIT_FOR_SYNC,
// When source has been synced but not added to any sink.
SYNCED,
// When addSource is called for this source and waiting for response.
ADD_SOURCE_WAIT_FOR_RESPONSE,
// When addSource result in a bad code response.
ADD_SOURCE_BAD_CODE,
// When addSource result in other bad state.
ADD_SOURCE_FAILED,
// Source is added to active sink.
SOURCE_ADDED,
}
private final Executor mExecutor;
private final AudioStreamsProgressCategoryCallback mBroadcastAssistantCallback;
private final AudioStreamsHelper mAudioStreamsHelper;
private final MediaControlHelper mMediaControlHelper;
private final @Nullable LocalBluetoothLeBroadcastAssistant mLeBroadcastAssistant;
private final @Nullable LocalBluetoothManager mBluetoothManager;
private final ConcurrentHashMap<Integer, AudioStreamPreference> mBroadcastIdToPreferenceMap =
new ConcurrentHashMap<>();
private @Nullable BluetoothLeBroadcastMetadata mSourceFromQrCode;
@Nullable private AudioStreamsProgressCategoryPreference mCategoryPreference;
@Nullable private AudioStreamsDashboardFragment mFragment;
public AudioStreamsProgressCategoryController(Context context, String preferenceKey) {
super(context, preferenceKey);
mExecutor = Executors.newSingleThreadExecutor();
mBluetoothManager = Utils.getLocalBtManager(mContext);
mAudioStreamsHelper = new AudioStreamsHelper(mBluetoothManager);
mMediaControlHelper = new MediaControlHelper(mContext, mBluetoothManager);
mLeBroadcastAssistant = mAudioStreamsHelper.getLeBroadcastAssistant();
mBroadcastAssistantCallback = new AudioStreamsProgressCategoryCallback(this);
}
@Override
public int getAvailabilityStatus() {
return AVAILABLE;
}
@Override
public void displayPreference(PreferenceScreen screen) {
super.displayPreference(screen);
mCategoryPreference = screen.findPreference(getPreferenceKey());
}
@Override
public void onStart(@NonNull LifecycleOwner owner) {
if (mBluetoothManager != null) {
mBluetoothManager.getEventManager().registerCallback(mBluetoothCallback);
}
mExecutor.execute(this::init);
}
@Override
public void onStop(@NonNull LifecycleOwner owner) {
if (mBluetoothManager != null) {
mBluetoothManager.getEventManager().unregisterCallback(mBluetoothCallback);
}
mExecutor.execute(this::stopScanning);
}
void setFragment(AudioStreamsDashboardFragment fragment) {
mFragment = fragment;
}
@Nullable
AudioStreamsDashboardFragment getFragment() {
return mFragment;
}
void setSourceFromQrCode(BluetoothLeBroadcastMetadata source) {
if (DEBUG) {
Log.d(TAG, "setSourceFromQrCode(): broadcastId " + source.getBroadcastId());
}
mSourceFromQrCode = source;
}
void setScanning(boolean isScanning) {
ThreadUtils.postOnMainThread(
() -> {
if (mCategoryPreference != null) mCategoryPreference.setProgress(isScanning);
});
}
// Find preference by scanned source and decide next state.
// Expect one of the following:
// 1) No preference existed, create new preference with state SYNCED
// 2) WAIT_FOR_SYNC, move to ADD_SOURCE_WAIT_FOR_RESPONSE
// 3) SOURCE_ADDED, leave as-is
void handleSourceFound(BluetoothLeBroadcastMetadata source) {
if (DEBUG) {
Log.d(TAG, "handleSourceFound()");
}
var broadcastIdFound = source.getBroadcastId();
if (mSourceFromQrCode != null && mSourceFromQrCode.getBroadcastId() == UNSET_BROADCAST_ID) {
// mSourceFromQrCode could have no broadcast Id, we fill in the broadcast Id from the
// scanned metadata.
if (DEBUG) {
Log.d(
TAG,
"handleSourceFound() : processing mSourceFromQrCode with broadcastId"
+ " unset");
}
boolean updated =
maybeUpdateId(
AudioStreamsHelper.getBroadcastName(source), source.getBroadcastId());
if (updated && mBroadcastIdToPreferenceMap.containsKey(UNSET_BROADCAST_ID)) {
var preference = mBroadcastIdToPreferenceMap.remove(UNSET_BROADCAST_ID);
mBroadcastIdToPreferenceMap.put(source.getBroadcastId(), preference);
}
}
mBroadcastIdToPreferenceMap.compute(
broadcastIdFound,
(k, existingPreference) -> {
if (existingPreference == null) {
return addNewPreference(source, AudioStreamState.SYNCED);
}
var fromState = existingPreference.getAudioStreamState();
if (fromState == AudioStreamState.WAIT_FOR_SYNC && mSourceFromQrCode != null) {
// A preference with source founded is existed from a QR code scan. As the
// source is now synced, we update the preference with source from scanning
// as it includes complete broadcast info.
existingPreference.setAudioStreamMetadata(
new BluetoothLeBroadcastMetadata.Builder(source)
.setBroadcastCode(mSourceFromQrCode.getBroadcastCode())
.build());
moveToState(
existingPreference, AudioStreamState.ADD_SOURCE_WAIT_FOR_RESPONSE);
} else {
// A preference with source founded existed either because it's already
// connected (SOURCE_ADDED). Any other reason is unexpected. We update the
// preference with this source and won't change it's state.
existingPreference.setAudioStreamMetadata(source);
if (fromState != AudioStreamState.SOURCE_ADDED) {
Log.w(
TAG,
"handleSourceFound(): unexpected state : "
+ fromState
+ " for broadcastId : "
+ broadcastIdFound);
}
}
return existingPreference;
});
}
private boolean maybeUpdateId(String targetBroadcastName, int broadcastIdToSet) {
if (mSourceFromQrCode == null) {
return false;
}
if (targetBroadcastName.equals(AudioStreamsHelper.getBroadcastName(mSourceFromQrCode))) {
if (DEBUG) {
Log.d(
TAG,
"maybeUpdateId() : updating unset broadcastId for metadataFromQrCode with"
+ " broadcastName: "
+ AudioStreamsHelper.getBroadcastName(mSourceFromQrCode)
+ " to broadcast Id: "
+ broadcastIdToSet);
}
mSourceFromQrCode =
new BluetoothLeBroadcastMetadata.Builder(mSourceFromQrCode)
.setBroadcastId(broadcastIdToSet)
.build();
return true;
}
return false;
}
// Find preference by mSourceFromQrCode and decide next state.
// Expect no preference existed, create new preference with state WAIT_FOR_SYNC
private void handleSourceFromQrCodeIfExists() {
if (DEBUG) {
Log.d(TAG, "handleSourceFromQrCodeIfExists()");
}
if (mSourceFromQrCode == null) {
return;
}
mBroadcastIdToPreferenceMap.compute(
mSourceFromQrCode.getBroadcastId(),
(k, existingPreference) -> {
if (existingPreference == null) {
// No existing preference for this source from the QR code scan, add one and
// set initial state to WAIT_FOR_SYNC.
// Check nullability to bypass NullAway check.
if (mSourceFromQrCode != null) {
return addNewPreference(
mSourceFromQrCode, AudioStreamState.WAIT_FOR_SYNC);
}
}
Log.w(
TAG,
"handleSourceFromQrCodeIfExists(): unexpected state : "
+ existingPreference.getAudioStreamState()
+ " for broadcastId : "
+ (mSourceFromQrCode == null
? "null"
: mSourceFromQrCode.getBroadcastId()));
return existingPreference;
});
}
void handleSourceLost(int broadcastId) {
if (DEBUG) {
Log.d(TAG, "handleSourceLost()");
}
if (mAudioStreamsHelper.getAllConnectedSources().stream()
.anyMatch(connected -> connected.getBroadcastId() == broadcastId)) {
Log.d(
TAG,
"handleSourceLost() : keep this preference as the source is still connected.");
return;
}
var toRemove = mBroadcastIdToPreferenceMap.remove(broadcastId);
if (toRemove != null) {
ThreadUtils.postOnMainThread(
() -> {
if (mCategoryPreference != null) {
mCategoryPreference.removePreference(toRemove);
}
});
}
}
void handleSourceRemoved() {
if (DEBUG) {
Log.d(TAG, "handleSourceRemoved()");
}
for (var entry : mBroadcastIdToPreferenceMap.entrySet()) {
var preference = entry.getValue();
// Look for preference has SOURCE_ADDED state, re-check if they are still connected. If
// not, means the source is removed from the sink, we move back the preference to SYNCED
// state.
if (preference.getAudioStreamState() == AudioStreamState.SOURCE_ADDED
&& mAudioStreamsHelper.getAllConnectedSources().stream()
.noneMatch(
connected ->
connected.getBroadcastId()
== preference.getAudioStreamBroadcastId())) {
ThreadUtils.postOnMainThread(
() -> {
var metadata = preference.getAudioStreamMetadata();
if (metadata != null) {
moveToState(preference, AudioStreamState.SYNCED);
} else {
handleSourceLost(preference.getAudioStreamBroadcastId());
}
});
return;
}
}
}
// Find preference by receiveState and decide next state.
// Expect one of the following:
// 1) No preference existed, create new preference with state SOURCE_ADDED
// 2) Any other state, move to SOURCE_ADDED
void handleSourceConnected(BluetoothLeBroadcastReceiveState receiveState) {
if (DEBUG) {
Log.d(TAG, "handleSourceConnected()");
}
if (!AudioStreamsHelper.isConnected(receiveState)) {
return;
}
var broadcastIdConnected = receiveState.getBroadcastId();
if (mSourceFromQrCode != null && mSourceFromQrCode.getBroadcastId() == UNSET_BROADCAST_ID) {
// mSourceFromQrCode could have no broadcast Id, we fill in the broadcast Id from the
// connected source receiveState.
if (DEBUG) {
Log.d(
TAG,
"handleSourceConnected() : processing mSourceFromQrCode with broadcastId"
+ " unset");
}
boolean updated =
maybeUpdateId(
AudioStreamsHelper.getBroadcastName(receiveState),
receiveState.getBroadcastId());
if (updated && mBroadcastIdToPreferenceMap.containsKey(UNSET_BROADCAST_ID)) {
var preference = mBroadcastIdToPreferenceMap.remove(UNSET_BROADCAST_ID);
mBroadcastIdToPreferenceMap.put(receiveState.getBroadcastId(), preference);
}
}
mBroadcastIdToPreferenceMap.compute(
broadcastIdConnected,
(k, existingPreference) -> {
if (existingPreference == null) {
// No existing preference for this source even if it's already connected,
// add one and set initial state to SOURCE_ADDED. This could happen because
// we retrieves the connected source during onStart() from
// AudioStreamsHelper#getAllConnectedSources() even before the source is
// founded by scanning.
return addNewPreference(receiveState, AudioStreamState.SOURCE_ADDED);
}
if (existingPreference.getAudioStreamState() == AudioStreamState.WAIT_FOR_SYNC
&& existingPreference.getAudioStreamBroadcastId() == UNSET_BROADCAST_ID
&& mSourceFromQrCode != null) {
existingPreference.setAudioStreamMetadata(mSourceFromQrCode);
}
moveToState(existingPreference, AudioStreamState.SOURCE_ADDED);
return existingPreference;
});
}
// Find preference by receiveState and decide next state.
// Expect one preference existed, move to ADD_SOURCE_BAD_CODE
void handleSourceConnectBadCode(BluetoothLeBroadcastReceiveState receiveState) {
if (DEBUG) {
Log.d(TAG, "handleSourceConnectBadCode()");
}
if (!AudioStreamsHelper.isBadCode(receiveState)) {
return;
}
mBroadcastIdToPreferenceMap.computeIfPresent(
receiveState.getBroadcastId(),
(k, existingPreference) -> {
moveToState(existingPreference, AudioStreamState.ADD_SOURCE_BAD_CODE);
return existingPreference;
});
}
// Find preference by broadcastId and decide next state.
// Expect one preference existed, move to ADD_SOURCE_FAILED
void handleSourceFailedToConnect(int broadcastId) {
if (DEBUG) {
Log.d(TAG, "handleSourceFailedToConnect()");
}
mBroadcastIdToPreferenceMap.computeIfPresent(
broadcastId,
(k, existingPreference) -> {
moveToState(existingPreference, AudioStreamState.ADD_SOURCE_FAILED);
return existingPreference;
});
}
// Find preference by metadata and decide next state.
// Expect one preference existed, move to ADD_SOURCE_WAIT_FOR_RESPONSE
void handleSourceAddRequest(
AudioStreamPreference preference, BluetoothLeBroadcastMetadata metadata) {
if (DEBUG) {
Log.d(TAG, "handleSourceAddRequest()");
}
mBroadcastIdToPreferenceMap.computeIfPresent(
metadata.getBroadcastId(),
(k, existingPreference) -> {
if (!existingPreference.equals(preference)) {
Log.w(TAG, "handleSourceAddedRequest(): existing preference not match");
}
existingPreference.setAudioStreamMetadata(metadata);
moveToState(existingPreference, AudioStreamState.ADD_SOURCE_WAIT_FOR_RESPONSE);
return existingPreference;
});
}
void showToast(String msg) {
AudioSharingUtils.toastMessage(mContext, msg);
}
private void init() {
mBroadcastIdToPreferenceMap.clear();
boolean hasConnected =
AudioStreamsHelper.getCachedBluetoothDeviceInSharingOrLeConnected(mBluetoothManager)
.isPresent();
AudioSharingUtils.postOnMainThread(
mContext,
() -> {
if (mCategoryPreference != null) {
mCategoryPreference.removeAudioStreamPreferences();
mCategoryPreference.setVisible(hasConnected);
}
});
if (hasConnected) {
startScanning();
AudioSharingUtils.postOnMainThread(
mContext,
() -> {
if (mFragment != null) {
AudioStreamsDialogFragment.dismissAll(mFragment);
}
});
} else {
stopScanning();
AudioSharingUtils.postOnMainThread(
mContext,
() -> {
if (mFragment != null) {
AudioStreamsDialogFragment.show(mFragment, getNoLeDeviceDialog());
}
});
}
}
private void startScanning() {
if (mLeBroadcastAssistant == null) {
Log.w(TAG, "startScanning(): LeBroadcastAssistant is null!");
return;
}
if (mLeBroadcastAssistant.isSearchInProgress()) {
Log.w(TAG, "startScanning(): scanning still in progress, stop scanning first.");
stopScanning();
}
mLeBroadcastAssistant.registerServiceCallBack(mExecutor, mBroadcastAssistantCallback);
mExecutor.execute(
() -> {
// Handle QR code scan, display currently connected streams then start scanning
// sequentially
handleSourceFromQrCodeIfExists();
mAudioStreamsHelper
.getAllConnectedSources()
.forEach(this::handleSourceConnected);
mLeBroadcastAssistant.startSearchingForSources(emptyList());
mMediaControlHelper.start();
});
}
private void stopScanning() {
if (mLeBroadcastAssistant == null) {
Log.w(TAG, "stopScanning(): LeBroadcastAssistant is null!");
return;
}
if (mLeBroadcastAssistant.isSearchInProgress()) {
if (DEBUG) {
Log.d(TAG, "stopScanning()");
}
mLeBroadcastAssistant.stopSearchingForSources();
mLeBroadcastAssistant.unregisterServiceCallBack(mBroadcastAssistantCallback);
}
mMediaControlHelper.stop();
mSourceFromQrCode = null;
}
private AudioStreamPreference addNewPreference(
BluetoothLeBroadcastReceiveState receiveState, AudioStreamState state) {
var preference = AudioStreamPreference.fromReceiveState(mContext, receiveState);
moveToState(preference, state);
return preference;
}
private AudioStreamPreference addNewPreference(
BluetoothLeBroadcastMetadata metadata, AudioStreamState state) {
var preference = AudioStreamPreference.fromMetadata(mContext, metadata);
moveToState(preference, state);
return preference;
}
private void moveToState(AudioStreamPreference preference, AudioStreamState state) {
AudioStreamStateHandler stateHandler = switch (state) {
case SYNCED -> SyncedState.getInstance();
case WAIT_FOR_SYNC -> WaitForSyncState.getInstance();
case ADD_SOURCE_WAIT_FOR_RESPONSE ->
AddSourceWaitForResponseState.getInstance();
case ADD_SOURCE_BAD_CODE -> AddSourceBadCodeState.getInstance();
case ADD_SOURCE_FAILED -> AddSourceFailedState.getInstance();
case SOURCE_ADDED -> SourceAddedState.getInstance();
default -> throw new IllegalArgumentException("Unsupported state: " + state);
};
stateHandler.handleStateChange(preference, this, mAudioStreamsHelper);
// Update UI with the updated preference
AudioSharingUtils.postOnMainThread(
mContext,
() -> {
if (mCategoryPreference != null) {
mCategoryPreference.addAudioStreamPreference(preference, mComparator);
}
});
}
private AudioStreamsDialogFragment.DialogBuilder getNoLeDeviceDialog() {
return new AudioStreamsDialogFragment.DialogBuilder(mContext)
.setTitle(mContext.getString(R.string.audio_streams_dialog_no_le_device_title))
.setSubTitle2(
mContext.getString(R.string.audio_streams_dialog_no_le_device_subtitle))
.setLeftButtonText(mContext.getString(R.string.audio_streams_dialog_close))
.setLeftButtonOnClickListener(AlertDialog::dismiss)
.setRightButtonText(
mContext.getString(R.string.audio_streams_dialog_no_le_device_button))
.setRightButtonOnClickListener(
dialog -> {
mContext.startActivity(
new Intent(Settings.ACTION_BLUETOOTH_SETTINGS)
.setPackage(mContext.getPackageName()));
dialog.dismiss();
});
}
}

View File

@@ -0,0 +1,89 @@
/*
* Copyright (C) 2023 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.audiostreams;
import android.content.Context;
import android.util.AttributeSet;
import androidx.annotation.NonNull;
import com.android.settings.ProgressCategory;
import com.android.settings.R;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
public class AudioStreamsProgressCategoryPreference extends ProgressCategory {
public AudioStreamsProgressCategoryPreference(Context context) {
super(context);
init();
}
public AudioStreamsProgressCategoryPreference(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public AudioStreamsProgressCategoryPreference(
Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
public AudioStreamsProgressCategoryPreference(
Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
init();
}
void addAudioStreamPreference(
@NonNull AudioStreamPreference preference,
Comparator<AudioStreamPreference> comparator) {
super.addPreference(preference);
List<AudioStreamPreference> preferences = getAllAudioStreamPreferences();
preferences.sort(comparator);
for (int i = 0; i < preferences.size(); i++) {
// setOrder to i + 1, since the order 0 preference should always be the
// "audio_streams_scan_qr_code"
preferences.get(i).setOrder(i + 1);
}
}
void removeAudioStreamPreferences() {
List<AudioStreamPreference> streams = getAllAudioStreamPreferences();
for (var toRemove : streams) {
removePreference(toRemove);
}
}
private List<AudioStreamPreference> getAllAudioStreamPreferences() {
List<AudioStreamPreference> streams = new ArrayList<>();
for (int i = 0; i < getPreferenceCount(); i++) {
if (getPreference(i) instanceof AudioStreamPreference) {
streams.add((AudioStreamPreference) getPreference(i));
}
}
return streams;
}
private void init() {
setEmptyTextRes(R.string.audio_streams_empty);
}
}

View File

@@ -0,0 +1,134 @@
/*
* Copyright (C) 2023 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.audiostreams;
import android.bluetooth.BluetoothLeBroadcastMetadata;
import android.graphics.Bitmap;
import android.os.Bundle;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.Nullable;
import com.android.settings.R;
import com.android.settings.bluetooth.Utils;
import com.android.settings.core.InstrumentedFragment;
import com.android.settingslib.bluetooth.BluetoothLeBroadcastMetadataExt;
import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast;
import com.android.settingslib.qrcode.QrCodeGenerator;
import com.google.zxing.WriterException;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
public class AudioStreamsQrCodeFragment extends InstrumentedFragment {
private static final String TAG = "AudioStreamsQrCodeFragment";
@Override
public int getMetricsCategory() {
// TODO(chelseahao): update metrics id
return 0;
}
@Override
public final View onCreateView(
LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
View view = inflater.inflate(R.xml.bluetooth_audio_streams_qr_code, container, false);
BluetoothLeBroadcastMetadata broadcastMetadata = getBroadcastMetadata();
if (broadcastMetadata != null) {
Optional<Bitmap> bm = getQrCodeBitmap(broadcastMetadata);
if (bm.isEmpty()) {
return view;
}
((ImageView) view.requireViewById(R.id.qrcode_view)).setImageBitmap(bm.get());
if (broadcastMetadata.getBroadcastCode() != null) {
String password =
new String(broadcastMetadata.getBroadcastCode(), StandardCharsets.UTF_8);
String passwordText =
getContext()
.getString(R.string.audio_streams_qr_code_page_password, password);
((TextView) view.requireViewById(R.id.password)).setText(passwordText);
}
TextView summaryView = view.requireViewById(android.R.id.summary);
String summary =
view.getContext()
.getString(
R.string.audio_streams_qr_code_page_description,
broadcastMetadata.getBroadcastName());
summaryView.setText(summary);
}
return view;
}
private Optional<Bitmap> getQrCodeBitmap(@Nullable BluetoothLeBroadcastMetadata metadata) {
if (metadata == null) {
Log.d(TAG, "onCreateView: broadcastMetadata is empty!");
return Optional.empty();
}
String metadataStr = BluetoothLeBroadcastMetadataExt.INSTANCE.toQrCodeString(metadata);
if (metadataStr.isEmpty()) {
Log.d(TAG, "onCreateView: metadataStr is empty!");
return Optional.empty();
}
Log.i(TAG, "onCreateView: metadataStr : " + metadataStr);
try {
int qrcodeSize =
getContext()
.getResources()
.getDimensionPixelSize(R.dimen.audio_streams_qrcode_size);
Bitmap bitmap = QrCodeGenerator.encodeQrCode(metadataStr, qrcodeSize);
return Optional.of(bitmap);
} catch (WriterException e) {
Log.d(
TAG,
"onCreateView: broadcastMetadata "
+ metadata
+ " qrCode generation exception "
+ e);
}
return Optional.empty();
}
@Nullable
private BluetoothLeBroadcastMetadata getBroadcastMetadata() {
LocalBluetoothLeBroadcast localBluetoothLeBroadcast =
Utils.getLocalBtManager(getActivity())
.getProfileManager()
.getLeAudioBroadcastProfile();
if (localBluetoothLeBroadcast == null) {
Log.d(TAG, "getBroadcastMetadataQrCode: localBluetoothLeBroadcast is null!");
return null;
}
BluetoothLeBroadcastMetadata metadata =
localBluetoothLeBroadcast.getLatestBluetoothLeBroadcastMetadata();
if (metadata == null) {
Log.d(TAG, "getBroadcastMetadataQrCode: metadata is null!");
return null;
}
return metadata;
}
}

View File

@@ -0,0 +1,159 @@
/*
* Copyright (C) 2024 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.audiostreams;
import android.bluetooth.BluetoothLeBroadcastMetadata;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import com.android.settingslib.bluetooth.BluetoothLeBroadcastMetadataExt;
import com.android.settingslib.bluetooth.BluetoothUtils;
import com.android.settingslib.utils.ThreadUtils;
import java.util.concurrent.ConcurrentHashMap;
import javax.annotation.Nullable;
/** Manages the caching and storage of Bluetooth audio stream metadata. */
public class AudioStreamsRepository {
private static final String TAG = "AudioStreamsRepository";
private static final boolean DEBUG = BluetoothUtils.D;
private static final String PREF_KEY = "bluetooth_audio_stream_pref";
private static final String METADATA_KEY = "bluetooth_audio_stream_metadata";
@Nullable private static AudioStreamsRepository sInstance = null;
private AudioStreamsRepository() {}
/**
* Gets the single instance of AudioStreamsRepository.
*
* @return The AudioStreamsRepository instance.
*/
public static synchronized AudioStreamsRepository getInstance() {
if (sInstance == null) {
sInstance = new AudioStreamsRepository();
}
return sInstance;
}
private final ConcurrentHashMap<Integer, BluetoothLeBroadcastMetadata>
mBroadcastIdToMetadataCacheMap = new ConcurrentHashMap<>();
/**
* Caches BluetoothLeBroadcastMetadata in a local cache.
*
* @param metadata The BluetoothLeBroadcastMetadata to be cached.
*/
void cacheMetadata(BluetoothLeBroadcastMetadata metadata) {
if (DEBUG) {
Log.d(
TAG,
"cacheMetadata(): broadcastId "
+ metadata.getBroadcastId()
+ " saved in local cache.");
}
mBroadcastIdToMetadataCacheMap.put(metadata.getBroadcastId(), metadata);
}
/**
* Gets cached BluetoothLeBroadcastMetadata by broadcastId.
*
* @param broadcastId The broadcastId to look up in the cache.
* @return The cached BluetoothLeBroadcastMetadata or null if not found.
*/
@Nullable
BluetoothLeBroadcastMetadata getCachedMetadata(int broadcastId) {
var metadata = mBroadcastIdToMetadataCacheMap.get(broadcastId);
if (metadata == null) {
Log.w(
TAG,
"getCachedMetadata(): broadcastId not found in"
+ " mBroadcastIdToMetadataCacheMap.");
return null;
}
return metadata;
}
/**
* Saves metadata to SharedPreferences asynchronously.
*
* @param context The context.
* @param metadata The BluetoothLeBroadcastMetadata to be saved.
*/
void saveMetadata(Context context, BluetoothLeBroadcastMetadata metadata) {
var unused =
ThreadUtils.postOnBackgroundThread(
() -> {
SharedPreferences sharedPref =
context.getSharedPreferences(PREF_KEY, Context.MODE_PRIVATE);
if (sharedPref != null) {
SharedPreferences.Editor editor = sharedPref.edit();
editor.putString(
METADATA_KEY,
BluetoothLeBroadcastMetadataExt.INSTANCE.toQrCodeString(
metadata));
editor.apply();
if (DEBUG) {
Log.d(
TAG,
"saveMetadata(): broadcastId "
+ metadata.getBroadcastId()
+ " metadata saved in storage.");
}
}
});
}
/**
* Gets saved metadata from SharedPreferences.
*
* @param context The context.
* @param broadcastId The broadcastId to retrieve metadata for.
* @return The saved BluetoothLeBroadcastMetadata or null if not found.
*/
@Nullable
BluetoothLeBroadcastMetadata getSavedMetadata(Context context, int broadcastId) {
SharedPreferences sharedPref = context.getSharedPreferences(PREF_KEY, Context.MODE_PRIVATE);
if (sharedPref != null) {
String savedMetadataStr = sharedPref.getString(METADATA_KEY, null);
if (savedMetadataStr == null) {
Log.w(TAG, "getSavedMetadata(): savedMetadataStr is null");
return null;
}
var savedMetadata =
BluetoothLeBroadcastMetadataExt.INSTANCE.convertToBroadcastMetadata(
savedMetadataStr);
if (savedMetadata == null || savedMetadata.getBroadcastId() != broadcastId) {
Log.w(TAG, "getSavedMetadata(): savedMetadata doesn't match broadcast Id.");
return null;
}
if (DEBUG) {
Log.d(
TAG,
"getSavedMetadata(): broadcastId "
+ savedMetadata.getBroadcastId()
+ " metadata found in storage.");
}
return savedMetadata;
}
return null;
}
}

View File

@@ -0,0 +1,140 @@
/*
* Copyright (C) 2023 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.audiostreams;
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.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.LifecycleOwner;
import androidx.preference.Preference;
import androidx.preference.PreferenceScreen;
import com.android.settings.bluetooth.Utils;
import com.android.settings.connecteddevice.audiosharing.audiostreams.qrcode.QrCodeScanModeActivity;
import com.android.settings.core.BasePreferenceController;
import com.android.settingslib.bluetooth.BluetoothBroadcastUtils;
import com.android.settingslib.bluetooth.BluetoothCallback;
import com.android.settingslib.bluetooth.BluetoothUtils;
import com.android.settingslib.bluetooth.CachedBluetoothDevice;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
import com.android.settingslib.utils.ThreadUtils;
public class AudioStreamsScanQrCodeController extends BasePreferenceController
implements DefaultLifecycleObserver {
static final int REQUEST_SCAN_BT_BROADCAST_QR_CODE = 0;
private static final String TAG = "AudioStreamsProgressCategoryController";
private static final boolean DEBUG = BluetoothUtils.D;
private static final String KEY = "audio_streams_scan_qr_code";
private final BluetoothCallback mBluetoothCallback =
new BluetoothCallback() {
@Override
public void onActiveDeviceChanged(
@Nullable CachedBluetoothDevice activeDevice, int bluetoothProfile) {
if (bluetoothProfile == BluetoothProfile.LE_AUDIO) {
updateVisibility();
}
}
};
@Nullable private final LocalBluetoothManager mLocalBtManager;
@Nullable private AudioStreamsDashboardFragment mFragment;
@Nullable private Preference mPreference;
public AudioStreamsScanQrCodeController(Context context, String preferenceKey) {
super(context, preferenceKey);
mLocalBtManager = Utils.getLocalBtManager(mContext);
}
public void setFragment(AudioStreamsDashboardFragment fragment) {
mFragment = fragment;
}
@Override
public void onStart(@NonNull LifecycleOwner owner) {
if (mLocalBtManager != null) {
mLocalBtManager.getEventManager().registerCallback(mBluetoothCallback);
}
}
@Override
public void onStop(@NonNull LifecycleOwner owner) {
if (mLocalBtManager != null) {
mLocalBtManager.getEventManager().unregisterCallback(mBluetoothCallback);
}
}
@Override
public int getAvailabilityStatus() {
return AVAILABLE;
}
@Override
public String getPreferenceKey() {
return KEY;
}
@Override
public void displayPreference(PreferenceScreen screen) {
super.displayPreference(screen);
mPreference = screen.findPreference(getPreferenceKey());
if (mPreference == null) {
Log.w(TAG, "displayPreference() mPreference is null!");
return;
}
mPreference.setOnPreferenceClickListener(
preference -> {
if (mFragment == null) {
Log.w(TAG, "displayPreference() mFragment is null!");
return false;
}
if (preference.getKey().equals(KEY)) {
Intent intent = new Intent(mContext, QrCodeScanModeActivity.class);
intent.setAction(
BluetoothBroadcastUtils.ACTION_BLUETOOTH_LE_AUDIO_QR_CODE_SCANNER);
mFragment.startActivityForResult(intent, REQUEST_SCAN_BT_BROADCAST_QR_CODE);
if (DEBUG) {
Log.w(TAG, "displayPreference() sent intent : " + intent);
}
return true;
}
return false;
});
}
private void updateVisibility() {
var unused =
ThreadUtils.postOnBackgroundThread(
() -> {
boolean hasConnectedLe =
AudioStreamsHelper
.getCachedBluetoothDeviceInSharingOrLeConnected(
mLocalBtManager)
.isPresent();
ThreadUtils.postOnMainThread(
() -> {
if (mPreference != null) {
mPreference.setVisible(hasConnectedLe);
}
});
});
}
}

View File

@@ -0,0 +1,133 @@
/*
* Copyright (C) 2024 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.audiostreams;
import android.content.Context;
import android.media.session.MediaController;
import android.media.session.MediaSessionManager;
import android.media.session.PlaybackState;
import android.util.Log;
import android.util.Pair;
import androidx.annotation.Nullable;
import com.android.settingslib.bluetooth.CachedBluetoothDevice;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
import com.android.settingslib.media.BluetoothMediaDevice;
import com.android.settingslib.media.LocalMediaManager;
import com.android.settingslib.media.MediaDevice;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
class MediaControlHelper {
private static final String TAG = "MediaControlHelper";
private final Context mContext;
private final MediaSessionManager mMediaSessionManager;
@Nullable private final LocalBluetoothManager mLocalBluetoothManager;
private final List<Pair<LocalMediaManager, LocalMediaManager.DeviceCallback>>
mLocalMediaManagers = new ArrayList<>();
MediaControlHelper(Context context, @Nullable LocalBluetoothManager localBluetoothManager) {
mContext = context;
mMediaSessionManager = context.getSystemService(MediaSessionManager.class);
mLocalBluetoothManager = localBluetoothManager;
}
void start() {
if (mLocalBluetoothManager == null) {
return;
}
var currentLeDevice =
AudioStreamsHelper.getCachedBluetoothDeviceInSharingOrLeConnected(
mLocalBluetoothManager);
if (currentLeDevice.isEmpty()) {
Log.d(TAG, "start() : current LE device is empty!");
return;
}
for (MediaController controller : mMediaSessionManager.getActiveSessions(null)) {
String packageName = controller.getPackageName();
// We won't stop media created from settings.
if (Objects.equals(packageName, mContext.getPackageName())) {
Log.d(TAG, "start() : skip package: " + packageName);
continue;
}
// Start scanning and listen to device list update, stop this media if device matched.
var localMediaManager = new LocalMediaManager(mContext, packageName);
var deviceCallback =
new LocalMediaManager.DeviceCallback() {
public void onDeviceListUpdate(List<MediaDevice> devices) {
if (shouldStopMedia(
controller,
currentLeDevice.get(),
localMediaManager.getCurrentConnectedDevice())) {
Log.d(
TAG,
"start() : Stopping media player for package: "
+ controller.getPackageName());
var controls = controller.getTransportControls();
if (controls != null) {
controls.stop();
}
}
}
};
localMediaManager.registerCallback(deviceCallback);
localMediaManager.startScan();
mLocalMediaManagers.add(new Pair<>(localMediaManager, deviceCallback));
}
}
void stop() {
mLocalMediaManagers.forEach(
m -> {
m.first.stopScan();
m.first.unregisterCallback(m.second);
});
mLocalMediaManagers.clear();
}
private static boolean shouldStopMedia(
MediaController controller,
CachedBluetoothDevice currentLeDevice,
MediaDevice currentMediaDevice) {
// We won't stop media if it's already stopped.
if (controller.getPlaybackState() != null
&& controller.getPlaybackState().getState() == PlaybackState.STATE_STOPPED) {
Log.d(TAG, "shouldStopMedia() : skip already stopped: " + controller.getPackageName());
return false;
}
var deviceForMedia =
currentMediaDevice instanceof BluetoothMediaDevice
? (BluetoothMediaDevice) currentMediaDevice
: null;
return deviceForMedia != null
&& hasOverlap(deviceForMedia.getCachedDevice(), currentLeDevice);
}
private static boolean hasOverlap(
CachedBluetoothDevice device1, CachedBluetoothDevice device2) {
return device1.equals(device2)
|| device1.getMemberDevice().contains(device2)
|| device2.getMemberDevice().contains(device1);
}
}

View File

@@ -0,0 +1,94 @@
/*
* Copyright (C) 2024 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.audiostreams;
import android.app.settings.SettingsEnums;
import android.os.Bundle;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.preference.Preference;
import com.android.settings.R;
import com.android.settings.core.SubSettingLauncher;
class SourceAddedState extends AudioStreamStateHandler {
@VisibleForTesting
static final int AUDIO_STREAM_SOURCE_ADDED_STATE_SUMMARY = R.string.audio_streams_listening_now;
@Nullable private static SourceAddedState sInstance = null;
private SourceAddedState() {}
static SourceAddedState getInstance() {
if (sInstance == null) {
sInstance = new SourceAddedState();
}
return sInstance;
}
@Override
void performAction(
AudioStreamPreference preference,
AudioStreamsProgressCategoryController controller,
AudioStreamsHelper helper) {
var context = preference.getContext();
// Saved connected metadata for user to re-join this broadcast later.
var cached =
mAudioStreamsRepository.getCachedMetadata(preference.getAudioStreamBroadcastId());
if (cached != null) {
mAudioStreamsRepository.saveMetadata(context, cached);
}
helper.startMediaService(
context,
preference.getAudioStreamBroadcastId(),
String.valueOf(preference.getTitle()));
}
@Override
int getSummary() {
return AUDIO_STREAM_SOURCE_ADDED_STATE_SUMMARY;
}
@Override
Preference.OnPreferenceClickListener getOnClickListener(
AudioStreamsProgressCategoryController controller) {
return preference -> {
var p = (AudioStreamPreference) preference;
Bundle broadcast = new Bundle();
broadcast.putString(
AudioStreamDetailsFragment.BROADCAST_NAME_ARG, (String) p.getTitle());
broadcast.putInt(
AudioStreamDetailsFragment.BROADCAST_ID_ARG, p.getAudioStreamBroadcastId());
new SubSettingLauncher(p.getContext())
.setTitleText(
p.getContext().getString(R.string.audio_streams_detail_page_title))
.setDestination(AudioStreamDetailsFragment.class.getName())
// TODO(chelseahao): Add logging enum
.setSourceMetricsCategory(SettingsEnums.PAGE_UNKNOWN)
.setArguments(broadcast)
.launch();
return true;
};
}
@Override
AudioStreamsProgressCategoryController.AudioStreamState getStateEnum() {
return AudioStreamsProgressCategoryController.AudioStreamState.SOURCE_ADDED;
}
}

View File

@@ -0,0 +1,114 @@
/*
* Copyright (C) 2024 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.audiostreams;
import android.app.AlertDialog;
import android.bluetooth.BluetoothLeBroadcastMetadata;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.EditText;
import android.widget.TextView;
import androidx.annotation.Nullable;
import androidx.preference.Preference;
import com.android.settings.R;
import com.android.settingslib.bluetooth.BluetoothUtils;
import com.android.settingslib.utils.ThreadUtils;
import java.nio.charset.StandardCharsets;
class SyncedState extends AudioStreamStateHandler {
private static final String TAG = "SyncedState";
private static final boolean DEBUG = BluetoothUtils.D;
@Nullable private static SyncedState sInstance = null;
SyncedState() {}
static SyncedState getInstance() {
if (sInstance == null) {
sInstance = new SyncedState();
}
return sInstance;
}
@Override
Preference.OnPreferenceClickListener getOnClickListener(
AudioStreamsProgressCategoryController controller) {
return p -> addSourceOrShowDialog(p, controller);
}
@Override
AudioStreamsProgressCategoryController.AudioStreamState getStateEnum() {
return AudioStreamsProgressCategoryController.AudioStreamState.SYNCED;
}
private boolean addSourceOrShowDialog(
Preference preference, AudioStreamsProgressCategoryController controller) {
var p = (AudioStreamPreference) preference;
if (DEBUG) {
Log.d(
TAG,
"preferenceClicked(): attempt to join broadcast id : "
+ p.getAudioStreamBroadcastId());
}
var source = p.getAudioStreamMetadata();
if (source != null) {
if (source.isEncrypted()) {
ThreadUtils.postOnMainThread(() -> launchPasswordDialog(source, p, controller));
} else {
controller.handleSourceAddRequest(p, source);
}
}
return true;
}
private void launchPasswordDialog(
BluetoothLeBroadcastMetadata source,
AudioStreamPreference preference,
AudioStreamsProgressCategoryController controller) {
View layout =
LayoutInflater.from(preference.getContext())
.inflate(R.layout.bluetooth_find_broadcast_password_dialog, null);
((TextView) layout.requireViewById(R.id.broadcast_name_text))
.setText(preference.getTitle());
AlertDialog alertDialog =
new AlertDialog.Builder(preference.getContext())
.setTitle(R.string.find_broadcast_password_dialog_title)
.setView(layout)
.setNeutralButton(android.R.string.cancel, null)
.setPositiveButton(
R.string.bluetooth_connect_access_dialog_positive,
(dialog, which) -> {
var code =
((EditText)
layout.requireViewById(
R.id.broadcast_edit_text))
.getText()
.toString();
var metadata =
new BluetoothLeBroadcastMetadata.Builder(source)
.setBroadcastCode(
code.getBytes(StandardCharsets.UTF_8))
.build();
controller.handleSourceAddRequest(preference, metadata);
})
.create();
alertDialog.show();
}
}

View File

@@ -0,0 +1,117 @@
/*
* Copyright (C) 2024 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.audiostreams;
import static com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsScanQrCodeController.REQUEST_SCAN_BT_BROADCAST_QR_CODE;
import android.app.AlertDialog;
import android.content.Context;
import android.content.Intent;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import com.android.settings.R;
import com.android.settings.connecteddevice.audiosharing.audiostreams.qrcode.QrCodeScanModeActivity;
import com.android.settingslib.bluetooth.BluetoothBroadcastUtils;
import com.android.settingslib.utils.ThreadUtils;
class WaitForSyncState extends AudioStreamStateHandler {
@VisibleForTesting
static final int AUDIO_STREAM_WAIT_FOR_SYNC_STATE_SUMMARY =
R.string.audio_streams_wait_for_sync_state_summary;
@VisibleForTesting static final int WAIT_FOR_SYNC_TIMEOUT_MILLIS = 15000;
@Nullable private static WaitForSyncState sInstance = null;
private WaitForSyncState() {}
static WaitForSyncState getInstance() {
if (sInstance == null) {
sInstance = new WaitForSyncState();
}
return sInstance;
}
@Override
void performAction(
AudioStreamPreference preference,
AudioStreamsProgressCategoryController controller,
AudioStreamsHelper helper) {
var metadata = preference.getAudioStreamMetadata();
if (metadata != null) {
mHandler.postDelayed(
() -> {
if (preference.isShown()
&& preference.getAudioStreamState() == getStateEnum()) {
controller.handleSourceLost(preference.getAudioStreamBroadcastId());
ThreadUtils.postOnMainThread(
() -> {
if (controller.getFragment() != null) {
AudioStreamsDialogFragment.show(
controller.getFragment(),
getBroadcastUnavailableDialog(
preference.getContext(),
AudioStreamsHelper.getBroadcastName(
metadata),
controller));
}
});
}
},
WAIT_FOR_SYNC_TIMEOUT_MILLIS);
}
}
@Override
int getSummary() {
return AUDIO_STREAM_WAIT_FOR_SYNC_STATE_SUMMARY;
}
@Override
AudioStreamsProgressCategoryController.AudioStreamState getStateEnum() {
return AudioStreamsProgressCategoryController.AudioStreamState.WAIT_FOR_SYNC;
}
private AudioStreamsDialogFragment.DialogBuilder getBroadcastUnavailableDialog(
Context context,
String broadcastName,
AudioStreamsProgressCategoryController controller) {
return new AudioStreamsDialogFragment.DialogBuilder(context)
.setTitle(context.getString(R.string.audio_streams_dialog_stream_is_not_available))
.setSubTitle1(broadcastName)
.setSubTitle2(context.getString(R.string.audio_streams_is_not_playing))
.setLeftButtonText(context.getString(R.string.audio_streams_dialog_close))
.setLeftButtonOnClickListener(AlertDialog::dismiss)
.setRightButtonText(context.getString(R.string.audio_streams_dialog_retry))
.setRightButtonOnClickListener(
dialog -> {
if (controller.getFragment() != null) {
Intent intent = new Intent(context, QrCodeScanModeActivity.class);
intent.setAction(
BluetoothBroadcastUtils
.ACTION_BLUETOOTH_LE_AUDIO_QR_CODE_SCANNER);
controller
.getFragment()
.startActivityForResult(
intent, REQUEST_SCAN_BT_BROADCAST_QR_CODE);
dialog.dismiss();
}
});
}
}

View File

@@ -0,0 +1,117 @@
/*
* Copyright (C) 2023 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.audiostreams.qrcode;
import android.content.Intent;
import android.os.Bundle;
import android.util.Log;
import androidx.fragment.app.FragmentTransaction;
import com.android.settings.R;
import com.android.settings.connecteddevice.audiosharing.AudioSharingUtils;
import com.android.settingslib.bluetooth.BluetoothBroadcastUtils;
import com.android.settingslib.bluetooth.BluetoothUtils;
/**
* Finding a broadcast through QR code.
*
* <p>To use intent action {@link
* BluetoothBroadcastUtils#ACTION_BLUETOOTH_LE_AUDIO_QR_CODE_SCANNER}, specify the bluetooth device
* sink of the broadcast to be provisioned in {@link
* BluetoothBroadcastUtils#EXTRA_BLUETOOTH_DEVICE_SINK} and check the operation for all coordinated
* set members throughout one session or not by {@link
* BluetoothBroadcastUtils#EXTRA_BLUETOOTH_SINK_IS_GROUP}.
*/
public class QrCodeScanModeActivity extends QrCodeScanModeBaseActivity {
private static final boolean DEBUG = BluetoothUtils.D;
private static final String TAG = "QrCodeScanModeActivity";
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
}
@Override
protected void handleIntent(Intent intent) {
if (!AudioSharingUtils.isFeatureEnabled()) {
finish();
}
String action = intent != null ? intent.getAction() : null;
if (DEBUG) {
Log.d(TAG, "handleIntent(), action = " + action);
}
if (action == null) {
finish();
return;
}
switch (action) {
case BluetoothBroadcastUtils.ACTION_BLUETOOTH_LE_AUDIO_QR_CODE_SCANNER:
showQrCodeScannerFragment(intent);
break;
default:
if (DEBUG) {
Log.e(TAG, "Launch with an invalid action");
}
finish();
}
}
protected void showQrCodeScannerFragment(Intent intent) {
if (intent == null) {
if (DEBUG) {
Log.d(TAG, "intent is null, can not get bluetooth information from intent.");
}
return;
}
if (DEBUG) {
Log.d(TAG, "showQrCodeScannerFragment");
}
if (DEBUG) {
Log.d(TAG, "get extra from intent");
}
QrCodeScanModeFragment fragment =
(QrCodeScanModeFragment)
mFragmentManager.findFragmentByTag(
BluetoothBroadcastUtils.TAG_FRAGMENT_QR_CODE_SCANNER);
if (fragment == null) {
fragment = new QrCodeScanModeFragment();
} else {
if (fragment.isVisible()) {
return;
}
// When the fragment in back stack but not on top of the stack, we can simply pop
// stack because current fragment transactions are arranged in an order
mFragmentManager.popBackStackImmediate();
return;
}
final FragmentTransaction fragmentTransaction = mFragmentManager.beginTransaction();
fragmentTransaction.replace(
R.id.fragment_container,
fragment,
BluetoothBroadcastUtils.TAG_FRAGMENT_QR_CODE_SCANNER);
fragmentTransaction.commit();
}
}

View File

@@ -0,0 +1,64 @@
/*
* Copyright (C) 2023 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.audiostreams.qrcode;
import android.content.Intent;
import android.os.Bundle;
import android.os.SystemProperties;
import androidx.fragment.app.FragmentManager;
import com.android.settings.R;
import com.android.settingslib.core.lifecycle.ObservableActivity;
import com.google.android.setupdesign.util.ThemeHelper;
import com.google.android.setupdesign.util.ThemeResolver;
public abstract class QrCodeScanModeBaseActivity extends ObservableActivity {
private static final String THEME_KEY = "setupwizard.theme";
private static final String THEME_DEFAULT_VALUE = "SudThemeGlifV3_DayNight";
protected FragmentManager mFragmentManager;
protected abstract void handleIntent(Intent intent);
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
int defaultTheme =
ThemeHelper.isSetupWizardDayNightEnabled(this)
? com.google.android.setupdesign.R.style.SudThemeGlifV3_DayNight
: com.google.android.setupdesign.R.style.SudThemeGlifV3_Light;
ThemeResolver themeResolver =
new ThemeResolver.Builder(ThemeResolver.getDefault())
.setDefaultTheme(defaultTheme)
.setUseDayNight(true)
.build();
setTheme(
themeResolver.resolve(
SystemProperties.get(THEME_KEY, THEME_DEFAULT_VALUE),
/* suppressDayNight= */ !ThemeHelper.isSetupWizardDayNightEnabled(this)));
setContentView(R.layout.qrcode_scan_mode_activity);
mFragmentManager = getSupportFragmentManager();
if (savedInstanceState == null) {
handleIntent(getIntent());
}
}
}

View File

@@ -0,0 +1,283 @@
/*
* Copyright (C) 2023 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.audiostreams.qrcode;
import android.app.Activity;
import android.app.settings.SettingsEnums;
import android.content.Context;
import android.content.Intent;
import android.graphics.Matrix;
import android.graphics.Outline;
import android.graphics.Rect;
import android.graphics.SurfaceTexture;
import android.os.Bundle;
import android.os.Handler;
import android.os.Message;
import android.os.VibrationEffect;
import android.os.Vibrator;
import android.util.Log;
import android.util.Size;
import android.view.LayoutInflater;
import android.view.TextureView;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewOutlineProvider;
import android.view.accessibility.AccessibilityEvent;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import com.android.settings.R;
import com.android.settings.bluetooth.Utils;
import com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsHelper;
import com.android.settings.core.InstrumentedFragment;
import com.android.settingslib.bluetooth.BluetoothBroadcastUtils;
import com.android.settingslib.bluetooth.BluetoothUtils;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
import com.android.settingslib.qrcode.QrCamera;
import java.time.Duration;
public class QrCodeScanModeFragment extends InstrumentedFragment
implements TextureView.SurfaceTextureListener, QrCamera.ScannerCallback {
private static final boolean DEBUG = BluetoothUtils.D;
private static final String TAG = "QrCodeScanModeFragment";
/** Message sent to hide error message */
private static final int MESSAGE_HIDE_ERROR_MESSAGE = 1;
/** Message sent to show error message */
private static final int MESSAGE_SHOW_ERROR_MESSAGE = 2;
/** Message sent to broadcast QR code */
private static final int MESSAGE_SCAN_BROADCAST_SUCCESS = 3;
private static final long SHOW_ERROR_MESSAGE_INTERVAL = 10000;
private static final long SHOW_SUCCESS_SQUARE_INTERVAL = 1000;
private static final Duration VIBRATE_DURATION_QR_CODE_RECOGNITION = Duration.ofMillis(3);
public static final String KEY_BROADCAST_METADATA = "key_broadcast_metadata";
private LocalBluetoothManager mLocalBluetoothManager;
private int mCornerRadius;
@Nullable private String mBroadcastMetadata;
private Context mContext;
@Nullable private QrCamera mCamera;
private TextureView mTextureView;
private TextView mSummary;
private TextView mErrorMessage;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mContext = getContext();
mLocalBluetoothManager = Utils.getLocalBluetoothManager(mContext);
}
@Override
public final View onCreateView(
LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
return inflater.inflate(
R.layout.qrcode_scanner_fragment, container, /* attachToRoot */ false);
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState) {
mTextureView = view.findViewById(R.id.preview_view);
mCornerRadius =
mContext.getResources()
.getDimensionPixelSize(R.dimen.audio_streams_qrcode_preview_radius);
mTextureView.setSurfaceTextureListener(this);
mTextureView.setOutlineProvider(
new ViewOutlineProvider() {
@Override
public void getOutline(View view, Outline outline) {
outline.setRoundRect(
0, 0, view.getWidth(), view.getHeight(), mCornerRadius);
}
});
mTextureView.setClipToOutline(true);
mErrorMessage = view.findViewById(R.id.error_message);
var device =
AudioStreamsHelper.getCachedBluetoothDeviceInSharingOrLeConnected(
mLocalBluetoothManager);
mSummary = view.findViewById(android.R.id.summary);
if (mSummary != null && device.isPresent()) {
mSummary.setText(
getString(
R.string.audio_streams_main_page_qr_code_scanner_summary,
device.get().getName()));
}
}
private void initCamera(SurfaceTexture surface) {
// Check if the camera has already created.
if (mCamera == null) {
mCamera = new QrCamera(mContext, this);
mCamera.start(surface);
}
}
private void destroyCamera() {
if (mCamera != null) {
mCamera.stop();
mCamera = null;
}
}
@Override
public void onSurfaceTextureAvailable(@NonNull SurfaceTexture surface, int width, int height) {
initCamera(surface);
}
@Override
public void onSurfaceTextureSizeChanged(
@NonNull SurfaceTexture surface, int width, int height) {}
@Override
public boolean onSurfaceTextureDestroyed(@NonNull SurfaceTexture surface) {
destroyCamera();
return true;
}
@Override
public void onSurfaceTextureUpdated(@NonNull SurfaceTexture surface) {}
@Override
public void handleSuccessfulResult(String qrCode) {
if (DEBUG) {
Log.d(TAG, "handleSuccessfulResult(), get the qr code string.");
}
mBroadcastMetadata = qrCode;
handleBtLeAudioScanner();
}
@Override
public void handleCameraFailure() {
destroyCamera();
}
@Override
public Size getViewSize() {
return new Size(mTextureView.getWidth(), mTextureView.getHeight());
}
@Override
public Rect getFramePosition(Size previewSize, int cameraOrientation) {
return new Rect(0, 0, previewSize.getHeight(), previewSize.getHeight());
}
@Override
public void setTransform(Matrix transform) {
mTextureView.setTransform(transform);
}
@Override
public boolean isValid(String qrCode) {
if (qrCode.startsWith(BluetoothBroadcastUtils.SCHEME_BT_BROADCAST_METADATA)) {
return true;
} else {
showErrorMessage(R.string.audio_streams_qr_code_is_not_valid_format);
return false;
}
}
protected boolean isDecodeTaskAlive() {
return mCamera != null && mCamera.isDecodeTaskAlive();
}
private final Handler mHandler =
new Handler() {
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
case MESSAGE_HIDE_ERROR_MESSAGE:
mErrorMessage.setVisibility(View.INVISIBLE);
break;
case MESSAGE_SHOW_ERROR_MESSAGE:
final String errorMessage = (String) msg.obj;
mErrorMessage.setVisibility(View.VISIBLE);
mErrorMessage.setText(errorMessage);
mErrorMessage.sendAccessibilityEvent(
AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED);
// Cancel any pending messages to hide error view and requeue the
// message so
// user has time to see error
removeMessages(MESSAGE_HIDE_ERROR_MESSAGE);
sendEmptyMessageDelayed(
MESSAGE_HIDE_ERROR_MESSAGE, SHOW_ERROR_MESSAGE_INTERVAL);
break;
case MESSAGE_SCAN_BROADCAST_SUCCESS:
Log.d(TAG, "scan success");
final Intent resultIntent = new Intent();
resultIntent.putExtra(KEY_BROADCAST_METADATA, mBroadcastMetadata);
getActivity().setResult(Activity.RESULT_OK, resultIntent);
notifyUserForQrCodeRecognition();
break;
default:
}
}
};
private void notifyUserForQrCodeRecognition() {
if (mCamera != null) {
mCamera.stop();
}
mErrorMessage.setVisibility(View.INVISIBLE);
mTextureView.setVisibility(View.INVISIBLE);
triggerVibrationForQrCodeRecognition(getContext());
getActivity().finish();
}
private static void triggerVibrationForQrCodeRecognition(Context context) {
Vibrator vibrator = context.getSystemService(Vibrator.class);
if (vibrator == null) {
return;
}
vibrator.vibrate(
VibrationEffect.createOneShot(
VIBRATE_DURATION_QR_CODE_RECOGNITION.toMillis(),
VibrationEffect.DEFAULT_AMPLITUDE));
}
private void showErrorMessage(@StringRes int messageResId) {
final Message message =
mHandler.obtainMessage(MESSAGE_SHOW_ERROR_MESSAGE, getString(messageResId));
message.sendToTarget();
}
private void handleBtLeAudioScanner() {
Message message = mHandler.obtainMessage(MESSAGE_SCAN_BROADCAST_SUCCESS);
mHandler.sendMessageDelayed(message, SHOW_SUCCESS_SQUARE_INTERVAL);
}
@Override
public int getMetricsCategory() {
return SettingsEnums.LE_AUDIO_BROADCAST_SCAN_QR_CODE;
}
}

View File

@@ -24,7 +24,6 @@ import com.android.settings.biometrics.face.FaceFeatureProvider
import com.android.settings.biometrics.fingerprint.FingerprintFeatureProvider
import com.android.settings.biometrics2.factory.BiometricsRepositoryProvider
import com.android.settings.bluetooth.BluetoothFeatureProvider
import com.android.settings.connecteddevice.audiosharing.AudioSharingFeatureProvider
import com.android.settings.connecteddevice.fastpair.FastPairFeatureProvider
import com.android.settings.connecteddevice.stylus.StylusFeatureProvider
import com.android.settings.dashboard.DashboardFeatureProvider
@@ -184,11 +183,6 @@ abstract class FeatureFactory {
*/
abstract val displayFeatureProvider: DisplayFeatureProvider
/**
* Gets implementation for audio sharing related feature.
*/
abstract val audioSharingFeatureProvider: AudioSharingFeatureProvider
/**
* Gets implementation for sync across devices related feature.
*/

View File

@@ -34,8 +34,6 @@ import com.android.settings.biometrics.fingerprint.FingerprintFeatureProviderImp
import com.android.settings.biometrics2.factory.BiometricsRepositoryProviderImpl
import com.android.settings.bluetooth.BluetoothFeatureProvider
import com.android.settings.bluetooth.BluetoothFeatureProviderImpl
import com.android.settings.connecteddevice.audiosharing.AudioSharingFeatureProvider
import com.android.settings.connecteddevice.audiosharing.AudioSharingFeatureProviderImpl
import com.android.settings.connecteddevice.dock.DockUpdaterFeatureProviderImpl
import com.android.settings.connecteddevice.fastpair.FastPairFeatureProvider
import com.android.settings.connecteddevice.fastpair.FastPairFeatureProviderImpl
@@ -196,10 +194,6 @@ open class FeatureFactoryImpl : FeatureFactory() {
DisplayFeatureProviderImpl()
}
override val audioSharingFeatureProvider: AudioSharingFeatureProvider by lazy {
AudioSharingFeatureProviderImpl()
}
override val syncAcrossDevicesFeatureProvider: SyncAcrossDevicesFeatureProvider by lazy {
SyncAcrossDevicesFeatureProviderImpl()
}