Bunch of Android Auto enhancements (#3225)

* Bunch of Android Auto enhancements

* Bump icon sizes.

* Formatting.

* Review Fixes.

* Ooops.

* Missed size.
This commit is contained in:
Justin Bassett 2023-01-13 13:27:35 -05:00 committed by GitHub
parent 3dfdc72588
commit 1e910e7ec7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 315 additions and 29 deletions

View file

@ -94,6 +94,8 @@ jobs:
play_publish:
name: Play Publish
runs-on: ubuntu-latest
concurrency:
group: playstore_deploy
steps:
- uses: actions/checkout@v3
with:

View file

@ -12,7 +12,9 @@ import androidx.car.app.model.GridItem
import androidx.car.app.model.GridTemplate
import androidx.car.app.model.ItemList
import androidx.car.app.model.Template
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial
import com.mikepenz.iconics.utils.sizeDp
@ -23,6 +25,7 @@ import io.homeassistant.companion.android.common.data.integration.friendlyName
import io.homeassistant.companion.android.common.data.integration.friendlyState
import io.homeassistant.companion.android.common.data.integration.getIcon
import io.homeassistant.companion.android.common.data.integration.onPressed
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
@RequiresApi(Build.VERSION_CODES.O)
@ -30,18 +33,22 @@ class EntityGridVehicleScreen(
carContext: CarContext,
val integrationRepository: IntegrationRepository,
val title: String,
val entities: MutableMap<String, Entity<*>>,
val entitiesFlow: Flow<List<Entity<*>>>,
) : Screen(carContext) {
companion object {
private const val TAG = "EntityGridVehicleScreen"
}
var loading = true
var entities: List<Entity<*>> = listOf()
init {
lifecycleScope.launch {
integrationRepository.getEntityUpdates()?.collect { entity ->
if (entities.containsKey(entity.entityId)) {
entities[entity.entityId] = entity
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
entitiesFlow.collect {
loading = false
entities = it
invalidate()
}
}
@ -50,7 +57,7 @@ class EntityGridVehicleScreen(
override fun onGetTemplate(): Template {
val listBuilder = ItemList.Builder()
entities.forEach { (entityId, entity) ->
entities.forEach { entity ->
val icon = entity.getIcon(carContext) ?: CommunityMaterial.Icon.cmd_cloud_question
listBuilder.addItem(
GridItem.Builder()
@ -67,7 +74,7 @@ class EntityGridVehicleScreen(
.build()
)
.setOnClickListener {
Log.i(TAG, "$entityId clicked")
Log.i(TAG, "${entity.entityId} clicked")
lifecycleScope.launch {
entity.onPressed(integrationRepository)
}
@ -76,10 +83,15 @@ class EntityGridVehicleScreen(
)
}
return GridTemplate.Builder()
.setTitle(title)
.setHeaderAction(Action.BACK)
.setSingleList(listBuilder.build())
.build()
return GridTemplate.Builder().apply {
setTitle(title)
setHeaderAction(Action.BACK)
if (loading) {
setLoading(true)
} else {
setLoading(false)
setSingleList(listBuilder.build())
}
}.build()
}
}

View file

@ -3,24 +3,40 @@ package io.homeassistant.companion.android.vehicle
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.car.app.CarAppService
import androidx.car.app.Screen
import androidx.car.app.Session
import androidx.car.app.SessionInfo
import androidx.car.app.validation.HostValidator
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.common.data.authentication.AuthenticationRepository
import io.homeassistant.companion.android.common.data.integration.Entity
import io.homeassistant.companion.android.common.data.integration.IntegrationRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.shareIn
import okhttp3.internal.toImmutableMap
import javax.inject.Inject
@RequiresApi(Build.VERSION_CODES.O)
@AndroidEntryPoint
class HaCarAppService : CarAppService() {
companion object {
private const val TAG = "HaCarAppService"
}
@Inject
lateinit var integrationRepository: IntegrationRepository
@Inject
lateinit var authenticationRepository: AuthenticationRepository
override fun createHostValidator(): HostValidator {
return if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) {
HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
@ -33,8 +49,35 @@ class HaCarAppService : CarAppService() {
override fun onCreateSession(sessionInfo: SessionInfo): Session {
return object : Session() {
private val allEntities: Flow<Map<String, Entity<*>>> = flow {
emit(emptyMap())
val entities: MutableMap<String, Entity<*>>? =
integrationRepository.getEntities()
?.associate { it.entityId to it }
?.toMutableMap()
if (entities != null) {
emit(entities.toImmutableMap())
integrationRepository.getEntityUpdates()?.collect { entity ->
entities[entity.entityId] = entity
emit(entities.toImmutableMap())
}
} else {
Log.w(TAG, "No entities found?")
}
}.shareIn(
lifecycleScope,
SharingStarted.WhileSubscribed(10_000),
1
)
override fun onCreateScreen(intent: Intent): Screen {
return MainVehicleScreen(carContext, integrationRepository)
return MainVehicleScreen(
carContext,
integrationRepository,
authenticationRepository,
allEntities
)
}
}
}

View file

@ -1,27 +1,48 @@
package io.homeassistant.companion.android.vehicle
import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.model.Action
import androidx.car.app.model.CarColor
import androidx.car.app.model.CarIcon
import androidx.car.app.model.ItemList
import androidx.car.app.model.ListTemplate
import androidx.car.app.model.MessageTemplate
import androidx.car.app.model.ParkedOnlyOnClickListener
import androidx.car.app.model.Row
import androidx.car.app.model.Template
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial
import com.mikepenz.iconics.utils.sizeDp
import com.mikepenz.iconics.utils.toAndroidIconCompat
import io.homeassistant.companion.android.common.data.authentication.AuthenticationRepository
import io.homeassistant.companion.android.common.data.authentication.SessionState
import io.homeassistant.companion.android.common.data.integration.Entity
import io.homeassistant.companion.android.common.data.integration.IntegrationRepository
import io.homeassistant.companion.android.common.data.integration.domain
import io.homeassistant.companion.android.common.data.integration.getIcon
import io.homeassistant.companion.android.launch.LaunchActivity
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import java.util.Calendar
import java.util.Locale
import io.homeassistant.companion.android.common.R as commonR
@RequiresApi(Build.VERSION_CODES.O)
class MainVehicleScreen(
carContext: CarContext,
val integrationRepository: IntegrationRepository,
private val integrationRepository: IntegrationRepository,
private val authenticationRepository: AuthenticationRepository,
private val allEntities: Flow<Map<String, Entity<*>>>
) : Screen(carContext) {
companion object {
@ -39,25 +60,64 @@ class MainVehicleScreen(
"switch" to commonR.string.switches,
)
private val SUPPORTED_DOMAINS = SUPPORTED_DOMAINS_WITH_STRING.keys
private val MAP_DOMAINS = listOf(
"device_tracker",
"person",
"zone",
)
}
private var isLoggedIn: Boolean? = null
private val domains = mutableSetOf<String>()
private val entities = mutableMapOf<String, Entity<*>>()
init {
lifecycleScope.launch {
integrationRepository.getEntities()?.forEach { entity ->
val domain = entity.domain
if (domain in SUPPORTED_DOMAINS) {
entities[entity.entityId] = entity
domains.add(domain)
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
isLoggedIn = authenticationRepository.getSessionState() == SessionState.CONNECTED
invalidate()
while (isLoggedIn != true) {
delay(1000)
isLoggedIn =
authenticationRepository.getSessionState() == SessionState.CONNECTED
invalidate()
}
allEntities.collect { entities ->
domains.clear()
entities.values.forEach {
if (it.domain in SUPPORTED_DOMAINS) {
domains.add(it.domain)
}
}
invalidate()
}
}
invalidate()
}
}
override fun onGetTemplate(): Template {
if (isLoggedIn == false) {
return MessageTemplate.Builder(carContext.getString(commonR.string.aa_app_not_logged_in))
.setTitle(carContext.getString(commonR.string.app_name))
.setHeaderAction(Action.APP_ICON)
.addAction(
Action.Builder()
.setTitle(carContext.getString(commonR.string.login))
.setOnClickListener(
ParkedOnlyOnClickListener.create {
Log.i(TAG, "Starting login activity")
carContext.startActivity(
Intent(carContext, LaunchActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
)
}
)
.build()
)
.build()
}
val listBuilder = ItemList.Builder()
domains.forEach { domain ->
val friendlyDomain =
@ -67,17 +127,39 @@ class MainVehicleScreen(
if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString()
}
}
val icon = Entity(
"$domain.ha_android_placeholder",
"",
mapOf<Any, Any>(),
Calendar.getInstance(),
Calendar.getInstance(),
null
).getIcon(carContext)
listBuilder.addItem(
Row.Builder()
Row.Builder().apply {
if (icon != null) {
setImage(
CarIcon.Builder(
IconicsDrawable(carContext, icon)
.apply {
sizeDp = 48
}.toAndroidIconCompat()
)
.setTint(CarColor.DEFAULT)
.build()
)
}
}
.setTitle(friendlyDomain)
.setOnClickListener {
Log.i(TAG, "$domain clicked")
Log.i(TAG, "Domain:$domain clicked")
screenManager.push(
EntityGridVehicleScreen(
carContext,
integrationRepository,
friendlyDomain,
entities.filter { it.value.domain == domain }.toMutableMap()
allEntities.map { it.values.filter { entity -> entity.domain == domain } }
)
)
}
@ -85,12 +167,43 @@ class MainVehicleScreen(
)
}
// TODO: Add row for zones so we can start navigation?
listBuilder.addItem(
Row.Builder()
.setImage(
CarIcon.Builder(
IconicsDrawable(
carContext,
CommunityMaterial.Icon3.cmd_map_outline
).apply {
sizeDp = 48
}.toAndroidIconCompat()
)
.setTint(CarColor.DEFAULT)
.build()
)
.setTitle(carContext.getString(commonR.string.aa_navigation))
.setOnClickListener {
Log.i(TAG, "Navigation clicked")
screenManager.push(
MapVehicleScreen(
carContext,
integrationRepository,
allEntities.map { it.values.filter { entity -> entity.domain in MAP_DOMAINS } }
)
)
}
.build()
)
return ListTemplate.Builder()
.setTitle(carContext.getString(io.homeassistant.companion.android.common.R.string.app_name))
.setHeaderAction(Action.APP_ICON)
.setSingleList(listBuilder.build())
.build()
return ListTemplate.Builder().apply {
setTitle(carContext.getString(commonR.string.app_name))
setHeaderAction(Action.APP_ICON)
if (domains.isEmpty()) {
setLoading(true)
} else {
setLoading(false)
setSingleList(listBuilder.build())
}
}.build()
}
}

View file

@ -0,0 +1,113 @@
package io.homeassistant.companion.android.vehicle
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.model.Action
import androidx.car.app.model.CarColor
import androidx.car.app.model.CarIcon
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.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial
import com.mikepenz.iconics.utils.sizeDp
import com.mikepenz.iconics.utils.toAndroidIconCompat
import io.homeassistant.companion.android.common.R
import io.homeassistant.companion.android.common.data.integration.Entity
import io.homeassistant.companion.android.common.data.integration.IntegrationRepository
import io.homeassistant.companion.android.common.data.integration.friendlyName
import io.homeassistant.companion.android.common.data.integration.getIcon
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import io.homeassistant.companion.android.common.R as commonR
@RequiresApi(Build.VERSION_CODES.O)
class MapVehicleScreen(
carContext: CarContext,
val integrationRepository: IntegrationRepository,
val entitiesFlow: Flow<List<Entity<*>>>,
) : Screen(carContext) {
companion object {
private const val TAG = "MapVehicleScreen"
}
var loading = true
var entities: List<Entity<*>> = listOf()
init {
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
entitiesFlow.collect {
loading = false
entities = it
invalidate()
}
}
}
}
override fun onGetTemplate(): Template {
val listBuilder = ItemList.Builder()
entities
.mapNotNull {
val attrs = it.attributes as? Map<*, *>
if (attrs != null) {
val lat = attrs["latitude"] as? Double
val lon = attrs["longitude"] as? Double
if (lat != null && lon != null) {
return@mapNotNull Pair(it, listOf(lat, lon))
}
}
return@mapNotNull null
}
.sortedBy { it.first.friendlyName }
.forEach { (entity, location) ->
val icon = entity.getIcon(carContext) ?: CommunityMaterial.Icon.cmd_account
listBuilder.addItem(
Row.Builder()
.setTitle(entity.friendlyName)
.setImage(
CarIcon.Builder(
IconicsDrawable(carContext, icon)
.apply {
sizeDp = 48
}.toAndroidIconCompat()
)
.setTint(CarColor.DEFAULT)
.build()
)
.setOnClickListener {
Log.i(TAG, "${entity.entityId} clicked")
val intent = Intent(
CarContext.ACTION_NAVIGATE,
Uri.parse("geo:${location[0]},${location[1]}")
)
carContext.startCarApp(intent)
}
.build()
)
}
return ListTemplate.Builder().apply {
setTitle(carContext.getString(R.string.aa_navigation))
setHeaderAction(Action.BACK)
if (loading) {
setLoading(true)
} else {
setLoading(false)
listBuilder.setNoItemsMessage(carContext.getString(commonR.string.aa_no_entities_with_locations))
setSingleList(listBuilder.build())
}
}.build()
}
}

View file

@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="aa_app_not_logged_in">Not logged in</string>
<string name="aa_navigation">Navigation</string>
<string name="aa_no_entities_with_locations">No entities with locations found.</string>
<string name="account">Account</string>
<string name="action_reply">Reply</string>
<string name="activity_intent_error">Unable to send activity intent, please check command format</string>