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

Commit bf57ec20 authored by Andre Le's avatar Andre Le
Browse files

CastDetailsView: Add more UI support for cast controller details view

- Make sure that the details view title and subtitle is updated
  correctly, based on whether we should show a chooser or controller UI.
- Add an icon and disconnect button for the cast controller UI.

Bug: 378514236
Flag: com.android.systemui.qs_tile_detailed_view
Test: CastDetailsViewModelTest, CastDetailsViewContentTest
Change-Id: Id3d616235b6635229b095191020cfb2cf3bbf7dd
parent 51b5fb54
Loading
Loading
Loading
Loading
+70 −12
Original line number Diff line number Diff line
@@ -16,7 +16,7 @@

package com.android.systemui.qs.tiles.dialog

import android.content.Context
import android.graphics.drawable.Drawable
import android.media.MediaRouter
import android.provider.Settings
import android.testing.TestableLooper.RunWithLooper
@@ -27,8 +27,10 @@ import com.android.systemui.SysuiTestCase
import com.android.systemui.qs.tiles.base.domain.actions.FakeQSTileIntentUserInputHandler
import com.android.systemui.qs.tiles.base.domain.actions.intentInputs
import com.google.common.truth.Truth.assertThat
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers.anyInt
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.mockito.kotlin.stub
@@ -38,13 +40,22 @@ import org.mockito.kotlin.stub
@RunWith(AndroidJUnit4::class)
class CastDetailsViewModelTest : SysuiTestCase() {
    var inputHandler: FakeQSTileIntentUserInputHandler = FakeQSTileIntentUserInputHandler()
    private var context: Context = mock()
    private var mediaRouter: MediaRouter = mock()
    private var selectedRoute: MediaRouter.RouteInfo = mock()
    private var mediaRouter: MediaRouter =
        mock<MediaRouter> { on { selectedRoute } doReturn selectedRoute }

    @Before
    fun SetUp() {
        // We need to set up a fake system service here since shouldShowChooserDialog access's
        // context system service, and we want to use the mocked selectedRoute to test this
        // function's behavior.
        context.addMockSystemService(MediaRouter::class.java, mediaRouter)
    }

