Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit a5fcac15 authored by Alex Shabalin's avatar Alex Shabalin Committed by Alexandr Shabalin
Browse files

Fix the connecting state for the device suggesting chip.

The changes in the suggested device chip state management:
- The matching between `MediaDevice` and `SuggestedDeviceInfo` is
 performed by route ID instead of `MediaDevice#id` since the latter
 might be not equal to route id in some scenarios.
- If `CONNECTING` or `CONNECTING_FAILED` set as a result of the event,
they are not replaced by the `DISCONNECTED` state.
- A `CONNECTING` state set as a result of the user interaction event
expires in 20 seconds.
- A `CONNECTING_FAILED` state set as a result of a failed request
expires in 10 seconds. Upon expiration, the suggestion is cleared until
the new suggestion is provided.
- `CONNECTING` state is set before scan starts. That shows the spinner
as soon as a user clicks on a button.

Bug: 431423375
Test: atest SuggestedDeviceManagerTest MediaDeviceManagerTest
Test: On a physical device
Flag: com.android.media.flags.enable_suggested_device_api
Change-Id: I716cd921c68c5f4ec9f4667b8aad673c6774a23d
parent 10179b0a
Loading
Loading
Loading
Loading
+0 −12
Original line number Diff line number Diff line
@@ -363,12 +363,6 @@ public class LocalMediaManager implements BluetoothCallback {
        }
    }

    void dispatchConnectionAttemptedForSuggestion(SuggestedDeviceState state) {
        for (DeviceCallback callback : getCallbacks()) {
            callback.onConnectionAttemptedForSuggestion(state);
        }
    }

    /**
     * Dispatch a change in the about-to-connect device. See
     * {@link DeviceCallback#onAboutToConnectDeviceAdded} for more information.
@@ -878,11 +872,6 @@ public class LocalMediaManager implements BluetoothCallback {
        default void onConnectSuggestedDeviceFinished(
                @NonNull SuggestedDeviceState suggestedDeviceState, boolean success) {
        }

        /** Callback for notifying that connection to suggested device is started. */
        default void onConnectionAttemptedForSuggestion(
                @NonNull SuggestedDeviceState suggestedDeviceState) {
        }
    }

    /**
@@ -999,7 +988,6 @@ public class LocalMediaManager implements BluetoothCallback {
            startScan();
            mConnectSuggestedDeviceHandler.postDelayed(
                    mConnectionAttemptFinishedRunnable, SCAN_DURATION_MS);
            dispatchConnectionAttemptedForSuggestion(mSuggestedDeviceState);
        }
    }
}
+7 −1
Original line number Diff line number Diff line
@@ -125,7 +125,7 @@ public abstract class MediaDevice implements Comparable<MediaDevice> {
    private int mRangeZone = NearbyDevice.RANGE_UNKNOWN;

    protected final Context mContext;
    protected final MediaRoute2Info mRouteInfo;
    @Nullable protected final MediaRoute2Info mRouteInfo;
    @Nullable private final DynamicRouteAttributes mDynamicRouteAttributes;
    protected final RouteListingPreference.Item mRlpItem;
    private boolean mIsSuggested;
@@ -206,6 +206,12 @@ public abstract class MediaDevice implements Comparable<MediaDevice> {
        mRangeZone = rangeZone;
    }

    /** Returns a route associated with this device. */
    @Nullable
    public MediaRoute2Info getRouteInfo() {
        return mRouteInfo;
    }

    /**
     * Get name from MediaDevice.
     *
+138 −77
Original line number Diff line number Diff line
@@ -18,16 +18,22 @@ package com.android.settingslib.media

import android.media.RoutingChangeInfo
import android.media.SuggestedDeviceInfo
import android.os.Handler
import android.util.Log
import androidx.annotation.GuardedBy
import com.android.settingslib.media.LocalMediaManager.MediaDeviceState
import com.android.settingslib.media.LocalMediaManager.MediaDeviceState.STATE_CONNECTED
import com.android.settingslib.media.LocalMediaManager.MediaDeviceState.STATE_CONNECTING
import com.android.settingslib.media.LocalMediaManager.MediaDeviceState.STATE_CONNECTING_FAILED
import com.android.settingslib.media.LocalMediaManager.MediaDeviceState.STATE_DISCONNECTED
import com.android.settingslib.media.LocalMediaManager.MediaDeviceState.STATE_SELECTED
import java.util.concurrent.CopyOnWriteArraySet

private const val TAG = "SuggestedDeviceManager"

private const val CONNECTING_TIMEOUT_MS = 20_000L
private const val CONNECTING_FAILED_TIMEOUT_MS = 10_000L

/**
 * Provides data to render and handles user interactions for the suggested device chip within the
 * Android Media Controls.
@@ -36,20 +42,44 @@ private const val TAG = "SuggestedDeviceManager"
 * - Lists of device suggestions and media routes (media devices) provided by the Media Router.
 * - User interactions with the suggested device chip.
 * - The results of user-initiated connection attempts to these devices.
 *
 * @param localMediaManager an instance of [LocalMediaManager]
 * @param handler a MainHandler to run timeout events on.
 */
