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

Commit e45a5615 authored by Alex Shabalin's avatar Alex Shabalin
Browse files

Use Kotlin coroutines for SuggestedDeviceConnectionManager.

The improvements are:
- Scan and connection are 2 sequential suspend functions with a limited
 scope.
- Reduce to a minimum mutable state within the class.
- Having a separate 10 second timeout for scan and 20 second timeout for
 connection.
- Ability to cleanup resources with the `cancel` method.

Bug: 443107229
Bug: 442856281
Test: atest SuggestedDeviceConnectionManagerTest
Test: atest SuggestedDeviceManagerTest
Test: On a physical device
Flag: com.android.media.flags.use_suggested_device_connection_manager
Change-Id: I77b11d64d1125ba55376002615d0fe29949c5f06
parent fbbfc73a
Loading
Loading
Loading
Loading
+113 −108
Original line number Diff line number Diff line
/*
 * Copyright 2025 The Android Open Source Project
 * Copyright (C) 2025 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
@@ -16,12 +16,18 @@
package com.android.settingslib.media

import android.media.RoutingChangeInfo
import android.os.Handler
import android.util.Log
import androidx.annotation.MainThread
import androidx.annotation.OpenForTesting
import com.android.internal.annotations.GuardedBy
import com.android.settingslib.media.LocalMediaManager.MediaDeviceState
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.resume
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull

typealias ConnectionFinishedCallback = (SuggestedDeviceState, Boolean) -> Unit

@@ -31,145 +37,144 @@ typealias ConnectionFinishedCallback = (SuggestedDeviceState, Boolean) -> Unit
 */