    @Test
    fun testClickOnSettingsButton() {
        var viewModel = CastDetailsViewModel(inputHandler, context, MediaRouter.ROUTE_TYPE_REMOTE_DISPLAY)
        val viewModel =
            CastDetailsViewModel(inputHandler, context, MediaRouter.ROUTE_TYPE_REMOTE_DISPLAY)

        viewModel.clickOnSettingsButton()

@@ -56,14 +67,7 @@ class CastDetailsViewModelTest : SysuiTestCase() {

    @Test
    fun testShouldShowChooserDialog() {
        context.stub {
            on { getSystemService(MediaRouter::class.java) } doReturn mediaRouter
        }
        mediaRouter.stub {
            on { selectedRoute } doReturn selectedRoute
        }

        var viewModel =
        val viewModel =
            CastDetailsViewModel(inputHandler, context, MediaRouter.ROUTE_TYPE_REMOTE_DISPLAY)

        assertThat(viewModel.shouldShowChooserDialog())
@@ -74,4 +78,58 @@ class CastDetailsViewModelTest : SysuiTestCase() {
                )
            )
    }

    @Test
    fun shouldShowChooserDialogFalse_subTitleEmpty() {
        selectedRoute.stub {
            on { isDefault } doReturn false
            on { matchesTypes(anyInt()) } doReturn true
        }
        val viewModel =
            CastDetailsViewModel(inputHandler, context, MediaRouter.ROUTE_TYPE_REMOTE_DISPLAY)

        assertThat(viewModel.shouldShowChooserDialog()).isEqualTo(false)
        assertThat(viewModel.subTitle).isEqualTo("")
    }

    @Test
    fun shouldShowChooserDialogTrue_useDefaultSubTitle() {
        selectedRoute.stub { on { isDefault } doReturn true }
        val viewModel =
            CastDetailsViewModel(inputHandler, context, MediaRouter.ROUTE_TYPE_REMOTE_DISPLAY)

        assertThat(viewModel.shouldShowChooserDialog()).isEqualTo(true)
        assertThat(viewModel.subTitle).isEqualTo("Searching for devices...")
    }

    @Test
    fun shouldShowChooserDialogTrue_useDefaultTitle() {
        selectedRoute.stub { on { isDefault } doReturn true }
        val viewModel =
            CastDetailsViewModel(inputHandler, context, MediaRouter.ROUTE_TYPE_REMOTE_DISPLAY)

        assertThat(viewModel.shouldShowChooserDialog()).isEqualTo(true)
        assertThat(viewModel.title).isEqualTo("Cast screen to device")
    }

    @Test
    fun setMediaRouteDeviceTitle() {
        val viewModel =
            CastDetailsViewModel(inputHandler, context, MediaRouter.ROUTE_TYPE_REMOTE_DISPLAY)

        viewModel.setMediaRouteDeviceTitle("test")

        assertThat(viewModel.title).isEqualTo("test")
    }

    @Test
    fun setMediaRouteDeviceIcon() {
        val viewModel =
            CastDetailsViewModel(inputHandler, context, MediaRouter.ROUTE_TYPE_REMOTE_DISPLAY)
        val testIcon = mock<Drawable>()

        viewModel.setMediaRouteDeviceIcon(testIcon)

        assertThat(viewModel.deviceIcon).isEqualTo(testIcon)
    }
}
+36 −2
Original line number Diff line number Diff line
@@ -17,12 +17,19 @@
package com.android.systemui.qs.tiles.dialog

import android.view.LayoutInflater
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.viewinterop.AndroidView
import com.android.compose.ui.graphics.painter.rememberDrawablePainter
import com.android.internal.R
import com.android.internal.app.MediaRouteControllerContentManager

@@ -37,8 +44,24 @@ fun CastDetailsContent(castDetailsViewModel: CastDetailsViewModel) {
        castDetailsViewModel.createControllerContentManager()
    }

    Column(
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        Image(
            painter = rememberDrawablePainter(castDetailsViewModel.deviceIcon),
            // TODO(b/388321032): Replace this string with a string in a translatable xml file.
            contentDescription = "device icon",
        )
        CastControllerView(contentManager)
        CastControllerDisconnectButton(contentManager)
    }
}