class SuggestedDeviceManager(private val localMediaManager: LocalMediaManager) {
class SuggestedDeviceManager(
  private val localMediaManager: LocalMediaManager,
  private val handler: Handler,
) {
  private val lock: Any = Object()
  private val listeners = CopyOnWriteArraySet<Listener>()
  @GuardedBy("lock") private var mediaDevices: List<MediaDevice> = listOf()
  @GuardedBy("lock") private var suggestions: List<SuggestedDeviceInfo> = listOf()
  @GuardedBy("lock") private var topSuggestion: SuggestedDeviceInfo? = null
  @GuardedBy("lock") private var suggestedDeviceState: SuggestedDeviceState? = null
  // Overrides the connection state obtained from the [MediaDevice] that matches the
  // [topSuggestion]. This is necessary to prevent UI state jumps during connection attempts or
  // when displaying error messages.
  @GuardedBy("lock") @MediaDeviceState private var connectionStateOverride: Int? = null

  private val onConnectionStateOverrideExpiredRunnable = Runnable {
    synchronized(lock) {
      if (connectionStateOverride == STATE_CONNECTING_FAILED) {
        // After the connection error, hide the suggestion chip until the new suggestion is
        // requested.
        topSuggestion = null
      }
      connectionStateOverride = null
      updateSuggestedDeviceStateLocked(topSuggestion, mediaDevices)
    }
    dispatchOnSuggestedDeviceUpdated()
  }

  private val localMediaManagerDeviceCallback =
    object : LocalMediaManager.DeviceCallback {
      override fun onDeviceListUpdate(newDevices: List<MediaDevice>?) {
        val stateChanged = synchronized(lock) {
        val stateChanged =
          synchronized(lock) {
            mediaDevices = newDevices?.toList() ?: listOf()
          updateSuggestedDeviceStateLocked()
            updateSuggestedDeviceStateLocked(topSuggestion, mediaDevices)
          }
        if (stateChanged) {
          dispatchOnSuggestedDeviceUpdated()
@@ -57,34 +87,16 @@ class SuggestedDeviceManager(private val localMediaManager: LocalMediaManager) {
      }

      override fun onDeviceSuggestionsUpdated(newSuggestions: List<SuggestedDeviceInfo>) {
        val stateChanged = synchronized(lock) {
          suggestions = newSuggestions
          updateSuggestedDeviceStateLocked()
        val stateChanged =
          synchronized(lock) {
            topSuggestion = newSuggestions.firstOrNull()
            updateSuggestedDeviceStateLocked(topSuggestion, mediaDevices)
          }
        if (stateChanged) {
          dispatchOnSuggestedDeviceUpdated()
        }
      }

      override fun onConnectionAttemptedForSuggestion(
        newSuggestedDeviceState: SuggestedDeviceState
      ) {
        synchronized(lock) {
          if (!isCurrentSuggestion(newSuggestedDeviceState.suggestedDeviceInfo)) {
            Log.w(TAG, "onConnectionAttemptedForSuggestion. Suggestion got changed.")
            return
          }
          if (
            suggestedDeviceState?.connectionState != STATE_DISCONNECTED &&
              suggestedDeviceState?.connectionState != STATE_CONNECTING_FAILED
          ) {
            return
          }
          suggestedDeviceState = suggestedDeviceState?.copy(connectionState = STATE_CONNECTING)
        }
        dispatchOnSuggestedDeviceUpdated()
      }

      override fun onConnectSuggestedDeviceFinished(
        newSuggestedDeviceState: SuggestedDeviceState,
        success: Boolean,
@@ -93,16 +105,18 @@ class SuggestedDeviceManager(private val localMediaManager: LocalMediaManager) {
          Log.w(TAG, "onConnectSuggestedDeviceFinished. Suggestion got changed.")
          return
        }
        synchronized(lock) {
          val connectionState = if (success) STATE_CONNECTED else STATE_CONNECTING_FAILED
          suggestedDeviceState = suggestedDeviceState?.copy(connectionState = connectionState)
        }
        dispatchOnSuggestedDeviceUpdated()
        if (!success) {
          overrideConnectionStateWithExpiration(
            connectionState = STATE_CONNECTING_FAILED,
            timeoutMs = CONNECTING_FAILED_TIMEOUT_MS,
          )
        } // On success, the state should automatically be updated by the MediaDevice state.
      }
    }

  fun addListener(listener: Listener) {
    val shouldRegisterCallback = synchronized(lock) {
    val shouldRegisterCallback =
      synchronized(lock) {
        val wasSetEmpty = listeners.isEmpty()
        listeners.add(listener)
        wasSetEmpty
@@ -115,7 +129,8 @@ class SuggestedDeviceManager(private val localMediaManager: LocalMediaManager) {
  }

  fun removeListener(listener: Listener) {
    val shouldUnregisterCallback = synchronized(lock) {
    val shouldUnregisterCallback =
      synchronized(lock) {
        listeners.remove(listener)
        listeners.isEmpty()
      }
@@ -138,69 +153,115 @@ class SuggestedDeviceManager(private val localMediaManager: LocalMediaManager) {
  }

  fun connectSuggestedDevice(
    suggestedDeviceState: SuggestedDeviceState,
    newSuggestedDeviceState: SuggestedDeviceState,
    routingChangeInfo: RoutingChangeInfo,
  ) {
    if (!isCurrentSuggestion(suggestedDeviceState.suggestedDeviceInfo)) {
    if (!isCurrentSuggestion(newSuggestedDeviceState.suggestedDeviceInfo)) {
      Log.w(TAG, "Suggestion got changed, aborting connection.")
      return
    }
    localMediaManager.connectSuggestedDevice(suggestedDeviceState, routingChangeInfo)
    overrideConnectionStateWithExpiration(
      connectionState = STATE_CONNECTING,
      timeoutMs = CONNECTING_TIMEOUT_MS,
    )
    localMediaManager.connectSuggestedDevice(newSuggestedDeviceState, routingChangeInfo)
  }

  private fun eagerlyUpdateState() {
    synchronized(lock) {
      mediaDevices = localMediaManager.mediaDevices
      suggestions = localMediaManager.suggestions
      updateSuggestedDeviceStateLocked()
      topSuggestion = localMediaManager.suggestions.firstOrNull()
      updateSuggestedDeviceStateLocked(topSuggestion, mediaDevices)
    }
  }

  @GuardedBy("lock")
  private fun updateSuggestedDeviceStateLocked(): Boolean {
    var newSuggestedDeviceState: SuggestedDeviceState? = null
    val previousState = suggestedDeviceState
    val topSuggestion = suggestions.firstOrNull()
    if (topSuggestion != null) {
      val matchedDevice = getDeviceById(mediaDevices, topSuggestion.routeId)
      if (matchedDevice != null) {
        newSuggestedDeviceState = SuggestedDeviceState(topSuggestion, matchedDevice.state)
      }
      if (newSuggestedDeviceState == null) {
        if (previousState != null
          && (topSuggestion.routeId == previousState.suggestedDeviceInfo.routeId)) {
          return false
  private fun updateSuggestedDeviceStateLocked(
    newTopSuggestion: SuggestedDeviceInfo?,
    newMediaDevices: List<MediaDevice>,
  ): Boolean {
    val newSuggestedDeviceState =
      calculateNewSuggestedDeviceStateLocked(newTopSuggestion, newMediaDevices)
    if (newSuggestedDeviceState != suggestedDeviceState) {
      suggestedDeviceState = newSuggestedDeviceState
      return true
    }
        newSuggestedDeviceState = SuggestedDeviceState(topSuggestion)
    return false
  }

  @GuardedBy("lock")
  private fun calculateNewSuggestedDeviceStateLocked(
    newTopSuggestion: SuggestedDeviceInfo?,
    newMediaDevices: List<MediaDevice>
  ): SuggestedDeviceState? {
    if (newTopSuggestion == null) {
      clearConnectionStateOverrideLocked()
      return null
    }

    if (newSuggestedDeviceState != null && isSuggestedDeviceSelected(newSuggestedDeviceState)) {
      newSuggestedDeviceState = null
    val newConnectionState =
      getConnectionStateFromMatchedDeviceLocked(newTopSuggestion, newMediaDevices)
    if (shouldClearStateOverride(newTopSuggestion, newConnectionState)) {
      clearConnectionStateOverrideLocked()
    }
    if (previousState != newSuggestedDeviceState) {
      synchronized(lock) { suggestedDeviceState = newSuggestedDeviceState }
      return true

    return if (isConnectedState(newConnectionState)) {
      // Don't display a suggestion if the MediaDevice that matches the suggestion is connected.
      null
    } else {
      SuggestedDeviceState(newTopSuggestion, connectionStateOverride ?: newConnectionState)
    }
    return false
  }

  private fun isSuggestedDeviceSelected(newSuggestedDeviceState: SuggestedDeviceState): Boolean {
    synchronized(lock) {
      return mediaDevices.any { device ->
        device.isSelected() && device.getId() == newSuggestedDeviceState.suggestedDeviceInfo.routeId
  @GuardedBy("lock")
  @MediaDeviceState
  private fun getConnectionStateFromMatchedDeviceLocked(
    newTopSuggestion: SuggestedDeviceInfo,
    newMediaDevices: List<MediaDevice>,
  ): Int {
    val matchedDevice = getDeviceByRouteId(newMediaDevices, newTopSuggestion.routeId)
    if (matchedDevice?.isSelected == true) {
      return STATE_SELECTED
    }
    return matchedDevice?.state ?: STATE_DISCONNECTED
  }

  private fun shouldClearStateOverride(
    newTopSuggestion: SuggestedDeviceInfo,
    @MediaDeviceState newConnectionState: Int,
  ): Boolean {
    // Don't clear the state override if a matched device is in DISCONNECTED state. Currently, the
    // DISCONNECTED state can be reported during connection that can lead to UI flicker.
    return !isCurrentSuggestion(newTopSuggestion) || newConnectionState != STATE_DISCONNECTED
  }

  private fun getDeviceById(mediaDevices: List<MediaDevice>, routeId: String): MediaDevice? =
    mediaDevices.find { it.id == routeId }
  private fun isConnectedState(@MediaDeviceState state: Int): Boolean =
    state == STATE_CONNECTED || state == STATE_SELECTED

  private fun getDeviceByRouteId(mediaDevices: List<MediaDevice>, routeId: String): MediaDevice? =
    mediaDevices.find { it.routeInfo?.id == routeId }

  private fun isCurrentSuggestion(suggestedDeviceInfo: SuggestedDeviceInfo) =
    synchronized(lock) {
      suggestedDeviceState?.suggestedDeviceInfo?.routeId == suggestedDeviceInfo.routeId
    }

  private fun overrideConnectionStateWithExpiration(connectionState: Int, timeoutMs: Long) {
    synchronized(lock) {
      connectionStateOverride = connectionState
      suggestedDeviceState = suggestedDeviceState?.copy(connectionState = connectionState)
      handler.removeCallbacks(onConnectionStateOverrideExpiredRunnable)
      handler.postDelayed(onConnectionStateOverrideExpiredRunnable, timeoutMs)
    }
    dispatchOnSuggestedDeviceUpdated()
  }

  @GuardedBy("lock")
  private fun clearConnectionStateOverrideLocked() {
    connectionStateOverride = null
    handler.removeCallbacks(onConnectionStateOverrideExpiredRunnable)
  }

  private fun dispatchOnSuggestedDeviceUpdated() {
    val state = synchronized(lock) { suggestedDeviceState }
    Log.i(TAG, "dispatchOnSuggestedDeviceUpdated(), state: $state")
+168 −11
Original line number Diff line number Diff line
@@ -16,20 +16,25 @@

package com.android.settingslib.media

import android.media.MediaRoute2Info
import android.media.MediaRoute2Info.TYPE_REMOTE_SPEAKER
import android.media.RoutingChangeInfo
import android.media.RoutingChangeInfo.ENTRY_POINT_SYSTEM_MEDIA_CONTROLS
import android.media.SuggestedDeviceInfo
import android.os.Handler
import android.os.Looper
import com.android.settingslib.media.LocalMediaManager.MediaDeviceState.STATE_CONNECTED
import com.android.settingslib.media.LocalMediaManager.MediaDeviceState.STATE_CONNECTING
import com.android.settingslib.media.LocalMediaManager.MediaDeviceState.STATE_CONNECTING_FAILED
import com.android.settingslib.media.LocalMediaManager.MediaDeviceState.STATE_DISCONNECTED
import com.google.common.truth.Truth.assertThat
import java.util.concurrent.TimeUnit
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.clearInvocations
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
@@ -37,6 +42,7 @@ import org.mockito.kotlin.stub
import org.mockito.kotlin.verify
import org.mockito.kotlin.verifyNoMoreInteractions
import org.robolectric.RobolectricTestRunner
import org.robolectric.shadows.ShadowLooper

private const val ROUTE_ID_1 = "ROUTE_ID_1"
private const val ROUTE_ID_2 = "ROUTE_ID_2"
@@ -51,16 +57,18 @@ class SuggestedDeviceManagerTest {
  private var listener2 = mock<SuggestedDeviceManager.Listener>()
  private lateinit var mSuggestedDeviceManager: SuggestedDeviceManager

  private val routeInfo1 = mock<MediaRoute2Info> { on { id } doReturn ROUTE_ID_1 }
  private val mediaDevice1 =
    mock<MediaDevice> {
      on { id } doReturn ROUTE_ID_1
      on { routeInfo } doReturn routeInfo1
      on { state } doReturn STATE_DISCONNECTED
      on { isSelected } doReturn false
    }

  private val routeInfo2 = mock<MediaRoute2Info> { on { id } doReturn ROUTE_ID_2 }
  private val mediaDevice2 =
    mock<MediaDevice> {
      on { id } doReturn ROUTE_ID_2
      on { routeInfo } doReturn routeInfo2
      on { state } doReturn STATE_DISCONNECTED
      on { isSelected } doReturn false
    }
@@ -76,7 +84,8 @@ class SuggestedDeviceManagerTest {

  @Before
  fun setUp() {
    mSuggestedDeviceManager = SuggestedDeviceManager(localMediaManager)
    val handler = Handler(Looper.getMainLooper())
    mSuggestedDeviceManager = SuggestedDeviceManager(localMediaManager, handler)
  }

  @Test
@@ -257,16 +266,147 @@ class SuggestedDeviceManagerTest {
  }

  @Test
  fun onConnectSuggestedDeviceFinished_success_dispatchesConnectedState() {
  fun onDeviceListUpdate_fromConnectingOverrideToDisconnected_noDispatch() {
    val deviceCallback = addListenerAndCaptureCallback(listener)

    deviceCallback.onDeviceListUpdate(listOf(mediaDevice1))
    deviceCallback.onDeviceSuggestionsUpdated(listOf(suggestedDeviceInfo1))

    verify(listener)
      .onSuggestedDeviceStateUpdated(SuggestedDeviceState(suggestedDeviceInfo1, STATE_DISCONNECTED))

    val initialSuggestedDeviceState = SuggestedDeviceState(suggestedDeviceInfo1, STATE_DISCONNECTED)
    verify(listener).onSuggestedDeviceStateUpdated(initialSuggestedDeviceState)
    mSuggestedDeviceManager.connectSuggestedDevice(initialSuggestedDeviceState, routingChangeInfo)

    verify(listener)
      .onSuggestedDeviceStateUpdated(SuggestedDeviceState(suggestedDeviceInfo1, STATE_CONNECTING))

    mediaDevice1.stub { on { state } doReturn STATE_DISCONNECTED }
    deviceCallback.onDeviceListUpdate(listOf(mediaDevice1))

    // STATE_DISCONNECTED is ignored.
    verifyNoMoreInteractions(listener)
  }

  @Test
  fun onDeviceListUpdate_fromConnectingOverrideToConnected_dispatchesConnectedState() {
    val deviceCallback = addListenerAndCaptureCallback(listener)

    deviceCallback.onDeviceListUpdate(listOf(mediaDevice1))
    deviceCallback.onDeviceSuggestionsUpdated(listOf(suggestedDeviceInfo1))

    verify(listener)
      .onSuggestedDeviceStateUpdated(SuggestedDeviceState(suggestedDeviceInfo1, STATE_DISCONNECTED))

    val initialSuggestedDeviceState = SuggestedDeviceState(suggestedDeviceInfo1, STATE_DISCONNECTED)
    // This call sets the state to STATE_CONNECTING
    mSuggestedDeviceManager.connectSuggestedDevice(initialSuggestedDeviceState, routingChangeInfo)

    verify(listener)
      .onSuggestedDeviceStateUpdated(SuggestedDeviceState(suggestedDeviceInfo1, STATE_CONNECTING))

    deviceCallback.onConnectSuggestedDeviceFinished(initialSuggestedDeviceState, true)
    val connectedState = initialSuggestedDeviceState.copy(connectionState = STATE_CONNECTED)
    verify(listener).onSuggestedDeviceStateUpdated(connectedState)
    mediaDevice1.stub { on { state } doReturn STATE_CONNECTED }
    deviceCallback.onDeviceListUpdate(listOf(mediaDevice1))

    // STATE_CONNECTED turns state to null
    verify(listener).onSuggestedDeviceStateUpdated(null)
  }

  @Test
  fun onTimeout_fromConnectingOverride_dispatchesDisconnectedState() {
    val expectedConnectingTimeoutMs = 20_000L
    val deviceCallback = addListenerAndCaptureCallback(listener)

    deviceCallback.onDeviceListUpdate(listOf(mediaDevice1))
    deviceCallback.onDeviceSuggestionsUpdated(listOf(suggestedDeviceInfo1))

    verify(listener)
      .onSuggestedDeviceStateUpdated(SuggestedDeviceState(suggestedDeviceInfo1, STATE_DISCONNECTED))

    val initialSuggestedDeviceState = SuggestedDeviceState(suggestedDeviceInfo1, STATE_DISCONNECTED)
    mSuggestedDeviceManager.connectSuggestedDevice(initialSuggestedDeviceState, routingChangeInfo)

    // dispatches STATE_CONNECTING on connection attempt.
    verify(listener)
      .onSuggestedDeviceStateUpdated(SuggestedDeviceState(suggestedDeviceInfo1, STATE_CONNECTING))

    clearInvocations(listener)
    // Check the state one second before the timeout is reached.
    ShadowLooper.idleMainLooper(expectedConnectingTimeoutMs - 1_000, TimeUnit.MILLISECONDS)
    verify(listener, never()).onSuggestedDeviceStateUpdated(any())

    clearInvocations(listener)
    // Check the state one second after the timeout is reached.
    ShadowLooper.idleMainLooper(2_000, TimeUnit.MILLISECONDS)
    verify(listener)
      .onSuggestedDeviceStateUpdated(SuggestedDeviceState(suggestedDeviceInfo1, STATE_DISCONNECTED))
  }

  @Test
  fun onDeviceListUpdate_fromConnectingFailedOverrideToDisconnected_noDispatch() {
    val deviceCallback = addListenerAndCaptureCallback(listener)

    deviceCallback.onDeviceListUpdate(listOf(mediaDevice1))
    deviceCallback.onDeviceSuggestionsUpdated(listOf(suggestedDeviceInfo1))

    verify(listener)
      .onSuggestedDeviceStateUpdated(SuggestedDeviceState(suggestedDeviceInfo1, STATE_DISCONNECTED))

    val initialSuggestedDeviceState = SuggestedDeviceState(suggestedDeviceInfo1, STATE_DISCONNECTED)
    deviceCallback.onConnectSuggestedDeviceFinished(initialSuggestedDeviceState, false)

    verify(listener)
      .onSuggestedDeviceStateUpdated(
        SuggestedDeviceState(suggestedDeviceInfo1, STATE_CONNECTING_FAILED)
      )

    mediaDevice1.stub { on { state } doReturn STATE_DISCONNECTED }
    deviceCallback.onDeviceListUpdate(listOf(mediaDevice1))

    // STATE_DISCONNECTED is ignored.
    verifyNoMoreInteractions(listener)
  }

  @Test
  fun onTimeout_fromConnectingFailedOverride_dispatchesNullState() {
    val expectedConnectingFailedTimeoutMs = 10_000L
    val deviceCallback = addListenerAndCaptureCallback(listener)

    deviceCallback.onDeviceListUpdate(listOf(mediaDevice1))
    deviceCallback.onDeviceSuggestionsUpdated(listOf(suggestedDeviceInfo1))

    verify(listener)
      .onSuggestedDeviceStateUpdated(SuggestedDeviceState(suggestedDeviceInfo1, STATE_DISCONNECTED))

    val initialSuggestedDeviceState = SuggestedDeviceState(suggestedDeviceInfo1, STATE_DISCONNECTED)
    deviceCallback.onConnectSuggestedDeviceFinished(initialSuggestedDeviceState, false)

    // dispatches STATE_CONNECTING_FAILED on failed attempt.
    verify(listener)
      .onSuggestedDeviceStateUpdated(
        SuggestedDeviceState(suggestedDeviceInfo1, STATE_CONNECTING_FAILED)
      )

    clearInvocations(listener)
    // One second before the timeout is reached - no events are dispatched.
    ShadowLooper.idleMainLooper(expectedConnectingFailedTimeoutMs - 1_000, TimeUnit.MILLISECONDS)
    verify(listener, never()).onSuggestedDeviceStateUpdated(any())

    clearInvocations(listener)
    // One second after the timeout is reached - clears the suggestedDeviceState.
    ShadowLooper.idleMainLooper(2_000, TimeUnit.MILLISECONDS)
    verify(listener).onSuggestedDeviceStateUpdated(null)

    clearInvocations(listener)
    // MediaDevice list updates don't cause the suggestedDeviceState to be restored.
    deviceCallback.onDeviceListUpdate(listOf(mediaDevice1))
    verify(listener, never()).onSuggestedDeviceStateUpdated(any())

    // A new suggestion list makes the suggestedDeviceState restore.
    clearInvocations(listener)
    deviceCallback.onDeviceSuggestionsUpdated(listOf(suggestedDeviceInfo1))
    verify(listener)
      .onSuggestedDeviceStateUpdated(SuggestedDeviceState(suggestedDeviceInfo1, STATE_DISCONNECTED))
  }

  @Test
@@ -277,21 +417,34 @@ class SuggestedDeviceManagerTest {
    val initialSuggestedDeviceState = SuggestedDeviceState(suggestedDeviceInfo1, STATE_DISCONNECTED)
    verify(listener).onSuggestedDeviceStateUpdated(initialSuggestedDeviceState)

    mSuggestedDeviceManager.connectSuggestedDevice(initialSuggestedDeviceState, routingChangeInfo)
    val connectingState = initialSuggestedDeviceState.copy(connectionState = STATE_CONNECTING)
    verify(listener).onSuggestedDeviceStateUpdated(connectingState)

    deviceCallback.onConnectSuggestedDeviceFinished(initialSuggestedDeviceState, false)
    val failedState = initialSuggestedDeviceState.copy(connectionState = STATE_CONNECTING_FAILED)
    verify(listener).onSuggestedDeviceStateUpdated(failedState)
  }

  @Test
  fun onConnectionAttemptedForSuggestion_fromDisconnected_changesStateToConnecting() {
  fun onConnectionStarted_fromConnectingFailed_changesStateToConnecting() {
    val deviceCallback = addListenerAndCaptureCallback(listener)

    // Simulate a failed connection first
    deviceCallback.onDeviceSuggestionsUpdated(listOf(suggestedDeviceInfo1))
    val initialSuggestedDeviceState = SuggestedDeviceState(suggestedDeviceInfo1, STATE_DISCONNECTED)
    deviceCallback.onConnectionAttemptedForSuggestion(initialSuggestedDeviceState)
    val failedState = initialSuggestedDeviceState.copy(connectionState = STATE_CONNECTING)
    deviceCallback.onConnectSuggestedDeviceFinished(
      initialSuggestedDeviceState,
      false,
    ) // Simulate failure
    val failedState = initialSuggestedDeviceState.copy(connectionState = STATE_CONNECTING_FAILED)
    verify(listener).onSuggestedDeviceStateUpdated(failedState)

    mSuggestedDeviceManager.connectSuggestedDevice(initialSuggestedDeviceState, routingChangeInfo)

    val expectedState = failedState.copy(connectionState = STATE_CONNECTING)
    verify(listener)
      .onSuggestedDeviceStateUpdated(expectedState) // Should be called again with connecting state
  }

  @Test
@@ -303,6 +456,10 @@ class SuggestedDeviceManagerTest {
    mSuggestedDeviceManager.connectSuggestedDevice(currentSuggestedState, routingChangeInfo)

    verify(localMediaManager).connectSuggestedDevice(currentSuggestedState, routingChangeInfo)
    verify(listener)
      .onSuggestedDeviceStateUpdated(
        currentSuggestedState.copy(connectionState = STATE_CONNECTING)
      )
  }

  @Test
+4 −2

File changed.

Preview size limit exceeded, changes collapsed.

Loading