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

Commit 79b6f015 authored by Treehugger Robot's avatar Treehugger Robot Committed by Android (Google) Code Review
Browse files

Merge "Migrate internet tile" into main

parents 206cdbcf bb98327d
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