@Composable
fun CastControllerView(contentManager: MediaRouteControllerContentManager) {
    AndroidView(
        modifier = Modifier.fillMaxWidth().fillMaxHeight(),
        modifier = Modifier.fillMaxWidth().testTag("CastControllerView"),
        factory = { context ->
            // Inflate with the existing dialog xml layout
            val view =
@@ -51,3 +74,14 @@ fun CastDetailsContent(castDetailsViewModel: CastDetailsViewModel) {
        onRelease = { contentManager.onDetachedFromWindow() },
    )
}

@Composable
fun CastControllerDisconnectButton(contentManager: MediaRouteControllerContentManager) {
    Button(
        onClick = { contentManager.onDisconnectButtonClick() },
        modifier = Modifier.fillMaxWidth(),
    ) {
        // TODO(b/388321032): Replace this string with a string in a translatable xml file.
        Text(text = "Disconnect")
    }
}
+17 −6
Original line number Diff line number Diff line
@@ -20,6 +20,9 @@ import android.content.Context
import android.content.Intent
import android.graphics.drawable.Drawable
import android.provider.Settings
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.android.internal.app.MediaRouteControllerContentManager
import com.android.internal.app.MediaRouteDialogPresenter
import com.android.systemui.plugins.qs.TileDetailsViewModel
@@ -36,6 +39,10 @@ constructor(
    @Assisted private val context: Context,
    @Assisted private val routeTypes: Int,
) : MediaRouteControllerContentManager.Delegate, TileDetailsViewModel {
    private var detailsViewTitle by mutableStateOf(DEFAULT_TITLE)
    private val detailsViewSubTitle = if (shouldShowChooserDialog()) DEFAULT_SUBTITLE else ""
    var deviceIcon: Drawable? by mutableStateOf(null)

    @AssistedFactory
    fun interface Factory {
        fun create(context: Context, routeTypes: Int): CastDetailsViewModel
@@ -56,23 +63,27 @@ constructor(
        )
    }

    // TODO(b/388321032): Replace this string with a string in a translatable xml file,
    override val title: String
        get() = "Cast screen to device"
        get() = detailsViewTitle

    // TODO(b/388321032): Replace this string with a string in a translatable xml file,
    override val subTitle: String
        get() = "Searching for devices..."
        get() = detailsViewSubTitle

    override fun setMediaRouteDeviceTitle(title: CharSequence?) {
        // TODO(b/378514236): Finish implementing this function.
        detailsViewTitle = title.toString()
    }

    override fun setMediaRouteDeviceIcon(icon: Drawable?) {
        // TODO(b/378514236): Finish implementing this function.
        deviceIcon = icon
    }

    override fun dismissView() {
        // TODO(b/378514236): Finish implementing this function.
    }

    companion object {
        // TODO(b/388321032): Replace this string with a string in a translatable xml file.
        const val DEFAULT_TITLE = "Cast screen to device"
        const val DEFAULT_SUBTITLE = "Searching for devices..."
    }
}
+79 −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.systemui.qs.tiles.dialog

import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.internal.app.MediaRouteControllerContentManager
import com.android.systemui.SysuiTestCase
import com.android.systemui.qs.tiles.impl.mediaroute.mediaRouteControllerContentManager
import com.android.systemui.testKosmos
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers.any
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.mockito.kotlin.stub
import org.mockito.kotlin.verify

@SmallTest
@RunWith(AndroidJUnit4::class)
class CastDetailsContentTest : SysuiTestCase() {
    @get:Rule val composeRule = createComposeRule()
    private val kosmos = testKosmos()
    private val controllerContentManager: MediaRouteControllerContentManager =
        kosmos.mediaRouteControllerContentManager
    private val viewModel: CastDetailsViewModel =
        mock<CastDetailsViewModel> {
            on { createControllerContentManager() } doReturn controllerContentManager
        }

    @Test
    fun shouldShowChooserDialogFalse_showControllerUI() {
        viewModel.stub { on { shouldShowChooserDialog() } doReturn false }

        composeRule.setContent { CastDetailsContent(viewModel) }
        composeRule.waitForIdle()

        composeRule.onNodeWithTag("CastControllerView").assertExists()
        composeRule.onNodeWithContentDescription("device icon").assertExists()
        composeRule.onNodeWithText("Disconnect").assertExists()

        verify(controllerContentManager).bindViews(any())
        verify(controllerContentManager).onAttachedToWindow()
    }

    @Test
    fun clickOnDisconnectButton_shouldCallDisconnect() {
        viewModel.stub { on { shouldShowChooserDialog() } doReturn false }

        composeRule.setContent { CastControllerDisconnectButton(controllerContentManager) }
        composeRule.waitForIdle()

        composeRule.onNodeWithText("Disconnect").assertIsDisplayed().performClick()
        composeRule.waitForIdle()

        verify(controllerContentManager).onDisconnectButtonClick()
    }
}
+24 −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.systemui.qs.tiles.impl.mediaroute

import com.android.internal.app.MediaRouteControllerContentManager
import com.android.systemui.kosmos.Kosmos
import org.mockito.kotlin.mock

val Kosmos.mediaRouteControllerContentManager: MediaRouteControllerContentManager by
    Kosmos.Fixture { mock<MediaRouteControllerContentManager>() }