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

Commit e2b3d6dc authored by Evan Laird's avatar Evan Laird
Browse files

[Sb refactor] WIP - InternetTileViewModel

This CL represents an implementation an InternetTileViewModel, which can
correctly calculate a state object (`InternetTileModel`) to fund a
repository-backed internet QS tile.

The `InternetTileBinder` will attach this model flow to the consumer
provided by the QS tile itself, which can then update its own itnernal
QS state object in its own handler.

Still WIP - needs a few more tests and an `areNetworksAvailable` flow
from the wifi stack. These will be implemented in a follow-up

Test: InternetTileViewModelTest
Bug: 291321279
Change-Id: Id2bd19da17e124cdf3b7c937f0fd28e158910960
parent a3c24fe0
Loading
Loading
Loading
Loading
+41 −16
Original line number Diff line number Diff line
@@ -33,6 +33,7 @@ import com.android.systemui.statusbar.pipeline.mobile.data.repository.UserSetupR
import com.android.systemui.statusbar.pipeline.shared.data.model.ConnectivitySlot
import com.android.systemui.statusbar.pipeline.shared.data.repository.ConnectivityRepository
import com.android.systemui.util.CarrierConfigTracker
import java.lang.ref.WeakReference
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -70,6 +71,12 @@ interface MobileIconsInteractor {
    /** True if the active mobile data subscription has data enabled */
    val activeDataConnectionHasDataEnabled: StateFlow<Boolean>

    /**
     * Flow providing a reference to the Interactor for the active data subId. This represents the
     * [MobileConnectionInteractor] responsible for the active data connection, if any.
     */
    val activeDataIconInteractor: StateFlow<MobileIconInteractor?>

    /** True if the RAT icon should always be displayed and false otherwise. */
    val alwaysShowDataRatIcon: StateFlow<Boolean>

@@ -96,9 +103,9 @@ interface MobileIconsInteractor {

    /**
     * Vends out a [MobileIconInteractor] tracking the [MobileConnectionRepository] for the given
     * subId. Will throw if the ID is invalid
     * subId.
     */
    fun createMobileConnectionInteractorForSubId(subId: Int): MobileIconInteractor
    fun getMobileConnectionInteractorForSubId(subId: Int): MobileIconInteractor
}

@Suppress("EXPERIMENTAL_IS_NOT_ENABLED")
@@ -116,6 +123,9 @@ constructor(
    private val context: Context,
) : MobileIconsInteractor {

    // Weak reference lookup for created interactors
    private val reuseCache = mutableMapOf<Int, WeakReference<MobileIconInteractor>>()

    override val mobileIsDefault =
        combine(
                mobileConnectionsRepo.mobileIsDefault,
@@ -138,6 +148,17 @@ constructor(
            .flatMapLatest { it?.dataEnabled ?: flowOf(false) }
            .stateIn(scope, SharingStarted.WhileSubscribed(), false)

    override val activeDataIconInteractor: StateFlow<MobileIconInteractor?> =
        mobileConnectionsRepo.activeMobileDataSubscriptionId
            .mapLatest {
                if (it != null) {
                    getMobileConnectionInteractorForSubId(it)
                } else {
                    null
                }
            }
            .stateIn(scope, SharingStarted.WhileSubscribed(), null)

    private val unfilteredSubscriptions: Flow<List<SubscriptionModel>> =
        mobileConnectionsRepo.subscriptions

@@ -306,7 +327,10 @@ constructor(
            .stateIn(scope, SharingStarted.WhileSubscribed(), false)

    /** Vends out new [MobileIconInteractor] for a particular subId */
    override fun createMobileConnectionInteractorForSubId(subId: Int): MobileIconInteractor =
    override fun getMobileConnectionInteractorForSubId(subId: Int): MobileIconInteractor =
        reuseCache[subId]?.get() ?: createMobileConnectionInteractorForSubId(subId)

    private fun createMobileConnectionInteractorForSubId(subId: Int): MobileIconInteractor =
        MobileIconInteractorImpl(
                scope,
                activeDataConnectionHasDataEnabled,
@@ -321,6 +345,7 @@ constructor(
                mobileConnectionsRepo.getRepoForSubId(subId),
                context,
            )
            .also { reuseCache[subId] = WeakReference(it) }

    companion object {
        private const val LOGGING_PREFIX = "Intr"
+2 −10
Original line number Diff line number Diff line
@@ -101,7 +101,7 @@ constructor(
        val common = commonViewModelForSub(subId)
        return LocationBasedMobileViewModel.viewModelForLocation(
            common,
            mobileIconInteractorForSub(subId),
            interactor.getMobileConnectionInteractorForSubId(subId),
            verboseLogger,
            location,
            scope,
@@ -112,7 +112,7 @@ constructor(
        return mobileIconSubIdCache[subId]
            ?: MobileIconViewModel(
                    subId,
                    mobileIconInteractorForSub(subId),
                    interactor.getMobileConnectionInteractorForSubId(subId),
                    airplaneModeInteractor,
                    constants,
                    scope,
@@ -120,14 +120,6 @@ constructor(
                .also { mobileIconSubIdCache[subId] = it }
    }

    @VisibleForTesting
    fun mobileIconInteractorForSub(subId: Int): MobileIconInteractor {
        return mobileIconInteractorSubIdCache[subId]
            ?: interactor.createMobileConnectionInteractorForSubId(subId).also {
                mobileIconInteractorSubIdCache[subId] = it
            }
    }

    private fun invalidateCaches(subIds: List<Int>) {
        val subIdsToRemove = mobileIconSubIdCache.keys.filter { !subIds.contains(it) }
        subIdsToRemove.forEach { mobileIconSubIdCache.remove(it) }
+42 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2023 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.statusbar.pipeline.shared.ui.binder

import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import androidx.lifecycle.repeatOnLifecycle
import com.android.systemui.statusbar.pipeline.shared.ui.model.InternetTileModel
import java.util.function.Consumer
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch

/**
 * Binds an [InternetTileModel] flow to a consumer for the internet tile to apply to its qs state
 */
object InternetTileBinder {
    fun bind(
        lifecycle: Lifecycle,
        tileModelFlow: StateFlow<InternetTileModel>,
        consumer: Consumer<InternetTileModel>
    ) {
        lifecycle.coroutineScope.launch {
            lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
                tileModelFlow.collect { consumer.accept(it) }
            }
        }
    }
}
+91 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2023 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.statusbar.pipeline.shared.ui.model

import android.content.Context
import android.graphics.drawable.Drawable
import android.service.quicksettings.Tile
import com.android.settingslib.graph.SignalDrawable
import com.android.systemui.common.shared.model.Text
import com.android.systemui.common.shared.model.Text.Companion.loadText
import com.android.systemui.plugins.qs.QSTile
import com.android.systemui.qs.tileimpl.QSTileImpl

/** Model describing the state that the QS Internet tile should be in. */
sealed interface InternetTileModel {
    val secondaryTitle: String?
    val secondaryLabel: Text?
    val iconId: Int?
    val icon: QSTile.Icon?

    fun applyTo(state: QSTile.SignalState, context: Context) {
        if (secondaryLabel != null) {
            state.secondaryLabel = secondaryLabel.loadText(context)
        } else {
            state.secondaryLabel = secondaryTitle
        }

        // inout indicators are unused
        state.activityIn = false
        state.activityOut = false

        // To support both SignalDrawable and other icons, give priority to icons over IDs
        if (icon != null) {
            state.icon = icon
        } else if (iconId != null) {
            state.icon = QSTileImpl.ResourceIcon.get(iconId!!)
        }

        state.state =
            if (this is Active) {
                Tile.STATE_ACTIVE
            } else {
                Tile.STATE_INACTIVE
            }
    }

    data class Active(
        override val secondaryTitle: String? = null,
        override val secondaryLabel: Text? = null,
        override val iconId: Int? = null,
        override val icon: QSTile.Icon? = null,
    ) : InternetTileModel

    data class Inactive(
        override val secondaryTitle: String? = null,
        override val secondaryLabel: Text? = null,
        override val iconId: Int? = null,
        override val icon: QSTile.Icon? = null,
    ) : InternetTileModel
}

/**
 * [QSTile.Icon]-compatible container class for us to marshal the compacted [SignalDrawable] state
 * across to the internet tile.
 */
data class SignalIcon(val state: Int) : QSTile.Icon() {

    override fun getDrawable(context: Context): Drawable {
        val d = SignalDrawable(context)
        d.setLevel(state)
        return d
    }

    override fun toString(): String {
        return String.format("SignalIcon[mState=0x%08x]", state)
    }
}
+219 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2023 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.statusbar.pipeline.shared.ui.viewmodel

import android.content.Context
import com.android.systemui.R
import com.android.systemui.common.shared.model.Text
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.qs.tileimpl.QSTileImpl
import com.android.systemui.qs.tileimpl.QSTileImpl.ResourceIcon
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.shared.data.repository.ConnectivityRepository
import com.android.systemui.statusbar.pipeline.shared.ui.model.InternetTileModel
import com.android.systemui.statusbar.pipeline.shared.ui.model.SignalIcon
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

/**
 * View model for the quick settings [InternetTile]. This model exposes mainly a single flow of
 * InternetTileModel objects, so that updating the tile is as simple as collecting on this state
 * flow and then calling [QSTileImpl.refreshState]
 */
@OptIn(ExperimentalCoroutinesApi::class)
@SysUISingleton
class InternetTileViewModel
@Inject
constructor(
    airplaneModeRepository: AirplaneModeRepository,
    connectivityRepository: ConnectivityRepository,
    ethernetInteractor: EthernetInteractor,
    mobileIconsInteractor: MobileIconsInteractor,
    wifiInteractor: WifiInteractor,
    private val context: Context,
    @Application scope: CoroutineScope,
) {
    // 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)
            if (it is WifiNetworkModel.Active && wifiIcon is WifiIcon.Visible) {
                flowOf(
                    InternetTileModel.Active(
                        secondaryTitle = removeDoubleQuotes(it.ssid),
                        icon = ResourceIcon.get(wifiIcon.icon.res)
                    )
                )
            } 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 ->
                    InternetTileModel.Active(
                        secondaryTitle =
                            mobileDataContentConcat(networkNameModel.name, dataContentDescription),
                        icon = SignalIcon(signalIcon.toSignalDrawableState()),
                    )
                }
            }
        }

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

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

    private fun loadString(resId: Int): String? =
        if (resId > 0) {
            context.getString(resId)
        } else {
            null
        }

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

    private val notConnectedFlow: StateFlow<InternetTileModel> =
        combine(
                wifiInteractor.areNetworksAvailable,
                airplaneModeRepository.isAirplaneMode,
            ) { networksAvailable, isAirplaneMode ->
                when {
                    isAirplaneMode -> {
                        InternetTileModel.Inactive(
                            secondaryTitle = context.getString(R.string.status_bar_airplane),
                            icon = ResourceIcon.get(R.drawable.ic_qs_no_internet_unavailable)
                        )
                    }
                    networksAvailable -> {
                        InternetTileModel.Inactive(
                            secondaryTitle =
                                context.getString(R.string.quick_settings_networks_available),
                            iconId = R.drawable.ic_qs_no_internet_available,
                        )
                    }
                    else -> {
                        NOT_CONNECTED_NETWORKS_UNAVAILABLE
                    }
                }
            }
            .stateIn(scope, SharingStarted.WhileSubscribed(), NOT_CONNECTED_NETWORKS_UNAVAILABLE)

    /**
     * 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)
     */
    private val activeModelProvider: Flow<InternetTileModel> =
        connectivityRepository.defaultConnections.flatMapLatest {
            when {
                it.ethernet.isDefault -> ethernetIconFlow
                it.mobile.isDefault || it.carrierMerged.isDefault -> mobileIconFlow
                it.wifi.isDefault -> wifiIconFlow
                else -> notConnectedFlow
            }
        }

    /** Consumable flow describing the correct state for the InternetTile */
    val tileModel: StateFlow<InternetTileModel> =
        activeModelProvider.stateIn(scope, SharingStarted.WhileSubscribed(), notConnectedFlow.value)

    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,
            )

        private fun removeDoubleQuotes(string: String?): String? {
            if (string == null) return null
            val length = string.length
            return if (length > 1 && string[0] == '"' && string[length - 1] == '"') {
                string.substring(1, length - 1)
            } else string
        }
    }
}
Loading