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

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

Merge "Extract suggested device connection logic from LocalMediaManager." into main

parents d6990727 5d686cbd
Loading
Loading
Loading
Loading
+10 −0
Original line number Diff line number Diff line
@@ -162,6 +162,16 @@ flag {
    }
}

flag {
    name: "use_suggested_device_connection_manager"
    namespace: "media_better_together"
    description: "Use a separate class to manage the Suggested Device connection in Media Controls."
    bug: "442856281"
    metadata {
        purpose: PURPOSE_BUGFIX
    }
}

flag {
    name: "enable_prevention_of_keep_alive_route_providers"
    namespace: "media_solutions"
+175 −0
Original line number Diff line number Diff line
/*
 * Copyright 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.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
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

typealias ConnectionFinishedCallback = (SuggestedDeviceState, Boolean) -> Unit

/**
 * Controls the connection for a suggested device pill in Media Controls. Responsible to start the
 * route scan if the suggested device is not discovered yet.
 */
@OpenForTesting
open class SuggestedDeviceConnectionManager(
    val localMediaManager: LocalMediaManager,
    var connectSuggestedDeviceHandler: Handler,
) {
    /** 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

    /**
     * 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.
     */
    @OpenForTesting
    open fun connectSuggestedDevice(
        suggestion: SuggestedDeviceState,
        routingChangeInfo: RoutingChangeInfo,
    ) {
        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
                }
            }
            connectingSuggestedDeviceState =
                ConnectingSuggestedDeviceState(suggestion, routingChangeInfo.entryPoint).apply {
                    tryConnect()
                }
        }
    }

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

    inner class ConnectingSuggestedDeviceState(
        val suggestedDeviceState: SuggestedDeviceState,
        @RoutingChangeInfo.EntryPoint entryPoint: Int,
    ) {
        var isConnectionAttemptActive: Boolean = false
        var didAttemptCompleteSuccessfully: Boolean = false

        private val mDeviceCallback =
            object : LocalMediaManager.DeviceCallback {
                override fun onDeviceListUpdate(mediaDevices: List<MediaDevice>) {
                    synchronized(lock) {
                        for (mediaDevice in mediaDevices) {
                            if (isSuggestedDevice(mediaDevice)) {
                                Log.i(
                                    TAG,
                                    "Scan found matched device, connecting. deviceId = ${mediaDevice.id}",
                                )
                                localMediaManager.connectDevice(
                                    mediaDevice,
                                    RoutingChangeInfo(entryPoint, /* isSuggested= */ true),
                                )
                                isConnectionAttemptActive = true
                                break
                            }
                        }
                    }
                }

                override fun onSelectedDeviceStateChanged(
                    device: MediaDevice,
                    @MediaDeviceState state: Int,
                ) {
                    if (isSuggestedDevice(device) && (state == MediaDeviceState.STATE_CONNECTED)) {
                        if (
                            !connectSuggestedDeviceHandler.hasCallbacks(
                                mConnectionAttemptFinishedRunnable
                            )
                        ) {
                            return
                        }
                        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)
                }
            }

        val mConnectionAttemptFinishedRunnable: Runnable = Runnable {
            synchronized(lock) {
                connectingSuggestedDeviceState = null
                isConnectionAttemptActive = false
            }
            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,
            )
        }
    }

    companion object {
        private const val TAG = "SuggestedDeviceConnectionManager"
        private const val SCAN_DURATION_MS = 10000L
    }
}
+38 −11
Original line number Diff line number Diff line
@@ -21,6 +21,7 @@ import android.media.SuggestedDeviceInfo
import android.os.Handler
import android.util.Log
import androidx.annotation.GuardedBy
import com.android.media.flags.Flags.useSuggestedDeviceConnectionManager
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
@@ -49,6 +50,7 @@ private const val CONNECTING_FAILED_TIMEOUT_MS = 10_000L
class SuggestedDeviceManager(
  private val localMediaManager: LocalMediaManager,
  private val handler: Handler,
  private val suggestedDeviceConnectionManager: SuggestedDeviceConnectionManager,
) {
  private val lock: Any = Object()
  private val listeners = CopyOnWriteArraySet<Listener>()
@@ -60,6 +62,15 @@ class SuggestedDeviceManager(
  // when displaying error messages.
  @GuardedBy("lock") @MediaDeviceState private var connectionStateOverride: Int? = null

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

  private val onConnectionStateOverrideExpiredRunnable = Runnable {
    synchronized(lock) {
      if (connectionStateOverride == STATE_CONNECTING_FAILED) {
@@ -101,16 +112,9 @@ class SuggestedDeviceManager(
        newSuggestedDeviceState: SuggestedDeviceState,
        success: Boolean,
      ) {
        if (!isCurrentSuggestion(newSuggestedDeviceState.suggestedDeviceInfo)) {
          Log.w(TAG, "onConnectSuggestedDeviceFinished. Suggestion got changed.")
          return
        if (!useSuggestedDeviceConnectionManager()) {
          onSuggestedDeviceConnectionFinished(newSuggestedDeviceState, success)
        }
        if (!success) {
          overrideConnectionStateWithExpiration(
            connectionState = STATE_CONNECTING_FAILED,
            timeoutMs = CONNECTING_FAILED_TIMEOUT_MS,
          )
        } // On success, the state should automatically be updated by the MediaDevice state.
      }
    }

@@ -164,8 +168,31 @@ class SuggestedDeviceManager(
      connectionState = STATE_CONNECTING,
      timeoutMs = CONNECTING_TIMEOUT_MS,
    )
    if (useSuggestedDeviceConnectionManager()) {
      suggestedDeviceConnectionManager.connectSuggestedDevice(
        newSuggestedDeviceState,
        routingChangeInfo,
      )
    } else {
      localMediaManager.connectSuggestedDevice(newSuggestedDeviceState, routingChangeInfo)
    }
  }

  private fun onSuggestedDeviceConnectionFinished(
    newSuggestedDeviceState: SuggestedDeviceState,
    success: Boolean,
  ) {
    if (!isCurrentSuggestion(newSuggestedDeviceState.suggestedDeviceInfo)) {
      Log.w(TAG, "onSuggestedDeviceConnectionFinished. Suggestion got changed.")
      return
    }
    if (!success) {
      overrideConnectionStateWithExpiration(
        connectionState = STATE_CONNECTING_FAILED,
        timeoutMs = CONNECTING_FAILED_TIMEOUT_MS,
      )
    } // On success, the state should automatically be updated by the MediaDevice state.
  }

  private fun eagerlyUpdateState() {
    synchronized(lock) {
@@ -192,7 +219,7 @@ class SuggestedDeviceManager(
  @GuardedBy("lock")
  private fun calculateNewSuggestedDeviceStateLocked(
    newTopSuggestion: SuggestedDeviceInfo?,
    newMediaDevices: List<MediaDevice>
    newMediaDevices: List<MediaDevice>,
  ): SuggestedDeviceState? {
    if (newTopSuggestion == null) {
      clearConnectionStateOverrideLocked()
+187 −0
Original line number Diff line number Diff line
/*
 * 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.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.android.settingslib.media

import android.content.Context
import android.media.MediaRoute2Info
import android.media.MediaRoute2Info.TYPE_REMOTE_SPEAKER
import android.media.RoutingChangeInfo
import android.media.SuggestedDeviceInfo
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.stub
import org.mockito.kotlin.verify
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.shadows.ShadowLooper

@RunWith(RobolectricTestRunner::class)
class SuggestedDeviceConnectionManagerTest {
    private val callback = mock<ConnectionFinishedCallback>()
    private var localMediaManager: LocalMediaManager = mock<LocalMediaManager>()
    private val routeInfo1 =
        mock<MediaRoute2Info> {
            on { name } doReturn TEST_DEVICE_NAME_1
            on { id } doReturn TEST_DEVICE_ID_1
        }
    private val routeInfo2 =
        mock<MediaRoute2Info> {
            on { name } doReturn TEST_DEVICE_NAME_2
            on { id } doReturn TEST_DEVICE_ID_2
        }
    private val suggestedDeviceInfo1 =
        SuggestedDeviceInfo.Builder(TEST_DEVICE_NAME_1, TEST_DEVICE_ID_1, TYPE_REMOTE_SPEAKER)
            .build()
    private val suggestedDeviceInfo2 =
        SuggestedDeviceInfo.Builder(TEST_DEVICE_NAME_2, TEST_DEVICE_ID_2, TYPE_REMOTE_SPEAKER)
            .build()
    private lateinit var mediaDevice1: MediaDevice
    private lateinit var mediaDevice2: MediaDevice
    private lateinit var suggestedDeviceConnectionManager: SuggestedDeviceConnectionManager

    @Before
    fun setUp() {
        val context: Context = RuntimeEnvironment.getApplication()

        mediaDevice1 =
            InfoMediaDevice(
                context,
                routeInfo1,
                /* dynamicRouteAttributes= */ null,
                /* rlpItem= */ null,
            )

        mediaDevice2 =
            InfoMediaDevice(
                context,
                routeInfo2,
                /* dynamicRouteAttributes= */ null,
                /* rlpItem= */ null,
            )

        suggestedDeviceConnectionManager =
            SuggestedDeviceConnectionManager(localMediaManager, context.mainThreadHandler)
        suggestedDeviceConnectionManager.setConnectionFinishedCallback(callback)
    }

    @Test
    fun connectSuggestedDevice_deviceIsDiscovered_immediatelyConnects() {
        val suggestedDeviceState = SuggestedDeviceState(suggestedDeviceInfo1)
        val mediaDevices = listOf(mediaDevice1)
        localMediaManager.stub { on { getMediaDevices() } doReturn mediaDevices }
        suggestedDeviceConnectionManager.connectSuggestedDevice(
            suggestedDeviceState,
            ROUTING_CHANGE_INFO,
        )

        verify(localMediaManager).connectDevice(mediaDevice1, ROUTING_CHANGE_INFO)
        verify(localMediaManager, never()).startScan()
    }

    @Test
    fun connectSuggestedDevice_deviceIsNotDiscovered_scanStarted() {
        val suggestedDeviceState = SuggestedDeviceState(suggestedDeviceInfo2)
        val mediaDevices = listOf(mediaDevice1)
        localMediaManager.stub { on { getMediaDevices() } doReturn mediaDevices }
        suggestedDeviceConnectionManager.connectSuggestedDevice(
            suggestedDeviceState,
            ROUTING_CHANGE_INFO,
        )

        verify(localMediaManager).startScan()
        verify(localMediaManager, never()).connectDevice(mediaDevice1, ROUTING_CHANGE_INFO)
    }

    @Test
    fun connectSuggestedDevice_deviceDiscoveredAfter_connects() {
        val suggestedDeviceState = SuggestedDeviceState(suggestedDeviceInfo1)
        val mediaDevices = mutableListOf(mediaDevice2)
        localMediaManager.stub { on { getMediaDevices() } doReturn mediaDevices }
        suggestedDeviceConnectionManager.connectSuggestedDevice(
            suggestedDeviceState,
            ROUTING_CHANGE_INFO,
        )
        mediaDevices.add(mediaDevice1)
        captureDeviceCallback().onDeviceListUpdate(mediaDevices)

        verify(localMediaManager).startScan()
        verify(localMediaManager).connectDevice(mediaDevice1, ROUTING_CHANGE_INFO)
    }

    @Test
    fun connectSuggestedDevice_handlerTimesOut_completesConnectionAttempt() {
        val suggestedDeviceState = SuggestedDeviceState(suggestedDeviceInfo1)
        val mediaDevices = mutableListOf(mediaDevice2)
        localMediaManager.stub { on { getMediaDevices() } doReturn mediaDevices }
        suggestedDeviceConnectionManager.connectSuggestedDevice(
            suggestedDeviceState,
            ROUTING_CHANGE_INFO,
        )
        mediaDevices.add(mediaDevice1)
        captureDeviceCallback().onDeviceListUpdate(mediaDevices)

        verify(localMediaManager).connectDevice(mediaDevice1, ROUTING_CHANGE_INFO)

        ShadowLooper.runUiThreadTasksIncludingDelayedTasks()

        verify(callback).invoke(suggestedDeviceState, false)
    }

    @Test
    fun connectSuggestedDevice_connectionSuccess_completesConnectionAttempt() {
        val suggestedDeviceState = SuggestedDeviceState(suggestedDeviceInfo1)
        val mediaDevices = mutableListOf(mediaDevice2)
        localMediaManager.stub { on { getMediaDevices() } doReturn mediaDevices }
        suggestedDeviceConnectionManager.connectSuggestedDevice(
            suggestedDeviceState,
            ROUTING_CHANGE_INFO,
        )
        mediaDevices.add(mediaDevice1)
        val deviceCallback = captureDeviceCallback()
        deviceCallback.onDeviceListUpdate(mediaDevices)

        verify(localMediaManager).connectDevice(mediaDevice1, ROUTING_CHANGE_INFO)

        deviceCallback.onSelectedDeviceStateChanged(
            mediaDevice1,
            LocalMediaManager.MediaDeviceState.STATE_CONNECTED,
        )
        verify(callback).invoke(suggestedDeviceState, true)
    }

    private fun captureDeviceCallback(): LocalMediaManager.DeviceCallback {
        val callbackCaptor = argumentCaptor<LocalMediaManager.DeviceCallback>()
        verify(localMediaManager).registerCallback(callbackCaptor.capture())
        return callbackCaptor.firstValue
    }

    companion object {
        private const val TEST_DEVICE_NAME_1 = "device_name_1"
        private const val TEST_DEVICE_NAME_2 = "device_name_2"
        private const val TEST_DEVICE_ID_1 = "device_id_1"
        private const val TEST_DEVICE_ID_2 = "device_id_2"
        private val ROUTING_CHANGE_INFO =
            RoutingChangeInfo(
                RoutingChangeInfo.ENTRY_POINT_SYSTEM_MEDIA_CONTROLS,
                /* isSuggested= */ true,
            )
    }
}
+101 −6

File changed.

Preview size limit exceeded, changes collapsed.

Loading