Skip to content
Open
Show file tree
Hide file tree
Changes from 42 commits
Commits
Show all changes
44 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
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
Empty file added .idea/markdown.xml
Empty file.
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 @@ -88,7 +89,6 @@ fun getChangeServerGridItem(
}
}

@RequiresApi(Build.VERSION_CODES.O)
fun getNavigationGridItem(
carContext: CarContext,
screenManager: ScreenManager,
Expand Down Expand Up @@ -131,7 +131,6 @@ fun getNavigationGridItem(
}
}

@RequiresApi(Build.VERSION_CODES.O)
fun getDomainList(
domains: MutableSet<String>,
carContext: CarContext,
Expand Down Expand Up @@ -217,7 +216,6 @@ fun getDomainList(
return listBuilder
}

@RequiresApi(Build.VERSION_CODES.O)
fun getDomainsGridItem(
carContext: CarContext,
screenManager: ScreenManager,
Expand Down Expand Up @@ -256,3 +254,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,21 +7,15 @@ 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
get() = try {
(car?.getCarManager(Car.CAR_UX_RESTRICTION_SERVICE) as? CarUxRestrictionsManager)
?.currentCarUxRestrictions
?.isRequiresDistractionOptimization
?: false
} catch (e: Exception) {
Timber.e(e, "Error getting UX Restrictions")
false
}
get() = carRestrictionManager
?.currentCarUxRestrictions
?.isRequiresDistractionOptimization
?: false

init {
lifecycle.addObserver(object : DefaultLifecycleObserver {
Expand All @@ -42,7 +36,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 +44,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") {
headerBuilder.addEndHeaderAction(nativeModeAction(carContext))
} else {
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,20 @@ 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) {
if (BuildConfig.FLAVOR != "full") {
headerBuilder.addEndHeaderAction(nativeModeAction(carContext))
} else {
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,181 @@
package io.homeassistant.companion.android.vehicle

import android.os.Build

Check failure

Code scanning / ktlint

Unused import Error

Unused import

Check failure

Code scanning / ktlint

Unused import Error

Unused import
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
import androidx.annotation.RequiresApi

Check failure

Code scanning / ktlint

Unused import Error

Unused import

Check failure

Code scanning / ktlint

Unused import Error

Unused import
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
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

/**
* 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

init {
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
favoritesList = prefsRepository.getAutoFavorites()
allEntities.collect { entityMap ->
val currentServerId = serverId.value
val favoriteEntityIds = favoritesList
.asSequence()
.filter { it.serverId == currentServerId }
.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 },
)
if (newEntities.map { it.entityId } != entities.map { it.entityId }) {
page = 0
}
entities = newEntities
isLoaded = true
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 {
val listLimit = carContext.getCarService(ConstraintManager::class.java)
.getContentLimit(ConstraintManager.CONTENT_LIMIT_TYPE_LIST)

val hasPreviousPage = page > 0
val reservedRowsWithoutNextPage = if (hasPreviousPage) 1 else 0
val maxItemsWithoutNextPage = (listLimit - reservedRowsWithoutNextPage).coerceAtLeast(1)
val fromIndex = page * maxItemsWithoutNextPage
val hasNextPage = fromIndex + maxItemsWithoutNextPage < entities.size
val reservedRows = reservedRowsWithoutNextPage + if (hasNextPage) 1 else 0
val itemsPerPage = (listLimit - reservedRows).coerceAtLeast(1)
val toIndex = minOf(fromIndex + itemsPerPage, entities.size)
val pageEntities = if (isLoaded) entities.subList(fromIndex, toIndex) else emptyList()
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

entities.subList(fromIndex, toIndex) can throw if fromIndex > entities.size (e.g., if page is out of range after the entity list changes). Guard against out-of-range paging (clamp page, or return an empty list when fromIndex >= entities.size) before calling subList.

Suggested change
val pageEntities = if (isLoaded) entities.subList(fromIndex, toIndex) else emptyList()
val pageEntities = if (isLoaded && fromIndex < entities.size) {
entities.subList(fromIndex, toIndex)
} else {
emptyList()
}

Copilot uses AI. Check for mistakes.

val listBuilder = ItemList.Builder()

if (hasPreviousPage) {
listBuilder.addItem(
Row.Builder()
.setTitle(carContext.getString(commonR.string.aa_previous_page))
.setOnClickListener {
page--
invalidate()
}
.build(),
)
}

pageEntities.forEach { entity ->
val isFavorite = favoritesList.any {
it.serverId == serverId.value && it.entityId == entity.entityId
}
val friendlyName = entity.attributes["friendly_name"]?.toString() ?: entity.entityId
val domainLabel = SUPPORTED_DOMAINS_WITH_STRING[entity.domain]
?.let { carContext.getString(it) }
?: entity.domain

listBuilder.addItem(
Row.Builder()
.setTitle(friendlyName)
.addText(domainLabel)
.setEnabled(!isDrivingOptimized)
.setToggle(
Toggle.Builder { isChecked ->
lifecycleScope.launch {
val favorite = AutoFavorite(
serverId = serverId.value,
entityId = entity.entityId,
)
if (isChecked) {
prefsRepository.addAutoFavorite(favorite)
} else {
val updated = favoritesList.filterNot { it == favorite }
Comment thread
cddu33 marked this conversation as resolved.
prefsRepository.setAutoFavorites(updated)
}
favoritesList = prefsRepository.getAutoFavorites()
Comment thread
cddu33 marked this conversation as resolved.
val favoriteEntityIds = favoritesList
.filter { it.serverId == serverId.value }
.map { it.entityId }
.toSet()
entities = entities.sortedByDescending { updatedEntity ->
updatedEntity.entityId in favoriteEntityIds
}
val maxPage = if (entities.isEmpty()) 0 else (entities.size - 1) / listLimit
page = page.coerceIn(minimumValue = 0, maximumValue = maxPage)
invalidate()
}
}
.setChecked(isFavorite)
.build(),
)
.build(),
)
}

if (hasNextPage) {
listBuilder.addItem(
Row.Builder()
.setTitle(carContext.getString(commonR.string.aa_next_page))
.setOnClickListener {
page++
invalidate()
}
.build(),
)
}

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

return ListTemplate.Builder()
.setHeader(carContext.getHeaderBuilder(commonR.string.android_automotive_favorites).build())
.setLoading(!isLoaded)
.apply { if (isLoaded) setSingleList(listBuilder.build()) }
.build()
}
}
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 @@ -1235,6 +1235,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