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

Commit bb98327d authored by Behnam Heydarshahi's avatar Behnam Heydarshahi
Browse files

Migrate internet tile

Flag: aconfig com.android.systemui.qs_new_tiles DEVELOPMENT
Fixes: 301056206
Test: atest SystemUiRoboTests
Test: atest InternetTileDataInteractorTest InternetTileUserActionInteractorTest InternetTileMapperTest

Change-Id: I92e2e033b37bf3d68a6ec91d29278519d6580658
parent a8177574
Loading
Loading
Loading
Loading
+128 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 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.internet.domain

import android.graphics.drawable.TestStubDrawable
import android.widget.Switch
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.common.shared.model.ContentDescription
import com.android.systemui.common.shared.model.Icon
import com.android.systemui.common.shared.model.Text
import com.android.systemui.kosmos.Kosmos
import com.android.systemui.qs.tiles.impl.custom.QSTileStateSubject
import com.android.systemui.qs.tiles.impl.internet.domain.model.InternetTileModel
import com.android.systemui.qs.tiles.impl.internet.qsInternetTileConfig
import com.android.systemui.qs.tiles.viewmodel.QSTileState
import com.android.systemui.res.R
import com.android.systemui.statusbar.connectivity.WifiIcons.WIFI_FULL_ICONS
import org.junit.Test
import org.junit.runner.RunWith

@SmallTest
@RunWith(AndroidJUnit4::class)
class InternetTileMapperTest : SysuiTestCase() {
    private val kosmos = Kosmos()
    private val internetTileConfig = kosmos.qsInternetTileConfig
    private val mapper by lazy {
        InternetTileMapper(
            context.orCreateTestableResources
                .apply {
                    addOverride(R.drawable.ic_qs_no_internet_unavailable, TestStubDrawable())
                    addOverride(wifiRes, TestStubDrawable())
                }
                .resources,
            context.theme,
            context
        )
    }

    @Test
    fun withActiveModel_mappedStateMatchesDataModel() {
        val inputModel =
            InternetTileModel.Active(
                secondaryLabel = Text.Resource(R.string.quick_settings_networks_available),
                iconId = wifiRes,
                stateDescription = null,
                contentDescription =
                    ContentDescription.Resource(R.string.quick_settings_internet_label),
            )

        val outputState = mapper.map(internetTileConfig, inputModel)

        val expectedState =
            createInternetTileState(
                QSTileState.ActivationState.ACTIVE,
                context.getString(R.string.quick_settings_networks_available),
                Icon.Loaded(context.getDrawable(wifiRes)!!, contentDescription = null),
                context.getString(R.string.quick_settings_internet_label)
            )
        QSTileStateSubject.assertThat(outputState).isEqualTo(expectedState)
    }

    @Test
    fun withInactiveModel_mappedStateMatchesDataModel() {
        val inputModel =
            InternetTileModel.Inactive(
                secondaryLabel = Text.Resource(R.string.quick_settings_networks_unavailable),
                iconId = R.drawable.ic_qs_no_internet_unavailable,
                stateDescription = null,
                contentDescription =
                    ContentDescription.Resource(R.string.quick_settings_networks_unavailable),
            )

        val outputState = mapper.map(internetTileConfig, inputModel)

        val expectedState =
            createInternetTileState(
                QSTileState.ActivationState.INACTIVE,
                context.getString(R.string.quick_settings_networks_unavailable),
                Icon.Loaded(
                    context.getDrawable(R.drawable.ic_qs_no_internet_unavailable)!!,
                    contentDescription = null
                ),
                context.getString(R.string.quick_settings_networks_unavailable)
            )
        QSTileStateSubject.assertThat(outputState).isEqualTo(expectedState)
    }

    private fun createInternetTileState(
        activationState: QSTileState.ActivationState,
        secondaryLabel: String,
        icon: Icon,
        contentDescription: String,
    ): QSTileState {
        val label = context.getString(R.string.quick_settings_internet_label)
        return QSTileState(
            { icon },
            label,
            activationState,
            secondaryLabel,
            setOf(QSTileState.UserAction.CLICK, QSTileState.UserAction.LONG_CLICK),
            contentDescription,
            null,
            QSTileState.SideViewIcon.Chevron,
            QSTileState.EnabledState.ENABLED,
            Switch::class.qualifiedName
        )
    }

    private companion object {
        val wifiRes = WIFI_FULL_ICONS[4]
    }
}
+548 −0

File added.

Preview size limit exceeded, changes collapsed.

+113 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 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.internet.domain.interactor

