Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ class HomeActivity :
}
}
launch { mainViewModel.entityRegistryUpdates() }
if (!mainViewModel.isFavoritesOnly) {
if (!mainViewModel.isFavoritesOnly.value) {
launch { mainViewModel.areaUpdates() }
launch { mainViewModel.deviceUpdates() }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,9 @@ import android.app.Application
import android.content.ComponentName
import android.content.pm.PackageManager
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.toMutableStateList
import androidx.core.app.NotificationManagerCompat
Expand Down Expand Up @@ -66,6 +64,33 @@ class MainViewModel @Inject constructor(
ERROR,
}

/**
* Holds entity classification information for filtering entities in the UI.
*/
data class EntityClassification(
val entitiesWithoutArea: Set<String> = emptySet(),
val entitiesWithCategory: Set<String> = emptySet(),
val entitiesHidden: Set<String> = emptySet(),
val hasAreasToShow: Boolean = false,
val hasMoreEntitiesToShow: Boolean = false,
)

/**
* Immutable UI state for MainView that contains thread-safe snapshots of all data.
*/
data class MainViewUiState(
val entities: Map<String, Entity> = emptyMap(),
val favoriteCaches: List<FavoriteCaches> = emptyList(),
val isFavoritesOnly: Boolean = false,
val loadingState: LoadingState = LoadingState.LOADING,
val entitiesByAreaOrder: List<String> = emptyList(),
val entitiesByArea: Map<String, List<Entity>> = emptyMap(),
val areas: List<AreaRegistryResponse> = emptyList(),
val entitiesByDomainFilteredOrder: List<String> = emptyList(),
val entitiesByDomainFiltered: Map<String, List<Entity>> = emptyMap(),
val entitiesByDomain: Map<String, List<Entity>> = emptyMap(),
)

private val app = application

private lateinit var homePresenter: HomePresenter
Expand All @@ -87,6 +112,12 @@ class MainViewModel @Inject constructor(
private val _supportedEntities = MutableStateFlow(emptyList<String>())
val supportedEntities = _supportedEntities.asStateFlow()

private val _entityClassification = MutableStateFlow(EntityClassification())
val entityClassification = _entityClassification.asStateFlow()

private val _mainViewUiState = MutableStateFlow(MainViewUiState())
val mainViewUiState = _mainViewUiState.asStateFlow()

/**
* IDs of favorites in the Favorites database.
*/
Expand Down Expand Up @@ -116,6 +147,15 @@ class MainViewModel @Inject constructor(
var entitiesByDomainOrder = mutableStateListOf<String>()
private set

/**
* Filtered entities by domain - only entities without area, category, or hidden status.
* Used for the "More Entities" section in the UI.
*/
var entitiesByDomainFiltered = mutableStateMapOf<String, SnapshotStateList<Entity>>()
private set
var entitiesByDomainFilteredOrder = mutableStateListOf<String>()
private set

// Content of EntityListView
var entityLists = mutableStateMapOf<String, List<Entity>>()
var entityListsOrder = mutableStateListOf<String>()
Expand All @@ -132,11 +172,11 @@ class MainViewModel @Inject constructor(
private set
var templateTiles = mutableStateMapOf<Int, TemplateTileConfig>()
private set
var isFavoritesOnly by mutableStateOf(false)
var isFavoritesOnly = mutableStateOf(false)
private set
var isAssistantAppAllowed by mutableStateOf(true)
var isAssistantAppAllowed = mutableStateOf(true)
private set
var areNotificationsAllowed by mutableStateOf(false)
var areNotificationsAllowed = mutableStateOf(false)
private set

init {
Expand Down Expand Up @@ -165,13 +205,13 @@ class MainViewModel @Inject constructor(
isShowShortcutTextEnabled.value = homePresenter.getShowShortcutText()
templateTiles.clear()
templateTiles.putAll(homePresenter.getAllTemplateTiles())
isFavoritesOnly = homePresenter.getWearFavoritesOnly()
isFavoritesOnly.value = homePresenter.getWearFavoritesOnly()

val assistantAppComponent = ComponentName(
BuildConfig.APPLICATION_ID,
"io.homeassistant.companion.android.conversation.AssistantActivity",
)
isAssistantAppAllowed =
isAssistantAppAllowed.value =
app.packageManager.getComponentEnabledSetting(assistantAppComponent) !=
PackageManager.COMPONENT_ENABLED_STATE_DISABLED

Expand Down Expand Up @@ -215,9 +255,11 @@ class MainViewModel @Inject constructor(
} else {
LoadingState.ERROR
}
updateMainViewUiState()
} catch (e: Exception) {
Timber.e(e, "Exception while loading entities")
loadingState.value = LoadingState.ERROR
updateMainViewUiState()
}
}
}
Expand All @@ -239,7 +281,7 @@ class MainViewModel @Inject constructor(
val getEntityRegistry = async { homePresenter.getEntityRegistry() }
val getEntities = async { homePresenter.getEntities() }

if (!isFavoritesOnly) {
if (!isFavoritesOnly.value) {
areaRegistry = getAreaRegistry.await()?.also {
areas.clear()
areas.addAll(it)
Expand All @@ -260,8 +302,10 @@ class MainViewModel @Inject constructor(
val climateEntities = it.filter { entity -> entity.domain == "climate" }
climateEntitiesMap["climate"] = mutableStateListOf<Entity>().apply { addAll(climateEntities) }
}
if (!isFavoritesOnly) {
if (!isFavoritesOnly.value) {
updateEntityDomains()
} else {
updateMainViewUiState()
}
}

Expand All @@ -271,14 +315,14 @@ class MainViewModel @Inject constructor(
}
homePresenter.getEntityUpdates(supportedEntities.value)?.collect {
updateEntityStates(it)
if (!isFavoritesOnly) {
if (!isFavoritesOnly.value) {
updateEntityDomains()
}
}
}

suspend fun areaUpdates() {
if (!homePresenter.isConnected() || isFavoritesOnly) {
if (!homePresenter.isConnected() || isFavoritesOnly.value) {
return
}
homePresenter.getAreaRegistryUpdates()?.throttleLatest(1000)?.collect {
Expand All @@ -292,7 +336,7 @@ class MainViewModel @Inject constructor(
}

suspend fun deviceUpdates() {
if (!homePresenter.isConnected() || isFavoritesOnly) {
if (!homePresenter.isConnected() || isFavoritesOnly.value) {
return
}
homePresenter.getDeviceRegistryUpdates()?.throttleLatest(1000)?.collect {
Expand All @@ -317,48 +361,174 @@ class MainViewModel @Inject constructor(
.map { it.entityId }
.filter { it.split(".")[0] in supportedDomains() }

private fun updateEntityDomains() {
/**
* Updates the main view UI state with thread-safe snapshots of all data.
* This should be called on a background thread whenever state changes.
*/
private fun updateMainViewUiState() {
Timber.e("Hello doing an update? ")
_mainViewUiState.value = MainViewUiState(
entities = entities.toMap(),
favoriteCaches = favoriteCaches.toList(),
isFavoritesOnly = isFavoritesOnly.value,
loadingState = loadingState.value,
entitiesByAreaOrder = entitiesByAreaOrder.toList(),
entitiesByArea = entitiesByArea.mapValues { it.value.toList() },
areas = areas.toList(),
entitiesByDomainFilteredOrder = entitiesByDomainFilteredOrder.toList(),
entitiesByDomainFiltered = entitiesByDomainFiltered.mapValues { it.value.toList() },
entitiesByDomain = entitiesByDomain.mapValues { it.value.toList() },
)
}

/**
* This function does a lot of manipulation and could take some time so we need
* to make sure it doesn't happen in the Main thread.
*/
private suspend fun updateEntityDomains() = withContext(Dispatchers.Default) {
val entitiesList = entities.values.toList().sortedBy { it.entityId }
val areasList = areaRegistry.orEmpty().sortedBy { it.name }
val domainsList = entitiesList.map { it.domain }.distinct()
val validAreaIds = areasList.map { it.areaId }.toSet()

// Single pass: compute entity metadata and cache area lookups to avoid redundant calls
val entityAreaMap = mutableMapOf<String, AreaRegistryResponse?>()
val withoutArea = mutableSetOf<String>()
val withCategory = mutableSetOf<String>()
val hidden = mutableSetOf<String>()

entities.keys.forEach { entityId ->
val area = getAreaForEntity(entityId)
entityAreaMap[entityId] = area

if (area == null) {
withoutArea.add(entityId)
}
if (getCategoryForEntity(entityId) != null) {
withCategory.add(entityId)
}
if (getHiddenByForEntity(entityId) != null) {
hidden.add(entityId)
}
}

// Determine if entity should be shown in filtered views
val shouldShowEntity: (String) -> Boolean = { entityId ->
entityId !in withCategory && entityId !in hidden
}

// Group entities by area using cached area lookups
updateEntitiesByArea(areasList, entitiesList, entityAreaMap)

// Remove areas that no longer exist
entitiesByArea.keys.toList().forEach { areaId ->
if (areaId !in validAreaIds) {
entitiesByArea.remove(areaId)
}
}

// Create a list with all areas + their entities
// Group entities by domain (both full and filtered) in a single pass
updateEntitiesByDomain(domainsList, entitiesList, withoutArea, withCategory, hidden)

// Compute UI visibility flags
val hasAreasToShow = entitiesByArea.values.any { areaEntities ->
areaEntities.any { entity -> shouldShowEntity(entity.entityId) }
}

val hasMoreEntitiesToShow = withoutArea.any(shouldShowEntity)

// Update entity classification with all computed values
_entityClassification.value = EntityClassification(
entitiesWithoutArea = withoutArea,
entitiesWithCategory = withCategory,
entitiesHidden = hidden,
hasAreasToShow = hasAreasToShow,
hasMoreEntitiesToShow = hasMoreEntitiesToShow,
)

// Update the main view UI state with snapshots
updateMainViewUiState()
}

/**
* Updates the entities grouped by area.
*/
private fun updateEntitiesByArea(
areasList: List<AreaRegistryResponse>,
entitiesList: List<Entity>,
entityAreaMap: Map<String, AreaRegistryResponse?>,
) {
areasList.forEach { area ->
val entitiesInArea = mutableStateListOf<Entity>()
entitiesInArea.addAll(
entitiesList
.filter { getAreaForEntity(it.entityId)?.areaId == area.areaId }
.sortedBy { (it.attributes["friendly_name"] ?: it.entityId) as String },
)
val entitiesInArea = entitiesList
.filter { entityAreaMap[it.entityId]?.areaId == area.areaId }
.sortedBy { (it.attributes["friendly_name"] ?: it.entityId) as String }

entitiesByArea[area.areaId]?.let {
it.clear()
it.addAll(entitiesInArea)
} ?: run {
entitiesByArea[area.areaId] = entitiesInArea
entitiesByArea[area.areaId] = mutableStateListOf<Entity>().apply { addAll(entitiesInArea) }
}
}

entitiesByAreaOrder.clear()
entitiesByAreaOrder.addAll(areasList.map { it.areaId })
// Quick check: are there any areas in the list that no longer exist?
entitiesByArea.forEach {
if (!areasList.any { item -> item.areaId == it.key }) {
entitiesByArea.remove(it.key)
}
}
}

/**
* Updates entities grouped by domain (both full and filtered).
*/
private fun updateEntitiesByDomain(
domainsList: List<String>,
entitiesList: List<Entity>,
withoutArea: Set<String>,
withCategory: Set<String>,
hidden: Set<String>,
) {
val filteredDomainsList = mutableListOf<String>()

// Create a list with all discovered domains + their entities
domainsList.forEach { domain ->
val entitiesInDomain = mutableStateListOf<Entity>()
entitiesInDomain.addAll(entitiesList.filter { it.domain == domain })
// All entities in domain
val entitiesInDomain = entitiesList.filter { it.domain == domain }

entitiesByDomain[domain]?.let {
it.clear()
it.addAll(entitiesInDomain)
} ?: run {
entitiesByDomain[domain] = entitiesInDomain
entitiesByDomain[domain] = mutableStateListOf<Entity>().apply { addAll(entitiesInDomain) }
}

// Filtered entities (without area, category, or hidden status)
val entitiesInDomainFiltered = entitiesInDomain.filter { entity ->
entity.entityId in withoutArea &&
entity.entityId !in withCategory &&
entity.entityId !in hidden
}

if (entitiesInDomainFiltered.isNotEmpty()) {
filteredDomainsList.add(domain)
entitiesByDomainFiltered[domain]?.let {
it.clear()
it.addAll(entitiesInDomainFiltered)
} ?: run {
entitiesByDomainFiltered[domain] =
mutableStateListOf<Entity>().apply { addAll(entitiesInDomainFiltered) }
}
}
}

entitiesByDomainOrder.clear()
entitiesByDomainOrder.addAll(domainsList)

// Remove domains that no longer have filtered entities
entitiesByDomainFiltered.keys.toList().forEach { domain ->
if (domain !in filteredDomainsList) {
entitiesByDomainFiltered.remove(domain)
}
}

entitiesByDomainFilteredOrder.clear()
entitiesByDomainFilteredOrder.addAll(filteredDomainsList)
}

fun toggleEntity(entityId: String, state: String) {
Expand Down Expand Up @@ -528,7 +698,7 @@ class MainViewModel @Inject constructor(
fun setWearFavoritesOnly(enabled: Boolean) {
viewModelScope.launch {
homePresenter.setWearFavoritesOnly(enabled)
isFavoritesOnly = enabled
isFavoritesOnly.value = enabled
}
}

Expand All @@ -553,7 +723,7 @@ class MainViewModel @Inject constructor(
favoritesDao.delete(entityId)
favoriteCachesDao.delete(entityId)

if (favoritesDao.getAll().isEmpty() && isFavoritesOnly) {
if (favoritesDao.getAll().isEmpty() && isFavoritesOnly.value) {
setWearFavoritesOnly(false)
}
}
Expand Down Expand Up @@ -583,11 +753,11 @@ class MainViewModel @Inject constructor(
},
PackageManager.DONT_KILL_APP,
)
isAssistantAppAllowed = allowed
isAssistantAppAllowed.value = allowed
}

fun refreshNotificationPermission() {
areNotificationsAllowed = NotificationManagerCompat.from(app).areNotificationsEnabled()
areNotificationsAllowed.value = NotificationManagerCompat.from(app).areNotificationsEnabled()
}

fun logout() {
Expand Down
Loading
Loading