[Audiosharing] Handle sync, add source via qrcode.

Bug: 305620450
Test: manual
Change-Id: I32c14607035d8f37f44186175657c42307780e7b
diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamPreference.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamPreference.java
index ffb0b88..678f952 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 @@
  */
 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 @@
         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 @@
     }
 
     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 @@
                 .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 a418415..b0af7dd 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 @@
 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 @@
     @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 @@
                 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 198e8e5..2c6eedb 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 @@
     }
 
     /** Retrieves a list of all LE broadcast receive states from active sinks. */
-    List<BluetoothLeBroadcastReceiveState> getAllSources() {
+    List<BluetoothLeBroadcastReceiveState> 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 @@
         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 3c005b2..ab380c8 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.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 @@
                 }
             };
 
+    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 @@
     private final @Nullable LocalBluetoothManager mBluetoothManager;
     private final ConcurrentHashMap<Integer, AudioStreamPreference> mBroadcastIdToPreferenceMap =
             new ConcurrentHashMap<>();
+    private TimedSourceFromQrCode mTimedSourceFromQrCode;
     private AudioStreamsProgressCategoryPreference mCategoryPreference;
 
     public AudioStreamsProgressCategoryController(Context context, String preferenceKey) {
@@ -122,6 +135,12 @@
         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 @@
                     }
                     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 @@
         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 @@
         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 @@
             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 @@
         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 @@
                                                     .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;
+        }
+    }
 }