import android.platform.test.annotations.EnabledOnRavenwood
import android.provider.Settings
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.kosmos.Kosmos
import com.android.systemui.kosmos.testScope
import com.android.systemui.qs.tiles.base.actions.FakeQSTileIntentUserInputHandler
import com.android.systemui.qs.tiles.base.actions.QSTileIntentUserInputHandlerSubject
import com.android.systemui.qs.tiles.base.interactor.QSTileInputTestKtx
import com.android.systemui.qs.tiles.dialog.InternetDialogManager
import com.android.systemui.qs.tiles.impl.internet.domain.model.InternetTileModel
import com.android.systemui.statusbar.connectivity.AccessPointController
import com.android.systemui.util.mockito.mock
import com.android.systemui.util.mockito.nullable
import com.google.common.truth.Truth
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers.anyBoolean
import org.mockito.ArgumentMatchers.eq
import org.mockito.Mock
import org.mockito.Mockito.verify

@SmallTest
@EnabledOnRavenwood
@RunWith(AndroidJUnit4::class)
class InternetTileUserActionInteractorTest : SysuiTestCase() {
    private val kosmos = Kosmos()
    private val inputHandler = FakeQSTileIntentUserInputHandler()

    private lateinit var underTest: InternetTileUserActionInteractor

    @Mock private lateinit var internetDialogManager: InternetDialogManager
    @Mock private lateinit var controller: AccessPointController

    @Before
    fun setup() {
        internetDialogManager = mock<InternetDialogManager>()
        controller = mock<AccessPointController>()

        underTest =
            InternetTileUserActionInteractor(
                kosmos.testScope.coroutineContext,
                internetDialogManager,
                controller,
                inputHandler,
            )
    }

    @Test
    fun handleClickWhenActive() =
        kosmos.testScope.runTest {
            val input = InternetTileModel.Active()

            underTest.handleInput(QSTileInputTestKtx.click(input))

            verify(internetDialogManager).create(eq(true), anyBoolean(), anyBoolean(), nullable())
        }

    @Test
    fun handleClickWhenInactive() =
        kosmos.testScope.runTest {
            val input = InternetTileModel.Inactive()

            underTest.handleInput(QSTileInputTestKtx.click(input))

            verify(internetDialogManager).create(eq(true), anyBoolean(), anyBoolean(), nullable())
        }

    @Test
    fun handleLongClickWhenActive() =
        kosmos.testScope.runTest {
            val input = InternetTileModel.Active()

            underTest.handleInput(QSTileInputTestKtx.longClick(input))

            QSTileIntentUserInputHandlerSubject.assertThat(inputHandler).handledOneIntentInput {
                Truth.assertThat(it.intent.action).isEqualTo(Settings.ACTION_WIFI_SETTINGS)
            }
        }

    @Test
    fun handleLongClickWhenInactive() =
        kosmos.testScope.runTest {
            val input = InternetTileModel.Inactive()

            underTest.handleInput(QSTileInputTestKtx.longClick(input))

            QSTileIntentUserInputHandlerSubject.assertThat(inputHandler).handledOneIntentInput {
                Truth.assertThat(it.intent.action).isEqualTo(Settings.ACTION_WIFI_SETTINGS)
            }
        }
}
+76 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 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.internet.domain

import android.content.Context
import android.content.res.Resources
import android.widget.Switch
import com.android.systemui.common.shared.model.ContentDescription.Companion.loadContentDescription
import com.android.systemui.common.shared.model.Icon
import com.android.systemui.common.shared.model.Text.Companion.loadText
import com.android.systemui.dagger.qualifiers.Main
import com.android.systemui.qs.tiles.base.interactor.QSTileDataToStateMapper
import com.android.systemui.qs.tiles.impl.internet.domain.model.InternetTileModel
import com.android.systemui.qs.tiles.viewmodel.QSTileConfig
import com.android.systemui.qs.tiles.viewmodel.QSTileState
import com.android.systemui.res.R
import javax.inject.Inject

/** Maps [InternetTileModel] to [QSTileState]. */
class InternetTileMapper
@Inject
constructor(
    @Main private val resources: Resources,
    private val theme: Resources.Theme,
    private val context: Context,
) : QSTileDataToStateMapper<InternetTileModel> {

    override fun map(config: QSTileConfig, data: InternetTileModel): QSTileState =
        QSTileState.build(resources, theme, config.uiConfig) {
            label = resources.getString(R.string.quick_settings_internet_label)
            expandedAccessibilityClass = Switch::class

            if (data.secondaryLabel != null) {
                secondaryLabel = data.secondaryLabel.loadText(context)
            } else {
                secondaryLabel = data.secondaryTitle
            }

            stateDescription = data.stateDescription.loadContentDescription(context)
            contentDescription = data.contentDescription.loadContentDescription(context)

            if (data.icon != null) {
                this.icon = { data.icon }
            } else if (data.iconId != null) {
                val loadedIcon =
                    Icon.Loaded(
                        resources.getDrawable(data.iconId!!, theme),
                        contentDescription = null
                    )
                this.icon = { loadedIcon }
            }

            sideViewIcon = QSTileState.SideViewIcon.Chevron

            activationState =
                if (data is InternetTileModel.Active) QSTileState.ActivationState.ACTIVE
                else QSTileState.ActivationState.INACTIVE

            supportedActions =
                setOf(QSTileState.UserAction.CLICK, QSTileState.UserAction.LONG_CLICK)
        }
}
+275 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 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.internet.domain.interactor

