diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamPreference.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamPreference.java index ffb0b884fda..678f9524a37 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamPreference.java +++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamPreference.java @@ -35,21 +35,27 @@ import com.google.common.base.Strings; */ class AudioStreamPreference extends TwoTargetPreference { private boolean mIsConnected = false; + private AudioStream mAudioStream; /** * Update preference UI based on connection status * - * @param isConnected Is this streams connected + * @param isConnected Is this stream connected + * @param summary Summary text + * @param onPreferenceClickListener Click listener for the preference */ void setIsConnected( - boolean isConnected, @Nullable OnPreferenceClickListener onPreferenceClickListener) { + boolean isConnected, + String summary, + @Nullable OnPreferenceClickListener onPreferenceClickListener) { if (mIsConnected == isConnected + && getSummary() == summary && getOnPreferenceClickListener() == onPreferenceClickListener) { // Nothing to update. return; } mIsConnected = isConnected; - setSummary(isConnected ? "Listening now" : ""); + setSummary(summary); setOrder(isConnected ? 0 : 1); setOnPreferenceClickListener(onPreferenceClickListener); notifyChanged(); @@ -60,6 +66,14 @@ class AudioStreamPreference extends TwoTargetPreference { setIcon(R.drawable.ic_bt_audio_sharing); } + void setAudioStreamState(AudioStreamsProgressCategoryController.AudioStreamState state) { + mAudioStream.setState(state); + } + + AudioStreamsProgressCategoryController.AudioStreamState getAudioStreamState() { + return mAudioStream.getState(); + } + @Override protected boolean shouldHideSecondTarget() { return mIsConnected; @@ -71,19 +85,31 @@ class AudioStreamPreference extends TwoTargetPreference { } static AudioStreamPreference fromMetadata( - Context context, BluetoothLeBroadcastMetadata source) { + Context context, + BluetoothLeBroadcastMetadata source, + AudioStreamsProgressCategoryController.AudioStreamState streamState) { AudioStreamPreference preference = new AudioStreamPreference(context, /* attrs= */ null); preference.setTitle(getBroadcastName(source)); + preference.setAudioStream(new AudioStream(source.getBroadcastId(), streamState)); return preference; } static AudioStreamPreference fromReceiveState( - Context context, BluetoothLeBroadcastReceiveState state) { + Context context, + BluetoothLeBroadcastReceiveState receiveState, + AudioStreamsProgressCategoryController.AudioStreamState streamState) { AudioStreamPreference preference = new AudioStreamPreference(context, /* attrs= */ null); - preference.setTitle(getBroadcastName(state)); + preference.setTitle(getBroadcastName(receiveState)); + preference.setAudioStream( + new AudioStream( + receiveState.getSourceId(), receiveState.getBroadcastId(), streamState)); return preference; } + private void setAudioStream(AudioStream audioStream) { + mAudioStream = audioStream; + } + private static String getBroadcastName(BluetoothLeBroadcastMetadata source) { return source.getSubgroups().stream() .map(s -> s.getContentMetadata().getProgramInfo()) @@ -99,4 +125,43 @@ class AudioStreamPreference extends TwoTargetPreference { .findFirst() .orElse("Broadcast Id: " + state.getBroadcastId()); } + + private static final class AudioStream { + private int mSourceId; + private int mBroadcastId; + private AudioStreamsProgressCategoryController.AudioStreamState mState; + + private AudioStream( + int broadcastId, AudioStreamsProgressCategoryController.AudioStreamState state) { + mBroadcastId = broadcastId; + mState = state; + } + + private AudioStream( + int sourceId, + int broadcastId, + AudioStreamsProgressCategoryController.AudioStreamState state) { + mSourceId = sourceId; + mBroadcastId = broadcastId; + mState = state; + } + + // TODO(chelseahao): use this to handleSourceRemoved + private int getSourceId() { + return mSourceId; + } + + // TODO(chelseahao): use this to handleSourceRemoved + private int getBroadcastId() { + return mBroadcastId; + } + + private AudioStreamsProgressCategoryController.AudioStreamState getState() { + return mState; + } + + private void setState(AudioStreamsProgressCategoryController.AudioStreamState state) { + mState = state; + } + } } diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsDashboardFragment.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsDashboardFragment.java index a418415d5f2..b0af7ddd78a 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsDashboardFragment.java +++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsDashboardFragment.java @@ -34,7 +34,7 @@ import com.android.settingslib.bluetooth.BluetoothUtils; public class AudioStreamsDashboardFragment extends DashboardFragment { private static final String TAG = "AudioStreamsDashboardFrag"; private static final boolean DEBUG = BluetoothUtils.D; - private AudioStreamsScanQrCodeController mAudioStreamsScanQrCodeController; + private AudioStreamsProgressCategoryController mAudioStreamsProgressCategoryController; public AudioStreamsDashboardFragment() { super(); @@ -69,8 +69,8 @@ public class AudioStreamsDashboardFragment extends DashboardFragment { @Override public void onAttach(Context context) { super.onAttach(context); - mAudioStreamsScanQrCodeController = use(AudioStreamsScanQrCodeController.class); - mAudioStreamsScanQrCodeController.setFragment(this); + use(AudioStreamsScanQrCodeController.class).setFragment(this); + mAudioStreamsProgressCategoryController = use(AudioStreamsProgressCategoryController.class); } @Override @@ -103,11 +103,13 @@ public class AudioStreamsDashboardFragment extends DashboardFragment { if (DEBUG) { Log.d(TAG, "onActivityResult() broadcastId : " + source.getBroadcastId()); } - if (mAudioStreamsScanQrCodeController == null) { - Log.w(TAG, "onActivityResult() AudioStreamsScanQrCodeController is null!"); + if (mAudioStreamsProgressCategoryController == null) { + Log.w( + TAG, + "onActivityResult() AudioStreamsProgressCategoryController is null!"); return; } - mAudioStreamsScanQrCodeController.addSource(source); + mAudioStreamsProgressCategoryController.setSourceFromQrCode(source); } } } diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsHelper.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsHelper.java index 198e8e5f335..2c6eedbfbf7 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsHelper.java +++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsHelper.java @@ -109,13 +109,14 @@ class AudioStreamsHelper { } /** Retrieves a list of all LE broadcast receive states from active sinks. */ - List getAllSources() { + List getAllConnectedSources() { if (mLeBroadcastAssistant == null) { Log.w(TAG, "getAllSources(): LeBroadcastAssistant is null!"); return emptyList(); } return getActiveSinksOnAssistant(mBluetoothManager).stream() .flatMap(sink -> mLeBroadcastAssistant.getAllSources(sink).stream()) + .filter(this::isConnected) .toList(); } @@ -124,7 +125,7 @@ class AudioStreamsHelper { return mLeBroadcastAssistant; } - static boolean isConnected(BluetoothLeBroadcastReceiveState state) { + boolean isConnected(BluetoothLeBroadcastReceiveState state) { return state.getPaSyncState() == BluetoothLeBroadcastReceiveState.PA_SYNC_STATE_SYNCHRONIZED && state.getBigEncryptionState() == BluetoothLeBroadcastReceiveState.BIG_ENCRYPTION_STATE_DECRYPTING; diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryController.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryController.java index 3c005b294b5..ab380c811ba 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryController.java +++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamsProgressCategoryController.java @@ -25,6 +25,7 @@ import android.bluetooth.BluetoothLeBroadcastReceiveState; import android.bluetooth.BluetoothProfile; import android.content.Context; import android.os.Bundle; +import android.os.CountDownTimer; import android.util.Log; import android.view.LayoutInflater; import android.view.View; @@ -71,6 +72,17 @@ public class AudioStreamsProgressCategoryController extends BasePreferenceContro } }; + enum AudioStreamState { + // When mTimedSourceFromQrCode 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. + WAIT_FOR_SOURCE_ADD, + // Source is added to active sink. + SOURCE_ADDED, + } + private final Executor mExecutor; private final AudioStreamsBroadcastAssistantCallback mBroadcastAssistantCallback; private final AudioStreamsHelper mAudioStreamsHelper; @@ -78,6 +90,7 @@ public class AudioStreamsProgressCategoryController extends BasePreferenceContro private final @Nullable LocalBluetoothManager mBluetoothManager; private final ConcurrentHashMap mBroadcastIdToPreferenceMap = new ConcurrentHashMap<>(); + private TimedSourceFromQrCode mTimedSourceFromQrCode; private AudioStreamsProgressCategoryPreference mCategoryPreference; public AudioStreamsProgressCategoryController(Context context, String preferenceKey) { @@ -122,6 +135,12 @@ public class AudioStreamsProgressCategoryController extends BasePreferenceContro mExecutor.execute(this::stopScanning); } + void setSourceFromQrCode(BluetoothLeBroadcastMetadata source) { + mTimedSourceFromQrCode = + new TimedSourceFromQrCode( + mContext, source, () -> handleSourceLost(source.getBroadcastId())); + } + void setScanning(boolean isScanning) { ThreadUtils.postOnMainThread( () -> { @@ -140,24 +159,90 @@ public class AudioStreamsProgressCategoryController extends BasePreferenceContro } if (source.isEncrypted()) { ThreadUtils.postOnMainThread( - () -> launchPasswordDialog(source, preference)); + () -> + launchPasswordDialog( + source, (AudioStreamPreference) preference)); } else { mAudioStreamsHelper.addSource(source); + ((AudioStreamPreference) preference) + .setAudioStreamState(AudioStreamState.WAIT_FOR_SOURCE_ADD); + updatePreferenceConnectionState( + (AudioStreamPreference) preference, + AudioStreamState.WAIT_FOR_SOURCE_ADD, + null); } return true; }; - mBroadcastIdToPreferenceMap.computeIfAbsent( - source.getBroadcastId(), - k -> { - var preference = AudioStreamPreference.fromMetadata(mContext, source); - ThreadUtils.postOnMainThread( - () -> { - preference.setIsConnected(false, addSourceOrShowDialog); - if (mCategoryPreference != null) { - mCategoryPreference.addPreference(preference); - } - }); - return preference; + + var broadcastIdFound = source.getBroadcastId(); + mBroadcastIdToPreferenceMap.compute( + broadcastIdFound, + (k, v) -> { + if (v == null) { + return addNewPreference( + source, AudioStreamState.SYNCED, addSourceOrShowDialog); + } + var fromState = v.getAudioStreamState(); + if (fromState == AudioStreamState.WAIT_FOR_SYNC) { + var pendingSource = mTimedSourceFromQrCode.get(); + if (pendingSource == null) { + Log.w( + TAG, + "handleSourceFound(): unexpected state with null pendingSource:" + + fromState + + " for broadcastId : " + + broadcastIdFound); + v.setAudioStreamState(AudioStreamState.SYNCED); + return v; + } + mAudioStreamsHelper.addSource(pendingSource); + mTimedSourceFromQrCode.consumed(); + v.setAudioStreamState(AudioStreamState.WAIT_FOR_SOURCE_ADD); + updatePreferenceConnectionState( + v, AudioStreamState.WAIT_FOR_SOURCE_ADD, null); + } else { + if (fromState != AudioStreamState.SOURCE_ADDED) { + Log.w( + TAG, + "handleSourceFound(): unexpected state : " + + fromState + + " for broadcastId : " + + broadcastIdFound); + } + } + return v; + }); + } + + private void handleSourceFromQrCodeIfExists() { + if (mTimedSourceFromQrCode == null || mTimedSourceFromQrCode.get() == null) { + return; + } + var metadataFromQrCode = mTimedSourceFromQrCode.get(); + mBroadcastIdToPreferenceMap.compute( + metadataFromQrCode.getBroadcastId(), + (k, v) -> { + if (v == null) { + mTimedSourceFromQrCode.waitForConsume(); + return addNewPreference( + metadataFromQrCode, AudioStreamState.WAIT_FOR_SYNC, null); + } + var fromState = v.getAudioStreamState(); + if (fromState == AudioStreamState.SYNCED) { + mAudioStreamsHelper.addSource(metadataFromQrCode); + mTimedSourceFromQrCode.consumed(); + v.setAudioStreamState(AudioStreamState.WAIT_FOR_SOURCE_ADD); + updatePreferenceConnectionState( + v, AudioStreamState.WAIT_FOR_SOURCE_ADD, null); + } else { + Log.w( + TAG, + "handleSourceFromQrCode(): unexpected state : " + + fromState + + " for broadcastId : " + + metadataFromQrCode.getBroadcastId()); + } + return v; }); } @@ -174,32 +259,54 @@ public class AudioStreamsProgressCategoryController extends BasePreferenceContro mAudioStreamsHelper.removeSource(broadcastId); } - void handleSourceConnected(BluetoothLeBroadcastReceiveState state) { - if (!AudioStreamsHelper.isConnected(state)) { + void handleSourceConnected(BluetoothLeBroadcastReceiveState receiveState) { + if (!mAudioStreamsHelper.isConnected(receiveState)) { return; } + var sourceAddedState = AudioStreamState.SOURCE_ADDED; + var broadcastIdConnected = receiveState.getBroadcastId(); mBroadcastIdToPreferenceMap.compute( - state.getBroadcastId(), + broadcastIdConnected, (k, v) -> { - // True if this source has been added either by scanning, or it's currently - // connected to another active sink. - boolean existed = v != null; - AudioStreamPreference preference = - existed ? v : AudioStreamPreference.fromReceiveState(mContext, state); - - ThreadUtils.postOnMainThread( - () -> { - preference.setIsConnected( - true, p -> launchDetailFragment(state.getBroadcastId())); - if (mCategoryPreference != null && !existed) { - mCategoryPreference.addPreference(preference); - } - }); - - return preference; + if (v == null) { + return addNewPreference( + receiveState, + sourceAddedState, + p -> launchDetailFragment(broadcastIdConnected)); + } + var fromState = v.getAudioStreamState(); + if (fromState == AudioStreamState.WAIT_FOR_SOURCE_ADD + || fromState == AudioStreamState.SYNCED + || fromState == AudioStreamState.WAIT_FOR_SYNC) { + if (mTimedSourceFromQrCode != null) { + mTimedSourceFromQrCode.consumed(); + } + } else { + if (fromState != AudioStreamState.SOURCE_ADDED) { + Log.w( + TAG, + "handleSourceConnected(): unexpected state : " + + fromState + + " for broadcastId : " + + broadcastIdConnected); + } + } + v.setAudioStreamState(sourceAddedState); + updatePreferenceConnectionState( + v, sourceAddedState, p -> launchDetailFragment(broadcastIdConnected)); + return v; }); } + private static String getPreferenceSummary(AudioStreamState state) { + return switch (state) { + case WAIT_FOR_SYNC -> "Scanning..."; + case WAIT_FOR_SOURCE_ADD -> "Connecting..."; + case SOURCE_ADDED -> "Listening now"; + default -> ""; + }; + } + void showToast(String msg) { AudioSharingUtils.toastMessage(mContext, msg); } @@ -235,13 +342,15 @@ public class AudioStreamsProgressCategoryController extends BasePreferenceContro mLeBroadcastAssistant.registerServiceCallBack(mExecutor, mBroadcastAssistantCallback); mLeBroadcastAssistant.startSearchingForSources(emptyList()); - // Display currently connected streams + // Handle QR code scan and display currently connected streams var unused = ThreadUtils.postOnBackgroundThread( - () -> - mAudioStreamsHelper - .getAllSources() - .forEach(this::handleSourceConnected)); + () -> { + handleSourceFromQrCodeIfExists(); + mAudioStreamsHelper + .getAllConnectedSources() + .forEach(this::handleSourceConnected); + }); } private void stopScanning() { @@ -256,6 +365,43 @@ public class AudioStreamsProgressCategoryController extends BasePreferenceContro mLeBroadcastAssistant.stopSearchingForSources(); } mLeBroadcastAssistant.unregisterServiceCallBack(mBroadcastAssistantCallback); + if (mTimedSourceFromQrCode != null) { + mTimedSourceFromQrCode.consumed(); + } + } + + private AudioStreamPreference addNewPreference( + BluetoothLeBroadcastReceiveState receiveState, + AudioStreamState state, + Preference.OnPreferenceClickListener onClickListener) { + var preference = AudioStreamPreference.fromReceiveState(mContext, receiveState, state); + updatePreferenceConnectionState(preference, state, onClickListener); + return preference; + } + + private AudioStreamPreference addNewPreference( + BluetoothLeBroadcastMetadata metadata, + AudioStreamState state, + Preference.OnPreferenceClickListener onClickListener) { + var preference = AudioStreamPreference.fromMetadata(mContext, metadata, state); + updatePreferenceConnectionState(preference, state, onClickListener); + return preference; + } + + private void updatePreferenceConnectionState( + AudioStreamPreference preference, + AudioStreamState state, + Preference.OnPreferenceClickListener onClickListener) { + ThreadUtils.postOnMainThread( + () -> { + preference.setIsConnected( + state == AudioStreamState.SOURCE_ADDED, + getPreferenceSummary(state), + onClickListener); + if (mCategoryPreference != null) { + mCategoryPreference.addPreference(preference); + } + }); } private boolean launchDetailFragment(int broadcastId) { @@ -282,7 +428,8 @@ public class AudioStreamsProgressCategoryController extends BasePreferenceContro return true; } - private void launchPasswordDialog(BluetoothLeBroadcastMetadata source, Preference preference) { + private void launchPasswordDialog( + BluetoothLeBroadcastMetadata source, AudioStreamPreference preference) { View layout = LayoutInflater.from(mContext) .inflate(R.layout.bluetooth_find_broadcast_password_dialog, null); @@ -307,8 +454,49 @@ public class AudioStreamsProgressCategoryController extends BasePreferenceContro .setBroadcastCode( code.getBytes(StandardCharsets.UTF_8)) .build()); + preference.setAudioStreamState( + AudioStreamState.WAIT_FOR_SOURCE_ADD); + updatePreferenceConnectionState( + preference, AudioStreamState.WAIT_FOR_SOURCE_ADD, null); }) .create(); alertDialog.show(); } + + private static class TimedSourceFromQrCode { + private static final int WAIT_FOR_SYNC_TIMEOUT_MILLIS = 15000; + private final CountDownTimer mTimer; + private BluetoothLeBroadcastMetadata mSourceFromQrCode; + + private TimedSourceFromQrCode( + Context context, + BluetoothLeBroadcastMetadata sourceFromQrCode, + Runnable timeoutAction) { + mSourceFromQrCode = sourceFromQrCode; + mTimer = + new CountDownTimer(WAIT_FOR_SYNC_TIMEOUT_MILLIS, 1000) { + @Override + public void onTick(long millisUntilFinished) {} + + @Override + public void onFinish() { + timeoutAction.run(); + AudioSharingUtils.toastMessage(context, "Audio steam isn't available"); + } + }; + } + + private void waitForConsume() { + mTimer.start(); + } + + private void consumed() { + mTimer.cancel(); + mSourceFromQrCode = null; + } + + private BluetoothLeBroadcastMetadata get() { + return mSourceFromQrCode; + } + } }