@OpenForTesting
open class SuggestedDeviceConnectionManager(
    val localMediaManager: LocalMediaManager,
    var connectSuggestedDeviceHandler: Handler,
    private val localMediaManager: LocalMediaManager,
    private val coroutineScope: CoroutineScope,
) {
    /** Callback for notifying that connection to suggested device is finished. */
    private var connectionFinishedCallback: ConnectionFinishedCallback? = null
    private val lock = Any()

    @GuardedBy("lock") var connectingSuggestedDeviceState: ConnectingSuggestedDeviceState? = null
    private val isConnectInProgress = AtomicBoolean(false)
    private var activeJob: Job? = null

    /**
     * Connects to a suggested device. If the device is not already scanned, a scan will be started
     * to attempt to discover the device.
     *
     * @param suggestion the suggested device to connect to.
     * @param routingChangeInfo the invocation details of the connect device request.
     * @param suggestedDeviceState the suggested device to connect to.
     * @param routingChangeInfo the invocation details of the connect device request. See [ ]
     * @param callback the callback to be invoked when the connection attempt is complete.
     */
    @OpenForTesting
    open fun connectSuggestedDevice(
        suggestion: SuggestedDeviceState,
    @Throws(IllegalStateException::class)
    open fun connect(
        suggestedDeviceState: SuggestedDeviceState,
        routingChangeInfo: RoutingChangeInfo,
        callback: ConnectionFinishedCallback,
    ) {
        synchronized(lock) {
            if (connectingSuggestedDeviceState != null) {
                Log.w(TAG, "Connection already in progress.")
                return
            }
            for (device in localMediaManager.mediaDevices) {
                if (suggestion.suggestedDeviceInfo.routeId == device.id) {
                    Log.i(TAG, "Device is available, connecting. deviceId = ${device.id}")
                    localMediaManager.connectDevice(device, routingChangeInfo)
                    return
        if (isConnectInProgress.compareAndSet(false, true)) {
            activeJob =
                coroutineScope.launch {
                    try {
                        val result = awaitConnect(suggestedDeviceState, routingChangeInfo)
                        callback(suggestedDeviceState, result)
                    } finally {
                        isConnectInProgress.set(false)
                    }
                }
            connectingSuggestedDeviceState =
                ConnectingSuggestedDeviceState(suggestion, routingChangeInfo.entryPoint).apply {
                    tryConnect()
                }
        } else {
            throw IllegalStateException("Connection already in progress")
        }
    }

    @OpenForTesting
    open fun setConnectionFinishedCallback(callback: ConnectionFinishedCallback?) {
        connectionFinishedCallback = callback
    open fun cancel() {
        activeJob?.cancel()
    }

    inner class ConnectingSuggestedDeviceState(
        val suggestedDeviceState: SuggestedDeviceState,
        @RoutingChangeInfo.EntryPoint entryPoint: Int,
    ) {
        var isConnectionAttemptActive: Boolean = false
        var didAttemptCompleteSuccessfully: Boolean = false
    private suspend fun awaitConnect(
        suggestedDeviceState: SuggestedDeviceState,
        routingChangeInfo: RoutingChangeInfo,
    ): Boolean {
        val suggestedRouteId = suggestedDeviceState.suggestedDeviceInfo.routeId
        val deviceFromCache = getDeviceByRouteId(localMediaManager.mediaDevices, suggestedRouteId)
        val deviceToConnect =
            deviceFromCache?.also { Log.i(TAG, "Device from cache found.") }
                ?: run {
                    Log.i(TAG, "Scanning for device.")
                    awaitScanForDevice(suggestedRouteId)
                }
        if (deviceToConnect == null) {
            Log.w(TAG, "Failed to find a device to connect to. routeId = $suggestedRouteId")
            return false
        }
        Log.i(TAG, "Connecting to device. id = ${deviceToConnect.id}")
        return awaitConnectToDevice(deviceToConnect, routingChangeInfo)
    }

        private val mDeviceCallback =
            object : LocalMediaManager.DeviceCallback {
                override fun onDeviceListUpdate(mediaDevices: List<MediaDevice>) {
                    synchronized(lock) {
                        for (mediaDevice in mediaDevices) {
                            if (isSuggestedDevice(mediaDevice)) {
    private suspend fun awaitScanForDevice(suggestedRouteId: String): MediaDevice? {
        var callback: LocalMediaManager.DeviceCallback? = null
        try {
            return withTimeoutOrNull(SCAN_TIMEOUT) {
                suspendCancellableCoroutine { continuation ->
                    callback = object : LocalMediaManager.DeviceCallback {
                        override fun onDeviceListUpdate(newDevices: List<MediaDevice>?) {
                            val device = getDeviceByRouteId(newDevices, suggestedRouteId)
                            if (device != null) {
                                Log.i(
                                    TAG,
                                    "Scan found matched device, connecting. deviceId = ${mediaDevice.id}",
                                )
                                localMediaManager.connectDevice(
                                    mediaDevice,
                                    RoutingChangeInfo(entryPoint, /* isSuggested= */ true),
                                    "Scan found matched device. routeId = $suggestedRouteId",
                                )
                                isConnectionAttemptActive = true
                                break
                                continuation.resume(device)
                            }
                        }
                    }
                    localMediaManager.registerCallback(callback)
                    localMediaManager.startScan()
                }
            } ?: run {
                Log.w(TAG, "Scan timed out. routeId = $suggestedRouteId")
                null
            }
        } finally {
            localMediaManager.unregisterCallback(callback)
            localMediaManager.stopScan()
        }
    }

    private suspend fun awaitConnectToDevice(
        deviceToConnect: MediaDevice,
        routingChangeInfo: RoutingChangeInfo,
    ): Boolean {
        var callback: LocalMediaManager.DeviceCallback? = null
        val deviceId = deviceToConnect.id
        try {
            return withTimeoutOrNull(CONNECTION_TIMEOUT) {
                suspendCancellableCoroutine { continuation: CancellableContinuation<Boolean> ->
                    callback = object : LocalMediaManager.DeviceCallback {
                        override fun onDeviceListUpdate(newDevices: List<MediaDevice>?) =
                            checkConnectionStatus()

                        override fun onSelectedDeviceStateChanged(
                            device: MediaDevice,
                            @MediaDeviceState state: Int,
                ) {
                    if (isSuggestedDevice(device) && (state == MediaDeviceState.STATE_CONNECTED)) {
                        if (
                            !connectSuggestedDeviceHandler.hasCallbacks(
                                mConnectionAttemptFinishedRunnable
                            )
                        ) {
                            return
                        ) = checkConnectionStatus()

                        private fun checkConnectionStatus() {
                            if (localMediaManager.currentConnectedDevice?.id == deviceId) {
                                Log.i(TAG, "Successfully connected to device. id = $deviceId")
                                continuation.resume(true)
                            }
                        didAttemptCompleteSuccessfully = true
                        // Remove the postDelayed runnable previously set and post a new one
                        // to be executed right away.
                        connectSuggestedDeviceHandler.removeCallbacks(
                            mConnectionAttemptFinishedRunnable
                        )
                        connectSuggestedDeviceHandler.post(mConnectionAttemptFinishedRunnable)
                        }
                    }

                fun isSuggestedDevice(device: MediaDevice): Boolean {
                    return connectingSuggestedDeviceState != null &&
                        (connectingSuggestedDeviceState!!
                            .suggestedDeviceState
                            .suggestedDeviceInfo
                            .routeId == device.id)
                    localMediaManager.registerCallback(callback)
                    localMediaManager.connectDevice(deviceToConnect, routingChangeInfo)
                }
            } ?: run {
                Log.w(TAG, "Connection timed out. id = $deviceId")
                false
            }

        val mConnectionAttemptFinishedRunnable: Runnable = Runnable {
            synchronized(lock) {
                connectingSuggestedDeviceState = null
                isConnectionAttemptActive = false
        } finally {
            localMediaManager.unregisterCallback(callback)
        }
            localMediaManager.unregisterCallback(mDeviceCallback)
            localMediaManager.stopScan()
            Log.i(TAG, "Scan stopped. success = $didAttemptCompleteSuccessfully")
            dispatchOnConnectionFinished(suggestedDeviceState, didAttemptCompleteSuccessfully)
        }

        @MainThread
        private fun dispatchOnConnectionFinished(state: SuggestedDeviceState, success: Boolean) {
            connectionFinishedCallback?.invoke(state, success)
    }

        fun tryConnect() {
            // Attempt connection only if there isn't one already in progress.
            if (isConnectionAttemptActive) {
                return
            }
            Log.i(TAG, "Scanning for devices.")
            // Reset mDidAttemptCompleteSuccessfully at the start of each connection attempt.
            didAttemptCompleteSuccessfully = false
            localMediaManager.registerCallback(mDeviceCallback)
            localMediaManager.startScan()
            connectSuggestedDeviceHandler.postDelayed(
                mConnectionAttemptFinishedRunnable,
                SCAN_DURATION_MS,
            )
        }
    private fun getDeviceByRouteId(
        mediaDevices: List<MediaDevice>?,
        routeId: String,
    ): MediaDevice? {
        return mediaDevices?.find { it.routeInfo?.id == routeId }
    }

    companion object {
        private const val TAG = "SuggestedDeviceConnectionManager"
        private const val SCAN_DURATION_MS = 10000L
        private val SCAN_TIMEOUT = 10.seconds
        private val CONNECTION_TIMEOUT = 20.seconds
    }
}
+22 −17
Original line number Diff line number Diff line
@@ -66,15 +66,6 @@ class SuggestedDeviceManager(
  @GuardedBy("lock") private var suggestedStateOverride: SuggestedDeviceState? = null
  @GuardedBy("lock") private var hideSuggestedDeviceState: Boolean = false

  init {
    if (useSuggestedDeviceConnectionManager()) {
      suggestedDeviceConnectionManager.setConnectionFinishedCallback { suggestedDeviceState, success
        ->
        onSuggestedDeviceConnectionFinished(suggestedDeviceState, success)
      }
    }
  }

  private val onSuggestedStateOverrideExpiredRunnable = Runnable {
    synchronized(lock) {
      if (suggestedStateOverride?.connectionState == STATE_CONNECTING_FAILED) {
@@ -148,6 +139,10 @@ class SuggestedDeviceManager(
    }
  }

  fun cancelAllRequests() {
    if (useSuggestedDeviceConnectionManager()) suggestedDeviceConnectionManager.cancel()
  }

  fun requestDeviceSuggestion() {
    localMediaManager.requestDeviceSuggestion()
    stopHidingSuggestedDeviceState()
@@ -182,16 +177,26 @@ class SuggestedDeviceManager(
      Log.w(TAG, "Suggestion got changed, aborting connection.")
      return
    }
    overrideSuggestedStateWithExpiration(
      connectionState = STATE_CONNECTING,
      timeoutMs = CONNECTING_TIMEOUT_MS,
    )
    if (useSuggestedDeviceConnectionManager()) {
      suggestedDeviceConnectionManager.connectSuggestedDevice(
      try {
        suggestedDeviceConnectionManager.connect(
          newSuggestedDeviceState,
          routingChangeInfo,
        ) { suggestedDeviceState, success ->
          onSuggestedDeviceConnectionFinished(suggestedDeviceState, success)
        }
        overrideSuggestedStateWithExpiration(
          connectionState = STATE_CONNECTING,
          timeoutMs = CONNECTING_TIMEOUT_MS,
        )
      } catch (e: IllegalStateException) {
        Log.e(TAG, "Connection already in progress", e)
      }
    } else {
      overrideSuggestedStateWithExpiration(
        connectionState = STATE_CONNECTING,
        timeoutMs = CONNECTING_TIMEOUT_MS,
      )
      localMediaManager.connectSuggestedDevice(newSuggestedDeviceState, routingChangeInfo)
    }
  }
+205 −29

File changed.

Preview size limit exceeded, changes collapsed.

+38 −13
Original line number Diff line number Diff line
@@ -42,6 +42,7 @@ import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.clearInvocations
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.stub
@@ -61,7 +62,6 @@ class SuggestedDeviceManagerTest {

  private var localMediaManager: LocalMediaManager = mock<LocalMediaManager>()
  private val suggestedDeviceConnectionManager = mock<SuggestedDeviceConnectionManager>()
  private var connectionFinishedCallback: ConnectionFinishedCallback? = null
  private var listener = mock<SuggestedDeviceManager.Listener>()
  private var listener2 = mock<SuggestedDeviceManager.Listener>()
  private lateinit var mSuggestedDeviceManager: SuggestedDeviceManager
@@ -96,9 +96,6 @@ class SuggestedDeviceManagerTest {
    val handler = Handler(Looper.getMainLooper())
    mSuggestedDeviceManager =
      SuggestedDeviceManager(localMediaManager, handler, suggestedDeviceConnectionManager)
    if (Flags.useSuggestedDeviceConnectionManager()) {
      connectionFinishedCallback = captureConnectionFinishedCallback()
    }
  }

  @Test
@@ -289,11 +286,14 @@ class SuggestedDeviceManagerTest {
    assertThat(mSuggestedDeviceManager.getSuggestedDevice()).isEqualTo(connectingSuggestedState)

    // Emulate connection failure and subsequently setting the override.
    if (Flags.useSuggestedDeviceConnectionManager()) {
      val connectionFinishedCallback = captureConnectionFinishedCallback()
      connectionFinishedCallback.invoke(initialSuggestedDeviceState, false)
    }
    deviceCallback.onConnectSuggestedDeviceFinished(
      initialSuggestedDeviceState,
      false,
    )
    connectionFinishedCallback?.invoke(initialSuggestedDeviceState, false)
    val failedSuggestedState = initialSuggestedDeviceState.copy(connectionState = STATE_CONNECTING_FAILED)
    verify(listener).onSuggestedDeviceStateUpdated(failedSuggestedState)
    clearInvocations(listener)
@@ -437,8 +437,16 @@ class SuggestedDeviceManagerTest {
      .onSuggestedDeviceStateUpdated(SuggestedDeviceState(suggestedDeviceInfo1, STATE_DISCONNECTED))

    val initialSuggestedDeviceState = SuggestedDeviceState(suggestedDeviceInfo1, STATE_DISCONNECTED)

    if (Flags.useSuggestedDeviceConnectionManager()) {
      mSuggestedDeviceManager.connectSuggestedDevice(initialSuggestedDeviceState, routingChangeInfo)
      val connectionFinishedCallback = captureConnectionFinishedCallback()
      connectionFinishedCallback.invoke(initialSuggestedDeviceState, false)
      verify(listener).onSuggestedDeviceStateUpdated(
        SuggestedDeviceState(suggestedDeviceInfo1, STATE_CONNECTING)
      )
    }
    deviceCallback.onConnectSuggestedDeviceFinished(initialSuggestedDeviceState, false)
    connectionFinishedCallback?.invoke(initialSuggestedDeviceState, false)

    verify(listener)
      .onSuggestedDeviceStateUpdated(
@@ -474,8 +482,13 @@ class SuggestedDeviceManagerTest {
      .onSuggestedDeviceStateUpdated(SuggestedDeviceState(suggestedDeviceInfo1, STATE_DISCONNECTED))

    val initialSuggestedDeviceState = SuggestedDeviceState(suggestedDeviceInfo1, STATE_DISCONNECTED)

    if (Flags.useSuggestedDeviceConnectionManager()) {
      mSuggestedDeviceManager.connectSuggestedDevice(initialSuggestedDeviceState, routingChangeInfo)
      val connectionFinishedCallback = captureConnectionFinishedCallback()
      connectionFinishedCallback.invoke(initialSuggestedDeviceState, false)
    }
    deviceCallback.onConnectSuggestedDeviceFinished(initialSuggestedDeviceState, false)
    connectionFinishedCallback?.invoke(initialSuggestedDeviceState, false)

    // dispatches STATE_CONNECTING_FAILED on failed attempt.
    verify(listener)
@@ -534,7 +547,11 @@ class SuggestedDeviceManagerTest {
    verify(listener).onSuggestedDeviceStateUpdated(connectingState)

    deviceCallback.onConnectSuggestedDeviceFinished(initialSuggestedDeviceState, false)
    connectionFinishedCallback?.invoke(initialSuggestedDeviceState, false)
    if (Flags.useSuggestedDeviceConnectionManager()) {
      val connectionFinishedCallback = captureConnectionFinishedCallback()
      connectionFinishedCallback.invoke(initialSuggestedDeviceState, false)
    }

    val failedState = initialSuggestedDeviceState.copy(connectionState = STATE_CONNECTING_FAILED)
    verify(listener).onSuggestedDeviceStateUpdated(failedState)
  }
@@ -557,11 +574,18 @@ class SuggestedDeviceManagerTest {
    // Simulate a failed connection first
    deviceCallback.onDeviceSuggestionsUpdated(listOf(suggestedDeviceInfo1))
    val initialSuggestedDeviceState = SuggestedDeviceState(suggestedDeviceInfo1, STATE_DISCONNECTED)

    if (Flags.useSuggestedDeviceConnectionManager()) {
      mSuggestedDeviceManager.connectSuggestedDevice(initialSuggestedDeviceState, routingChangeInfo)
      clearInvocations(listener) // Clear a call with STATE_CONNECTING
      val connectionFinishedCallback = captureConnectionFinishedCallback()
      connectionFinishedCallback.invoke(initialSuggestedDeviceState, false)
    }
    deviceCallback.onConnectSuggestedDeviceFinished(
      initialSuggestedDeviceState,
      false,
    ) // Simulate failure
    connectionFinishedCallback?.invoke(initialSuggestedDeviceState, false)

    val failedState = initialSuggestedDeviceState.copy(connectionState = STATE_CONNECTING_FAILED)
    verify(listener).onSuggestedDeviceStateUpdated(failedState)

@@ -592,8 +616,9 @@ class SuggestedDeviceManagerTest {
    mSuggestedDeviceManager.connectSuggestedDevice(currentSuggestedState, routingChangeInfo)

    if (Flags.useSuggestedDeviceConnectionManager()) {
      verify(suggestedDeviceConnectionManager)
        .connectSuggestedDevice(currentSuggestedState, routingChangeInfo)
      verify(suggestedDeviceConnectionManager).connect(
        eq(currentSuggestedState), eq(routingChangeInfo), any()
      )
    } else {
      verify(localMediaManager).connectSuggestedDevice(currentSuggestedState, routingChangeInfo)
    }
@@ -629,7 +654,7 @@ class SuggestedDeviceManagerTest {
    mSuggestedDeviceManager.connectSuggestedDevice(differentSuggestedState, routingChangeInfo)

    if (Flags.useSuggestedDeviceConnectionManager()) {
      verify(suggestedDeviceConnectionManager, never()).connectSuggestedDevice(any(), any())
      verify(suggestedDeviceConnectionManager, never()).connect(any(), any(), any())
    } else {
      verify(localMediaManager, never()).connectSuggestedDevice(any(), any())
    }
@@ -651,7 +676,7 @@ class SuggestedDeviceManagerTest {

  private fun captureConnectionFinishedCallback(): ConnectionFinishedCallback {
    val callbackCaptor = argumentCaptor<ConnectionFinishedCallback>()
    verify(suggestedDeviceConnectionManager).setConnectionFinishedCallback(callbackCaptor.capture())
    verify(suggestedDeviceConnectionManager).connect(any(), any(), callbackCaptor.capture())
    return callbackCaptor.firstValue
  }
}
+1 −0
Original line number Diff line number Diff line
@@ -290,6 +290,7 @@ constructor(
                    }
                    localMediaManager.unregisterCallback(this)
                    suggestedDeviceManager.removeListener(this)
                    suggestedDeviceManager.cancelAllRequests()
                    muteAwaitConnectionManager.stopListening()
                    configurationController.removeCallback(configListener)
                }
Loading