import android.annotation.StringRes
import android.content.Context
import android.os.UserHandle
import android.text.Html
import com.android.settingslib.graph.SignalDrawable
import com.android.systemui.common.shared.model.ContentDescription
import com.android.systemui.common.shared.model.ContentDescription.Companion.loadContentDescription
import com.android.systemui.common.shared.model.Icon
import com.android.systemui.common.shared.model.Text
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.qs.tiles.base.interactor.DataUpdateTrigger
import com.android.systemui.qs.tiles.base.interactor.QSTileDataInteractor
import com.android.systemui.qs.tiles.impl.internet.domain.model.InternetTileModel
import com.android.systemui.res.R
import com.android.systemui.statusbar.pipeline.airplane.data.repository.AirplaneModeRepository
import com.android.systemui.statusbar.pipeline.ethernet.domain.EthernetInteractor
import com.android.systemui.statusbar.pipeline.mobile.domain.interactor.MobileIconsInteractor
import com.android.systemui.statusbar.pipeline.mobile.domain.model.SignalIconModel
import com.android.systemui.statusbar.pipeline.shared.data.repository.ConnectivityRepository
import com.android.systemui.statusbar.pipeline.wifi.domain.interactor.WifiInteractor
import com.android.systemui.statusbar.pipeline.wifi.shared.model.WifiNetworkModel
import com.android.systemui.statusbar.pipeline.wifi.ui.model.WifiIcon
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn

