Initial Android Auto Support (#3211)

* Initial work on Android Auto support.

* Get some toggling working, add a main view that is useful.

* Make sure we can test this on every commit.

* Migrate to full flavor.
This commit is contained in:
Justin Bassett 2023-01-11 17:31:53 -05:00 committed by GitHub
parent df47acb83c
commit ee4272a168
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 378 additions and 46 deletions

View file

@ -90,3 +90,67 @@ jobs:
with:
version: io.homeassistant.companion.android@${{ steps.rel_number.outputs.version }}
environment: Beta
play_publish:
name: Play Publish
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up JDK 11
uses: actions/setup-java@v3.9.0
with:
distribution: 'temurin'
java-version: '11'
- uses: actions/cache@v3
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
restore-keys: |
${{ runner.os }}-gradle-
- uses: ./.github/actions/create-release-number
name: Create Release Number
id: rel_number
with:
beta: true
- uses: ./.github/actions/inflate-secrets
name: Inflate Secrets
with:
keystore: ${{ secrets.UPLOAD_KEYSTORE_FILE }}
google-services: ${{ secrets.GOOGLESERVICES }}
firebase-creds: ${{ secrets.FIREBASECREDS }}
playstore-creds: ${{ secrets.PLAYSTORECREDS }}
- uses: ./.github/actions/create-release-notes
name: Create Release Notes
- uses: ./.github/actions/download-translations
name: Download Translations
with:
lokalise-project: ${{ secrets.LOKALISE_PROJECT }}
lokalise-token: ${{ secrets.LOKALISE_TOKEN }}
- name: Build Release
env:
KEYSTORE_PASSWORD: ${{ secrets.UPLOAD_KEYSTORE_FILE_PASSWORD }}
KEYSTORE_ALIAS: ${{ secrets.UPLOAD_KEYSTORE_ALIAS }}
KEYSTORE_ALIAS_PASSWORD: ${{ secrets.UPLOAD_KEYSTORE_ALIAS_PASSWORD }}
VERSION: ${{ steps.rel_number.outputs.version }}
VERSION_CODE: ${{ steps.rel_number.outputs.version-code }}
run: ./gradlew bundleRelease
- name: Deploy to Playstore Internal
env:
KEYSTORE_PASSWORD: ${{ secrets.UPLOAD_KEYSTORE_FILE_PASSWORD }}
KEYSTORE_ALIAS: ${{ secrets.UPLOAD_KEYSTORE_ALIAS }}
KEYSTORE_ALIAS_PASSWORD: ${{ secrets.UPLOAD_KEYSTORE_ALIAS_PASSWORD }}
VERSION: ${{ steps.rel_number.outputs.version }}
VERSION_CODE: ${{ steps.rel_number.outputs.version-code }}
run: ./gradlew publishReleaseBundle

View file

@ -163,7 +163,7 @@ jobs:
VERSION_CODE: ${{ steps.rel_number.outputs.version-code }}
run: ./gradlew bundleRelease
- name: Deploy to Playstore Beta
- name: Deploy to Playstore Internal
env:
KEYSTORE_PASSWORD: ${{ secrets.UPLOAD_KEYSTORE_FILE_PASSWORD }}
KEYSTORE_ALIAS: ${{ secrets.UPLOAD_KEYSTORE_ALIAS }}
@ -172,6 +172,15 @@ jobs:
VERSION_CODE: ${{ steps.rel_number.outputs.version-code }}
run: ./gradlew publishReleaseBundle
- name: Promote to Internal to Beta
env:
KEYSTORE_PASSWORD: ${{ secrets.UPLOAD_KEYSTORE_FILE_PASSWORD }}
KEYSTORE_ALIAS: ${{ secrets.UPLOAD_KEYSTORE_ALIAS }}
KEYSTORE_ALIAS_PASSWORD: ${{ secrets.UPLOAD_KEYSTORE_ALIAS_PASSWORD }}
VERSION: ${{ steps.rel_number.outputs.version }}
VERSION_CODE: ${{ steps.rel_number.outputs.version-code }}
run: ./gradlew promoteArtifact --from-track internal --promote-track beta
- name: Promote to Beta to Production
env:
KEYSTORE_PASSWORD: ${{ secrets.UPLOAD_KEYSTORE_FILE_PASSWORD }}

View file

@ -5,8 +5,8 @@ on:
schedule:
- cron: '0 4 * * 0'
jobs:
play_publish:
name: Play Publish
play_promote:
name: Playstore Promote
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
@ -42,15 +42,6 @@ jobs:
firebase-creds: ${{ secrets.FIREBASECREDS }}
playstore-creds: ${{ secrets.PLAYSTORECREDS }}
- uses: ./.github/actions/create-release-notes
name: Create Release Notes
- uses: ./.github/actions/download-translations
name: Download Translations
with:
lokalise-project: ${{ secrets.LOKALISE_PROJECT }}
lokalise-token: ${{ secrets.LOKALISE_TOKEN }}
- name: Build Release
env:
KEYSTORE_PASSWORD: ${{ secrets.UPLOAD_KEYSTORE_FILE_PASSWORD }}
@ -58,30 +49,4 @@ jobs:
KEYSTORE_ALIAS_PASSWORD: ${{ secrets.UPLOAD_KEYSTORE_ALIAS_PASSWORD }}
VERSION: ${{ steps.rel_number.outputs.version }}
VERSION_CODE: ${{ steps.rel_number.outputs.version-code }}
run: ./gradlew bundleRelease
- name: Check for build need
run: |
commits=$(git log --since="7 days ago" --oneline | wc -l)
echo "commits=$commits" >> $GITHUB_ENV
- name: Deploy to Playstore Beta
# Only run if a new commit is present. This should prevent a new beta being created
# incorrectly when the release is generated.
if: env.commits != '0' || github.event_name == 'workflow_dispatch'
env:
KEYSTORE_PASSWORD: ${{ secrets.UPLOAD_KEYSTORE_FILE_PASSWORD }}
KEYSTORE_ALIAS: ${{ secrets.UPLOAD_KEYSTORE_ALIAS }}
KEYSTORE_ALIAS_PASSWORD: ${{ secrets.UPLOAD_KEYSTORE_ALIAS_PASSWORD }}
VERSION: ${{ steps.rel_number.outputs.version }}
VERSION_CODE: ${{ steps.rel_number.outputs.version-code }}
run: ./gradlew publishReleaseBundle || echo "Issue uploading Release, may just be nothing has changed"
- name: Deploy to Playstore Listing
env:
KEYSTORE_PASSWORD: ${{ secrets.UPLOAD_KEYSTORE_FILE_PASSWORD }}
KEYSTORE_ALIAS: ${{ secrets.UPLOAD_KEYSTORE_ALIAS }}
KEYSTORE_ALIAS_PASSWORD: ${{ secrets.UPLOAD_KEYSTORE_ALIAS_PASSWORD }}
VERSION: ${{ steps.rel_number.outputs.version }}
VERSION_CODE: ${{ steps.rel_number.outputs.version-code }}
run: ./gradlew publishListing
run: ./gradlew promoteArtifact --from-track internal --promote-track beta

View file

@ -130,7 +130,7 @@ android {
play {
serviceAccountCredentials.set(file("playStorePublishServiceCredentialsFile.json"))
track.set("beta")
track.set("internal")
resolutionStrategy.set(ResolutionStrategy.IGNORE)
// We will depend on the wear commit.
commit.set(true)
@ -211,6 +211,8 @@ dependencies {
"fullImplementation"("org.burnoutcrew.composereorderable:reorderable:0.9.6")
implementation("com.github.AppDevNext:ChangeLog:3.4")
"fullImplementation"("androidx.car.app:app:1.3.0-rc01")
}
// Disable to fix memory leak and be compatible with the configuration cache.

View file

@ -45,6 +45,21 @@
<meta-data
android:name="com.google.firebase.messaging.default_notification_color"
android:resource="@color/colorPrimary" />
<meta-data
android:name="androidx.car.app.minCarApiLevel"
android:value="1"/>
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc"/>
<service
android:name=".vehicle.HaCarAppService"
android:exported="true">
<intent-filter>
<action android:name="androidx.car.app.CarAppService" />
<category android:name="androidx.car.app.category.IOT"/>
</intent-filter>
</service>
</application>
</manifest>

View file

@ -0,0 +1,80 @@
package io.homeassistant.companion.android.vehicle
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.GridItem
import androidx.car.app.model.GridTemplate
import androidx.car.app.model.ItemList
import androidx.car.app.model.Template
import androidx.lifecycle.lifecycleScope
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial
import com.mikepenz.iconics.utils.toAndroidIconCompat
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.friendlyState
import io.homeassistant.companion.android.common.data.integration.getIcon
import io.homeassistant.companion.android.common.data.integration.onPressed
import kotlinx.coroutines.launch
@RequiresApi(Build.VERSION_CODES.O)
class EntityGridVehicleScreen(
carContext: CarContext,
val integrationRepository: IntegrationRepository,
val title: String,
val entities: MutableMap<String, Entity<*>>,
) : Screen(carContext) {
companion object {
private const val TAG = "EntityGridVehicleScreen"
}
init {
lifecycleScope.launch {
integrationRepository.getEntityUpdates()?.collect { entity ->
if (entities.containsKey(entity.entityId)) {
entities[entity.entityId] = entity
invalidate()
}
}
}
}
override fun onGetTemplate(): Template {
val listBuilder = ItemList.Builder()
entities.forEach { (entityId, entity) ->
val icon = entity.getIcon(carContext) ?: CommunityMaterial.Icon.cmd_cloud_question
listBuilder.addItem(
GridItem.Builder()
.setLoading(false)
.setTitle(entity.friendlyName)
.setText(entity.friendlyState)
.setImage(
CarIcon.Builder(IconicsDrawable(carContext, icon).toAndroidIconCompat())
.setTint(CarColor.DEFAULT)
.build()
)
.setOnClickListener {
Log.i(TAG, "$entityId clicked")
lifecycleScope.launch {
entity.onPressed(integrationRepository)
}
}
.build()
)
}
return GridTemplate.Builder()
.setTitle(title)
.setHeaderAction(Action.BACK)
.setSingleList(listBuilder.build())
.build()
}
}

View file

@ -0,0 +1,41 @@
package io.homeassistant.companion.android.vehicle
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.os.Build
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 dagger.hilt.android.AndroidEntryPoint
import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.common.data.integration.IntegrationRepository
import javax.inject.Inject
@RequiresApi(Build.VERSION_CODES.O)
@AndroidEntryPoint
class HaCarAppService : CarAppService() {
@Inject
lateinit var integrationRepository: IntegrationRepository
override fun createHostValidator(): HostValidator {
return if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) {
HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
} else {
HostValidator.Builder(applicationContext)
.addAllowedHosts(R.array.hosts_allowlist)
.build()
}
}
override fun onCreateSession(sessionInfo: SessionInfo): Session {
return object : Session() {
override fun onCreateScreen(intent: Intent): Screen {
return MainVehicleScreen(carContext, integrationRepository)
}
}
}
}

View file

@ -0,0 +1,92 @@
package io.homeassistant.companion.android.vehicle
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.ItemList
import androidx.car.app.model.ListTemplate
import androidx.car.app.model.Row
import androidx.car.app.model.Template
import androidx.lifecycle.lifecycleScope
import io.homeassistant.companion.android.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.domain
import kotlinx.coroutines.launch
import java.util.Locale
@RequiresApi(Build.VERSION_CODES.O)
class MainVehicleScreen(
carContext: CarContext,
val integrationRepository: IntegrationRepository,
) : Screen(carContext) {
companion object {
private const val TAG = "MainVehicleScreen"
private val SUPPORTED_DOMAINS = listOf(
"button",
"cover",
"input_boolean",
"light",
"lock",
"scene",
"script",
"switch",
)
}
private val domains = mutableSetOf<String>()
private val entities = mutableMapOf<String, Entity<*>>()
init {
lifecycleScope.launch {
integrationRepository.getEntities()?.forEach { entity ->
val domain = entity.entityId.split(".")[0]
if (domain in SUPPORTED_DOMAINS) {
entities[entity.entityId] = entity
domains.add(domain)
}
}
invalidate()
}
}
override fun onGetTemplate(): Template {
val listBuilder = ItemList.Builder()
domains.forEach { domain ->
val friendlyDomain = domain.split("_").joinToString(" ") { word ->
word.replaceFirstChar {
if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString()
}
}
listBuilder.addItem(
Row.Builder()
.setTitle(friendlyDomain)
.setOnClickListener {
Log.i(TAG, "$domain clicked")
screenManager.push(
EntityGridVehicleScreen(
carContext,
integrationRepository,
friendlyDomain,
entities.filter { it.value.domain == domain }.toMutableMap()
)
)
}
.build()
)
}
// TODO: Add row for zones so we can start navigation?
return ListTemplate.Builder()
.setTitle(carContext.getString(io.homeassistant.companion.android.common.R.string.app_name))
.setHeaderAction(Action.APP_ICON)
.setSingleList(listBuilder.build())
.build()
}
}

View file

@ -840,6 +840,7 @@
android:name=".notifications.NotificationDeleteReceiver"
android:enabled="true"
android:exported="true" />
</application>
</manifest>

View file

@ -94,4 +94,18 @@
<item>@string/tile_39</item>
<item>@string/tile_40</item>
</string-array>
<string-array name="hosts_allowlist" translatable="false">
<item>fdb00c43dbde8b51cb312aa81d3b5fa17713adb94b28f598d77f8eb89daceedf,
com.google.android.projection.gearhead</item>
<item>70811a3eacfd2e83e18da9bfede52df16ce91f2e69a44d21f18ab66991130771,
com.google.android.projection.gearhead</item>
<item>1975b2f17177bc89a5dff31f9e64a6cae281a53dc1d1d59b1d147fe1c82afa00,
com.google.android.projection.gearhead</item>
<item>c241ffbc8e287c4e9a4ad19632ba1b1351ad361d5177b7d7b29859bd2b7fc631,
com.google.android.apps.automotive.templates.host</item>
<item>dd66deaf312d8daec7adbe85a218ecc8c64f3b152f9b5998d5b29300c2623f61,
com.google.android.apps.automotive.templates.host</item>
<item>50e603d333c6049a37bd751375d08f3bd0abebd33facd30bd17b64b89658b421,
com.google.android.apps.automotive.templates.host</item>
</string-array>
</resources>

View file

@ -0,0 +1,3 @@
<automotiveApp>
<uses name="template" />
</automotiveApp>

View file

@ -8,6 +8,7 @@ import com.mikepenz.iconics.typeface.IIcon
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial
import io.homeassistant.companion.android.common.data.websocket.impl.entities.CompressedStateDiff
import java.util.Calendar
import java.util.Locale
import kotlin.math.round
data class Entity<T>(
@ -61,7 +62,8 @@ fun Entity<Map<String, Any>>.applyCompressedStateDiff(diff: CompressedStateDiff)
newLastChanged = calendar
newLastUpdated = calendar
} ?: plus.lastUpdated?.let {
newLastUpdated = Calendar.getInstance().apply { timeInMillis = round(it * 1000).toLong() }
newLastUpdated =
Calendar.getInstance().apply { timeInMillis = round(it * 1000).toLong() }
}
plus.attributes?.let {
newAttributes = newAttributes.plus(it)
@ -144,7 +146,9 @@ fun <T> Entity<T>.getFanSteps(): Int? {
return calculateNumStep(percentageStep * 2)
}
return calculateNumStep(((attributes as Map<*, *>)["percentage_step"] as? Double)?.toDouble() ?: 1.0) - 1
return calculateNumStep(
((attributes as Map<*, *>)["percentage_step"] as? Double)?.toDouble() ?: 1.0
) - 1
} catch (e: Exception) {
Log.e(EntityExt.TAG, "Unable to get getFanSteps")
null
@ -157,7 +161,8 @@ fun <T> Entity<T>.supportsLightBrightness(): Boolean {
// On HA Core 2021.5 and later brightness detection has changed
// to simplify things in the app lets use both methods for now
val supportedColorModes = (attributes as Map<*, *>)["supported_color_modes"] as? List<String>
val supportedColorModes =
(attributes as Map<*, *>)["supported_color_modes"] as? List<String>
val supportsBrightness =
if (supportedColorModes == null) false else (supportedColorModes - EntityExt.LIGHT_MODE_NO_BRIGHTNESS_SUPPORT).isNotEmpty()
val supportedFeatures = attributes["supported_features"] as Int
@ -178,7 +183,8 @@ fun <T> Entity<T>.getLightBrightness(): EntityPosition? {
val minValue = 0f
val maxValue = 100f
val currentValue =
((attributes as Map<*, *>)["brightness"] as? Number)?.toFloat()?.div(255f)?.times(100)
((attributes as Map<*, *>)["brightness"] as? Number)?.toFloat()?.div(255f)
?.times(100)
?: 0f
EntityPosition(
@ -199,8 +205,10 @@ fun <T> Entity<T>.supportsLightColorTemperature(): Boolean {
return try {
if (domain != "light") return false
val supportedColorModes = (attributes as Map<*, *>)["supported_color_modes"] as? List<String>
val supportsColorTemp = supportedColorModes?.contains(EntityExt.LIGHT_MODE_COLOR_TEMP) ?: false
val supportedColorModes =
(attributes as Map<*, *>)["supported_color_modes"] as? List<String>
val supportsColorTemp =
supportedColorModes?.contains(EntityExt.LIGHT_MODE_COLOR_TEMP) ?: false
val supportedFeatures = attributes["supported_features"] as Int
supportsColorTemp || (supportedFeatures and EntityExt.LIGHT_SUPPORT_COLOR_TEMP_DEPR == EntityExt.LIGHT_SUPPORT_COLOR_TEMP_DEPR)
} catch (e: Exception) {
@ -395,3 +403,41 @@ private fun coverIcon(state: String?, entity: Entity<Map<String, Any?>>): IIcon
}
}
}
suspend fun <T> Entity<T>.onPressed(
integrationRepository: IntegrationRepository
) {
val service = when (domain) {
"lock" -> {
if (state == "unlocked") "lock" else "unlock"
}
"cover" -> {
if (state == "open") "close_cover" else "open_cover"
}
"scene",
"script",
"button" -> "press"
"fan",
"input_boolean",
"switch" -> {
if (state == "on") "turn_off" else "turn_on"
}
else -> "toggle"
}
integrationRepository.callService(
domain = this.domain,
service = service,
serviceData = hashMapOf("entity_id" to entityId)
)
}
val <T> Entity<T>.friendlyName: String
get() = (attributes as? Map<*, *>)?.get("friendly_name")?.toString() ?: entityId
val <T> Entity<T>.friendlyState: String
get() = state.split("_").joinToString(" ") { word ->
word.replaceFirstChar {
if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString()
}
}