Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
2657805
Add Favorite on android automotive (#1)
cddu33 Apr 15, 2026
65f3651
clean
cddu33 Apr 15, 2026
b9f75c1
improve detection car park
cddu33 Apr 15, 2026
ceebc75
correct favorite visible
cddu33 Apr 16, 2026
03b9cc0
beta publication playstore
cddu33 Apr 16, 2026
1e42075
Merge branch 'main' into feature/add-favorite-automotive
cddu33 Apr 16, 2026
251f8a2
Update app/src/main/kotlin/io/homeassistant/companion/android/vehicle…
cddu33 Apr 16, 2026
74cc6f1
Update common/src/main/res/values/strings.xml
cddu33 Apr 16, 2026
e2cd297
Update app/src/main/kotlin/io/homeassistant/companion/android/vehicle…
cddu33 Apr 16, 2026
82f2143
Update app/src/main/kotlin/io/homeassistant/companion/android/vehicle…
cddu33 Apr 16, 2026
ce72bb6
Update app/src/main/kotlin/io/homeassistant/companion/android/vehicle…
cddu33 Apr 16, 2026
2bb7210
Update app/src/main/kotlin/io/homeassistant/companion/android/vehicle…
cddu33 Apr 16, 2026
f39eaf4
Merge branch 'feature/add-favorite-automotive' of https://github.com/…
cddu33 Apr 16, 2026
df516d2
clean logs
cddu33 Apr 16, 2026
d43b4aa
Update .idea/markdown.xml
cddu33 Apr 16, 2026
a473bc0
clean logs
cddu33 Apr 16, 2026
13129c8
Merge branch 'feature/add-favorite-automotive' of https://github.com/…
cddu33 Apr 16, 2026
b65ce01
Revert "beta publication playstore" (#2)
cddu33 Apr 16, 2026
ee5a158
clean logs
cddu33 Apr 16, 2026
622c276
Update app/src/main/kotlin/io/homeassistant/companion/android/vehicle…
cddu33 Apr 16, 2026
6da3ba5
delete unused val
cddu33 Apr 16, 2026
62465f1
Merge remote-tracking branch 'origin/feature/add-favorite-automotive'…
cddu33 Apr 16, 2026
4af0bf0
Copilot suggestion (#3)
cddu33 Apr 16, 2026
2df7f86
revert beta
cddu33 Apr 16, 2026
51bd2d3
revert beta
cddu33 Apr 16, 2026
b413653
removed unused import
cddu33 Apr 16, 2026
0ed7c46
feature/add-favorite-automotive (#4)
cddu33 Apr 16, 2026
3b44395
clean import
cddu33 Apr 16, 2026
0a74259
Feature/add favorite automotive (#5)
cddu33 Apr 16, 2026
2782057
Reorder imports in DomainListScreen.kt
cddu33 Apr 16, 2026
9c2ca73
Update DomainListScreen.kt
cddu33 Apr 16, 2026
d9e331b
clean @API
cddu33 Apr 16, 2026
80bcfc0
clean @api
cddu33 Apr 16, 2026
10f6bf9
clean @api
cddu33 Apr 16, 2026
b622996
beta
cddu33 Apr 16, 2026
e91c50c
Merge remote-tracking branch 'origin/beta_playsotre' into feature/add…
cddu33 Apr 16, 2026
1fed001
Merge branch 'beta_playsotre' into feature/add-favorite-automotive
cddu33 Apr 16, 2026
dea98ca
beta
cddu33 Apr 16, 2026
02dffc4
Merge remote-tracking branch 'origin/feature/add-favorite-automotive'…
cddu33 Apr 16, 2026
17cf7ad
Merge branch 'main' into feature/add-favorite-automotive
cddu33 Apr 16, 2026
9bab495
Revert "beta"
clementD33 Apr 20, 2026
e395938
Merge branch 'main' into feature/add-favorite-automotive
clementD33 Apr 20, 2026
00c7879
remove unused import
cddu33 Apr 20, 2026
86b234e
correct src/main/kotlin/io/homeassistant/companion/android/util/ve…
cddu33 Apr 20, 2026
d58dba4
Apply suggestions from code review
cddu33 Apr 23, 2026
216a401
Merge branch 'main' into feature/add-favorite-automotive
cddu33 Apr 23, 2026
e65e004
Merge branch 'main' into feature/add-favorite-automotive
clementD33 Apr 23, 2026
f7ed752
Merge branch 'main' into feature/add-favorite-automotive
cddu33 Apr 25, 2026
f438fee
Merge branch 'main' into feature/add-favorite-automotive
cddu33 Apr 30, 2026
cb34a67
Merge branch 'home-assistant:main' into feature/add-favorite-automotive
cddu33 May 5, 2026
edb2625
delete markdown.xml
cddu33 May 5, 2026
9358e49
protec isDrivingOptimized
cddu33 May 5, 2026
db57201
btn favorite on all automotive
cddu33 May 5, 2026
549a9ee
delete icon Previous/Next page
cddu33 May 5, 2026
bcabf58
correct manageFavorite
cddu33 May 5, 2026
a038ae6
remove timber log
cddu33 May 5, 2026
1917abf
clean space and line
cddu33 May 5, 2026
a4007d5
clean space and line
cddu33 May 5, 2026
95e5972
Merge branch 'main' into feature/add-favorite-automotive
cddu33 May 5, 2026
54be43f
api 26/23 @RequiresApi(Build.VERSION_CODES.O)
cddu33 May 5, 2026
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 @@ -28,6 +28,7 @@ import io.homeassistant.companion.android.util.RegistriesDataHandler
import io.homeassistant.companion.android.vehicle.ChangeServerScreen
import io.homeassistant.companion.android.vehicle.DomainListScreen
import io.homeassistant.companion.android.vehicle.EntityGridVehicleScreen
import io.homeassistant.companion.android.vehicle.ManageFavoritesVehicleScreen
import io.homeassistant.companion.android.vehicle.MapVehicleScreen
import java.time.LocalDateTime
import java.util.Locale
Expand Down Expand Up @@ -256,3 +257,31 @@ fun getDomainsGridItem(
}
}
}

/**
* Creates a header [Action] that opens the [ManageFavoritesVehicleScreen], allowing the user
* to add or remove entities from the automotive favorites list. Intended for use in the header
* of automotive screens when the vehicle is parked.
*/
fun getManageFavoritesAction(
carContext: CarContext,
screenManager: ScreenManager,
serverId: StateFlow<Int>,
allEntities: Flow<Map<String, Entity>>,
prefsRepository: PrefsRepository,
): Action {
return Action.Builder()
.setTitle(carContext.getString(R.string.aa_manage_favorites))
.setOnClickListener {
Timber.i("Manage favorites clicked")
screenManager.push(
ManageFavoritesVehicleScreen(
carContext,
serverId,
allEntities,
prefsRepository,
),
)
}
.build()
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,17 @@ import androidx.car.app.Screen
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import io.homeassistant.companion.android.common.util.isAutomotive
import timber.log.Timber

abstract class BaseVehicleScreen(carContext: CarContext) : Screen(carContext) {
private var car: Car? = null
private var carRestrictionManager: CarUxRestrictionsManager? = null
protected val isDrivingOptimized
protected val isDrivingOptimized: Boolean
get() = try {
(car?.getCarManager(Car.CAR_UX_RESTRICTION_SERVICE) as? CarUxRestrictionsManager)
carRestrictionManager
?.currentCarUxRestrictions
?.isRequiresDistractionOptimization
?: false
} catch (e: Exception) {
Timber.e(e, "Error getting UX Restrictions")
false
}

Expand All @@ -42,7 +40,6 @@ abstract class BaseVehicleScreen(carContext: CarContext) : Screen(carContext) {

private fun registerAutomotiveRestrictionListener() {
if (carContext.isAutomotive()) {
Timber.i("Register for Automotive Restrictions")
car = Car.createCar(carContext)
carRestrictionManager =
car?.getCarManager(Car.CAR_UX_RESTRICTION_SERVICE) as? CarUxRestrictionsManager
Expand All @@ -51,6 +48,7 @@ abstract class BaseVehicleScreen(carContext: CarContext) : Screen(carContext) {
onDrivingOptimizedChanged(restrictions.isRequiresDistractionOptimization)
}
carRestrictionManager?.registerListener(listener)
invalidate()
Comment thread
cddu33 marked this conversation as resolved.
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import io.homeassistant.companion.android.common.util.isAutomotive
import io.homeassistant.companion.android.util.vehicle.SUPPORTED_DOMAINS
import io.homeassistant.companion.android.util.vehicle.getDomainList
import io.homeassistant.companion.android.util.vehicle.getHeaderBuilder
import io.homeassistant.companion.android.util.vehicle.getManageFavoritesAction
import io.homeassistant.companion.android.util.vehicle.nativeModeAction
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
Expand Down Expand Up @@ -71,8 +72,20 @@ class DomainListScreen(

return GridTemplate.Builder().apply {
val headerBuilder = carContext.getHeaderBuilder(R.string.all_entities)
if (isAutomotive && !isDrivingOptimized && BuildConfig.FLAVOR != "full") {
headerBuilder.addEndHeaderAction(nativeModeAction(carContext))
if (isAutomotive && !isDrivingOptimized) {
if (BuildConfig.FLAVOR != "full") {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's a shame that we can't use your screen also in minimal flavor. You should consider a UI that would allow both the native button and add favorite to be displayed.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add a screenshot showing both?

headerBuilder.addEndHeaderAction(nativeModeAction(carContext))
}

headerBuilder.addEndHeaderAction(
getManageFavoritesAction(
carContext,
screenManager,
serverId,
allEntities,
prefsRepository,
),
)
}
setHeader(headerBuilder.build())
val domainBuild = domainList.build()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import io.homeassistant.companion.android.util.vehicle.SUPPORTED_DOMAINS
import io.homeassistant.companion.android.util.vehicle.getChangeServerGridItem
import io.homeassistant.companion.android.util.vehicle.getDomainList
import io.homeassistant.companion.android.util.vehicle.getHeaderBuilder
import io.homeassistant.companion.android.util.vehicle.getManageFavoritesAction
import io.homeassistant.companion.android.util.vehicle.getNavigationGridItem
import io.homeassistant.companion.android.util.vehicle.nativeModeAction
import kotlinx.coroutines.CancellationException
Expand Down Expand Up @@ -217,8 +218,19 @@ class MainVehicleScreen(
}.build()

val headerBuilder = carContext.getHeaderBuilder(commonR.string.app_name, Action.APP_ICON)
if (isAutomotive && !isDrivingOptimized && BuildConfig.FLAVOR != "full") {
headerBuilder.addEndHeaderAction(nativeModeAction(carContext))
if (isAutomotive && !isDrivingOptimized) {
Comment thread
cddu33 marked this conversation as resolved.
if (BuildConfig.FLAVOR != "full") {
headerBuilder.addEndHeaderAction(nativeModeAction(carContext))
}
headerBuilder.addEndHeaderAction(
getManageFavoritesAction(
carContext,
screenManager,
serverId,
allEntities,
prefsRepository,
),
)
}
headerBuilder.addEndHeaderAction(refreshAction)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
package io.homeassistant.companion.android.vehicle

import androidx.car.app.CarContext
import androidx.car.app.constraints.ConstraintManager
import androidx.car.app.model.ItemList
import androidx.car.app.model.ListTemplate
import androidx.car.app.model.Row
import androidx.car.app.model.Template
import androidx.car.app.model.Toggle
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import io.homeassistant.companion.android.common.R as commonR
import io.homeassistant.companion.android.common.data.integration.Entity
import io.homeassistant.companion.android.common.data.prefs.AutoFavorite
import io.homeassistant.companion.android.common.data.prefs.PrefsRepository
import io.homeassistant.companion.android.util.vehicle.SUPPORTED_DOMAINS_WITH_STRING
import io.homeassistant.companion.android.util.vehicle.getHeaderBuilder
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

/**
* A Car App screen that allows users to manage their automotive favorites when the vehicle is
* parked. Each entity from the supported domains is displayed with a toggle to add or remove
* it from the favorites list. Current favorites are sorted to the top.
*
* This screen stays fully within the Car App API, making it compliant with Play Store
* automotive distribution policies.
*/
class ManageFavoritesVehicleScreen(
carContext: CarContext,
private val serverId: StateFlow<Int>,
private val allEntities: Flow<Map<String, Entity>>,
private val prefsRepository: PrefsRepository,
) : BaseVehicleScreen(carContext) {

private var entities: List<Entity> = emptyList()
private var favoritesList: List<AutoFavorite> = emptyList()
private var isLoaded = false
private var page = 0
private val toggleMutex = Mutex()

init {
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
favoritesList = prefsRepository.getAutoFavorites()
allEntities.collect { entityMap ->
val favoriteEntityIds = favoritesList
.asSequence()
.filter { it.serverId == serverId.value }
.map { it.entityId }
.toSet()

val newEntities = entityMap.values
.filter { it.domain in SUPPORTED_DOMAINS_WITH_STRING }
.sortedWith(
compareByDescending<Entity> { entity ->
favoriteEntityIds.contains(entity.entityId)
}.thenBy { it.attributes["friendly_name"]?.toString() ?: it.entityId },
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
}.thenBy { it.attributes["friendly_name"]?.toString() ?: it.entityId },
}.thenBy { it.friendlyName },

)
val listChanged = newEntities.map { it.entityId } != entities.map { it.entityId }
if (listChanged) page = 0
val shouldInvalidate = !isLoaded || listChanged
entities = newEntities
isLoaded = true
if (shouldInvalidate) invalidate()
}
}
}
}

override fun onDrivingOptimizedChanged(newState: Boolean) {
if (newState) {
lifecycleScope.launch {
screenManager.pop()
Comment thread
cddu33 marked this conversation as resolved.
}
}
invalidate()
}

override fun onGetTemplate(): Template {
Comment thread
cddu33 marked this conversation as resolved.
val listLimit = carContext.getCarService(ConstraintManager::class.java)
.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)
val pageSlice = computePageSlice(entities.size, page, listLimit)
val pageEntities = if (isLoaded && pageSlice.fromIndex < entities.size) {
entities.subList(pageSlice.fromIndex, pageSlice.toIndex)
} else {
emptyList()
}

return ListTemplate.Builder()
.setHeader(carContext.getHeaderBuilder(commonR.string.android_automotive_favorites).build())
.setLoading(!isLoaded)
.apply {
if (isLoaded) setSingleList(buildList(pageEntities, pageSlice).build())
}
.build()
}

private fun buildList(pageEntities: List<Entity>, pageSlice: PageSlice): ItemList.Builder {
val listBuilder = ItemList.Builder()

if (pageSlice.hasPreviousPage) {
listBuilder.addItem(
buildNavigationRow(commonR.string.aa_previous_page) {
page--
invalidate()
},
)
}

pageEntities.forEach { entity ->
listBuilder.addItem(buildEntityRow(entity))
}

if (pageSlice.hasNextPage) {
listBuilder.addItem(
buildNavigationRow(commonR.string.aa_next_page) {
page++
invalidate()
},
)
}

if (isLoaded && entities.isEmpty()) {
listBuilder.setNoItemsMessage(carContext.getString(commonR.string.no_supported_entities))
}

return listBuilder
}

private fun buildNavigationRow(titleRes: Int, onClick: () -> Unit): Row = Row.Builder()
.setTitle(carContext.getString(titleRes))
.setOnClickListener(onClick)
.build()

private fun buildEntityRow(entity: Entity): Row {
val isFavorite = favoritesList.any {
it.serverId == serverId.value && it.entityId == entity.entityId
}
val friendlyName = entity.attributes["friendly_name"]?.toString() ?: entity.entityId
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
val friendlyName = entity.attributes["friendly_name"]?.toString() ?: entity.entityId
val friendlyName = entity.friendlyName

We already have an extension to get the name and the fallback properly.

val domainLabel = SUPPORTED_DOMAINS_WITH_STRING[entity.domain]
?.let { carContext.getString(it) }
?: entity.domain

return Row.Builder()
.setTitle(friendlyName)
.addText(domainLabel)
.setToggle(
Toggle.Builder { isChecked ->
lifecycleScope.launch {
toggleMutex.withLock {
val favorite = AutoFavorite(
serverId = serverId.value,
entityId = entity.entityId,
)
if (isChecked) {
prefsRepository.addAutoFavorite(favorite)
} else {
prefsRepository.setAutoFavorites(
favoritesList.filterNot { it == favorite },
)
}
favoritesList = prefsRepository.getAutoFavorites()
val favoriteEntityIds = favoritesList
.filter { it.serverId == serverId.value }
.map { it.entityId }
.toSet()
entities = entities.sortedWith(
compareByDescending<Entity> { it.entityId in favoriteEntityIds }
.thenBy { it.attributes["friendly_name"]?.toString() ?: it.entityId },
)
invalidate()
}
}
}
.setChecked(isFavorite)
.build(),
)
.build()
}
}

internal data class PageSlice(
val fromIndex: Int,
val toIndex: Int,
val hasPreviousPage: Boolean,
val hasNextPage: Boolean,
)

/**
* Computes the slice of entities to display for the given page.
*
* Always reserves 2 rows for navigation (previous/next), giving a consistent
* [itemsPerPage] across all pages and avoiding skipped entities.
*/
internal fun computePageSlice(totalItems: Int, page: Int, listLimit: Int): PageSlice {
val itemsPerPage = (listLimit - 2).coerceAtLeast(1)
val fromIndex = page * itemsPerPage
val toIndex = minOf(fromIndex + itemsPerPage, totalItems)
return PageSlice(
fromIndex = fromIndex,
toIndex = toIndex,
hasPreviousPage = page > 0,
hasNextPage = toIndex < totalItems,
)
}
3 changes: 3 additions & 0 deletions common/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1237,6 +1237,9 @@
<string name="aa_favorites_summary">Select your favorite entities to be shown in the app while viewing the Home Assistant driving interface</string>
<string name="android_automotive">Android Automotive</string>
<string name="android_automotive_favorites">Driving favorites</string>
<string name="aa_manage_favorites">Manage favorites</string>
<string name="aa_previous_page">Previous page</string>
<string name="aa_next_page">Next page</string>
<string name="alarm_control_panels">Alarm Control Panels</string>
<string name="state_triggered">Triggered</string>
<string name="state_disarmed">Disarmed</string>
Expand Down
Loading