@OptIn(ExperimentalCoroutinesApi::class)
/** Observes internet state changes providing the [InternetTileModel]. */
class InternetTileDataInteractor
@Inject
constructor(
    private val context: Context,
    @Application private val scope: CoroutineScope,
    airplaneModeRepository: AirplaneModeRepository,
    private val connectivityRepository: ConnectivityRepository,
    ethernetInteractor: EthernetInteractor,
    mobileIconsInteractor: MobileIconsInteractor,
    wifiInteractor: WifiInteractor,
) : QSTileDataInteractor<InternetTileModel> {
    private val internetLabel: String = context.getString(R.string.quick_settings_internet_label)

    // Three symmetrical Flows that can be switched upon based on the value of
    // [DefaultConnectionModel]
    private val wifiIconFlow: Flow<InternetTileModel> =
        wifiInteractor.wifiNetwork.flatMapLatest {
            val wifiIcon = WifiIcon.fromModel(it, context, showHotspotInfo = true)
            if (it is WifiNetworkModel.Active && wifiIcon is WifiIcon.Visible) {
                val secondary = removeDoubleQuotes(it.ssid)
                flowOf(
                    InternetTileModel.Active(
                        secondaryTitle = secondary,
                        icon = Icon.Loaded(context.getDrawable(wifiIcon.icon.res)!!, null),
                        stateDescription = wifiIcon.contentDescription,
                        contentDescription = ContentDescription.Loaded("$internetLabel,$secondary"),
                    )
                )
            } else {
                notConnectedFlow
            }
        }

    private val mobileDataContentName: Flow<CharSequence?> =
        mobileIconsInteractor.activeDataIconInteractor.flatMapLatest {
            if (it == null) {
                flowOf(null)
            } else {
                combine(it.isRoaming, it.networkTypeIconGroup) { isRoaming, networkTypeIconGroup ->
                    val cd = loadString(networkTypeIconGroup.contentDescription)
                    if (isRoaming) {
                        val roaming = context.getString(R.string.data_connection_roaming)
                        if (cd != null) {
                            context.getString(R.string.mobile_data_text_format, roaming, cd)
                        } else {
                            roaming
                        }
                    } else {
                        cd
                    }
                }
            }
        }

    private val mobileIconFlow: Flow<InternetTileModel> =
        mobileIconsInteractor.activeDataIconInteractor.flatMapLatest {
            if (it == null) {
                notConnectedFlow
            } else {
                combine(
                    it.networkName,
                    it.signalLevelIcon,
                    mobileDataContentName,
                ) { networkNameModel, signalIcon, dataContentDescription ->
                    when (signalIcon) {
                        is SignalIconModel.Cellular -> {
                            val secondary =
                                mobileDataContentConcat(
                                    networkNameModel.name,
                                    dataContentDescription
                                )

                            val stateLevel = signalIcon.level
                            val drawable = SignalDrawable(context)
                            drawable.setLevel(stateLevel)
                            val loadedIcon = Icon.Loaded(drawable, null)

                            InternetTileModel.Active(
                                secondaryTitle = secondary,
                                icon = loadedIcon,
                                stateDescription = ContentDescription.Loaded(secondary.toString()),
                                contentDescription = ContentDescription.Loaded(internetLabel),
                            )
                        }
                        is SignalIconModel.Satellite -> {
                            val secondary =
                                signalIcon.icon.contentDescription.loadContentDescription(context)
                            InternetTileModel.Active(
                                secondaryTitle = secondary,
                                iconId = signalIcon.icon.res,
                                stateDescription = ContentDescription.Loaded(secondary),
                                contentDescription = ContentDescription.Loaded(internetLabel),
                            )
                        }
                    }
                }
            }
        }

    private fun mobileDataContentConcat(
        networkName: String?,
        dataContentDescription: CharSequence?
    ): CharSequence {
        if (dataContentDescription == null) {
            return networkName ?: ""
        }
        if (networkName == null) {
            return Html.fromHtml(dataContentDescription.toString(), 0)
        }

        return Html.fromHtml(
            context.getString(
                R.string.mobile_carrier_text_format,
                networkName,
                dataContentDescription
            ),
            0
        )
    }

    private fun loadString(@StringRes resId: Int): CharSequence? =
        if (resId != 0) {
            context.getString(resId)
        } else {
            null
        }

    private val ethernetIconFlow: Flow<InternetTileModel> =
        ethernetInteractor.icon.flatMapLatest {
            if (it == null) {
                notConnectedFlow
            } else {
                val secondary = it.contentDescription
                flowOf(
                    InternetTileModel.Active(
                        secondaryLabel = secondary?.toText(),
                        iconId = it.res,
                        stateDescription = null,
                        contentDescription = secondary,
                    )
                )
            }
        }

    private val notConnectedFlow: StateFlow<InternetTileModel> =
        combine(
                wifiInteractor.areNetworksAvailable,
                airplaneModeRepository.isAirplaneMode,
            ) { networksAvailable, isAirplaneMode ->
                when {
                    isAirplaneMode -> {
                        val secondary = context.getString(R.string.status_bar_airplane)
                        InternetTileModel.Inactive(
                            secondaryTitle = secondary,
                            iconId = R.drawable.ic_qs_no_internet_unavailable,
                            stateDescription = null,
                            contentDescription = ContentDescription.Loaded(secondary),
                        )
                    }
                    networksAvailable -> {
                        val secondary =
                            context.getString(R.string.quick_settings_networks_available)
                        InternetTileModel.Inactive(
                            secondaryTitle = secondary,
                            iconId = R.drawable.ic_qs_no_internet_available,
                            stateDescription = null,
                            contentDescription =
                                ContentDescription.Loaded("$internetLabel,$secondary")
                        )
                    }
                    else -> {
                        NOT_CONNECTED_NETWORKS_UNAVAILABLE
                    }
                }
            }
            .stateIn(scope, SharingStarted.WhileSubscribed(), NOT_CONNECTED_NETWORKS_UNAVAILABLE)

    /**
     * Consumable flow describing the correct state for the InternetTile.
     *
     * Strict ordering of which repo is sending its data to the internet tile. Swaps between each of
     * the interim providers (wifi, mobile, ethernet, or not-connected).
     */
    override fun tileData(
        user: UserHandle,
        triggers: Flow<DataUpdateTrigger>
    ): Flow<InternetTileModel> =
        connectivityRepository.defaultConnections.flatMapLatest {
            when {
                it.ethernet.isDefault -> ethernetIconFlow
                it.mobile.isDefault || it.carrierMerged.isDefault -> mobileIconFlow
                it.wifi.isDefault -> wifiIconFlow
                else -> notConnectedFlow
            }
        }

    override fun availability(user: UserHandle): Flow<Boolean> = flowOf(true)

    private companion object {
        val NOT_CONNECTED_NETWORKS_UNAVAILABLE =
            InternetTileModel.Inactive(
                secondaryLabel = Text.Resource(R.string.quick_settings_networks_unavailable),
                iconId = R.drawable.ic_qs_no_internet_unavailable,
                stateDescription = null,
                contentDescription =
                    ContentDescription.Resource(R.string.quick_settings_networks_unavailable),
            )

        fun removeDoubleQuotes(string: String?): String? {
            if (string == null) return null
            return if (string.firstOrNull() == '"' && string.lastOrNull() == '"') {
                string.substring(1, string.length - 1)
            } else string
        }

        fun ContentDescription.toText(): Text =
            when (this) {
                is ContentDescription.Loaded -> Text.Loaded(this.description)
                is ContentDescription.Resource -> Text.Resource(this.res)
            }
    }
}
Loading