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

Commit 42d4e085 authored by Alexandr Shabalin's avatar Alexandr Shabalin Committed by Android (Google) Code Review
Browse files

Merge "Use Kotlin coroutines for SuggestedDeviceConnectionManager." into main

parents 879475f3 e45a5615
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