From 48a7755e6251e1f629d8827a45bb041391fca284 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Fri, 30 Jun 2023 04:19:13 +0200 Subject: [PATCH] Add shortcut for Assist (#3615) - Adds a shortcut to the app that allows accessing Assist from the home screen, including options for pipeline and voice/text --- .../mipmap-anydpi-v26/ic_assist_launcher.xml | 6 + app/src/main/AndroidManifest.xml | 11 ++ .../shortcuts/ManageShortcutsViewModel.kt | 11 +- ...DropdownMenu.kt => ExposedDropdownMenu.kt} | 26 +++- .../widgets/assist/AssistShortcutActivity.kt | 64 ++++++++ .../widgets/assist/AssistShortcutView.kt | 140 ++++++++++++++++++ .../widgets/assist/AssistShortcutViewModel.kt | 63 ++++++++ .../mipmap-anydpi-v26/ic_assist_launcher.xml | 6 + .../ic_assist_launcher_foreground.xml | 13 ++ common/src/main/res/values/strings.xml | 3 + .../ic_assist_launcher_foreground.xml | 13 -- 11 files changed, 335 insertions(+), 21 deletions(-) create mode 100644 app/src/debug/res/mipmap-anydpi-v26/ic_assist_launcher.xml rename app/src/main/java/io/homeassistant/companion/android/util/compose/{ServerExposedDropdownMenu.kt => ExposedDropdownMenu.kt} (71%) create mode 100644 app/src/main/java/io/homeassistant/companion/android/widgets/assist/AssistShortcutActivity.kt create mode 100644 app/src/main/java/io/homeassistant/companion/android/widgets/assist/AssistShortcutView.kt create mode 100644 app/src/main/java/io/homeassistant/companion/android/widgets/assist/AssistShortcutViewModel.kt create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_assist_launcher.xml create mode 100644 common/src/main/res/drawable/ic_assist_launcher_foreground.xml delete mode 100755 wear/src/main/res/drawable/ic_assist_launcher_foreground.xml diff --git a/app/src/debug/res/mipmap-anydpi-v26/ic_assist_launcher.xml b/app/src/debug/res/mipmap-anydpi-v26/ic_assist_launcher.xml new file mode 100644 index 000000000..7652e59e9 --- /dev/null +++ b/app/src/debug/res/mipmap-anydpi-v26/ic_assist_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a01f67e80..9399b9f0f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -397,6 +397,17 @@ + + + + + + ()!! val canPinShortcuts = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && shortcutManager.isRequestPinShortcutSupported - var pinnedShortcuts: MutableList = shortcutManager.pinnedShortcuts + var pinnedShortcuts = shortcutManager.pinnedShortcuts + .filter { !it.id.startsWith(AssistShortcutActivity.SHORTCUT_PREFIX) } + .toMutableList() private set var dynamicShortcuts: MutableList = shortcutManager.dynamicShortcuts private set @@ -227,6 +230,10 @@ class ManageShortcutsViewModel @Inject constructor( } fun updatePinnedShortcuts() { - pinnedShortcuts = shortcutManager.pinnedShortcuts + pinnedShortcuts.clear() + pinnedShortcuts.addAll( + shortcutManager.pinnedShortcuts + .filter { !it.id.startsWith(AssistShortcutActivity.SHORTCUT_PREFIX) } + ) } } diff --git a/app/src/main/java/io/homeassistant/companion/android/util/compose/ServerExposedDropdownMenu.kt b/app/src/main/java/io/homeassistant/companion/android/util/compose/ExposedDropdownMenu.kt similarity index 71% rename from app/src/main/java/io/homeassistant/companion/android/util/compose/ServerExposedDropdownMenu.kt rename to app/src/main/java/io/homeassistant/companion/android/util/compose/ExposedDropdownMenu.kt index 91be18eba..58aa9a92e 100644 --- a/app/src/main/java/io/homeassistant/companion/android/util/compose/ServerExposedDropdownMenu.kt +++ b/app/src/main/java/io/homeassistant/companion/android/util/compose/ExposedDropdownMenu.kt @@ -20,7 +20,7 @@ import io.homeassistant.companion.android.common.R as commonR @OptIn(ExperimentalMaterialApi::class) @Composable -fun ServerExposedDropdownMenu(servers: List, current: Int?, onSelected: (Int) -> Unit, modifier: Modifier = Modifier) { +fun ExposedDropdownMenu(label: String, keys: List, currentIndex: Int?, onSelected: (Int) -> Unit, modifier: Modifier = Modifier) { var expanded by remember { mutableStateOf(false) } val focusManager = LocalFocusManager.current ExposedDropdownMenuBox( @@ -30,23 +30,37 @@ fun ServerExposedDropdownMenu(servers: List, current: Int?, onSelected: ) { OutlinedTextField( readOnly = true, - value = servers.firstOrNull { it.id == current }?.friendlyName ?: "", + value = currentIndex?.let { keys[it] } ?: "", onValueChange = { }, - label = { Text(stringResource(commonR.string.server_select)) }, + label = { Text(label) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), modifier = Modifier.fillMaxWidth() ) ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - servers.forEach { server -> + keys.forEachIndexed { index, key -> DropdownMenuItem(onClick = { - onSelected(server.id) + onSelected(index) expanded = false focusManager.clearFocus() }) { - Text(server.friendlyName) + Text(key) } } } } } + +@Composable +fun ServerExposedDropdownMenu(servers: List, current: Int?, onSelected: (Int) -> Unit, modifier: Modifier = Modifier) { + val keys = servers.map { it.friendlyName } + val ids = servers.map { it.id } + val currentIndex = servers.indexOfFirst { it.id == current }.takeUnless { it == -1 } + ExposedDropdownMenu( + label = stringResource(commonR.string.server_select), + keys = keys, + currentIndex = currentIndex, + onSelected = { onSelected(ids[it]) }, + modifier = modifier + ) +} diff --git a/app/src/main/java/io/homeassistant/companion/android/widgets/assist/AssistShortcutActivity.kt b/app/src/main/java/io/homeassistant/companion/android/widgets/assist/AssistShortcutActivity.kt new file mode 100644 index 000000000..33df1e01c --- /dev/null +++ b/app/src/main/java/io/homeassistant/companion/android/widgets/assist/AssistShortcutActivity.kt @@ -0,0 +1,64 @@ +package io.homeassistant.companion.android.widgets.assist + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.drawable.IconCompat +import com.google.accompanist.themeadapter.material.MdcTheme +import dagger.hilt.android.AndroidEntryPoint +import io.homeassistant.companion.android.BaseActivity +import io.homeassistant.companion.android.R +import io.homeassistant.companion.android.assist.AssistActivity +import java.util.UUID + +@AndroidEntryPoint +class AssistShortcutActivity : BaseActivity() { + + companion object { + const val SHORTCUT_PREFIX = ".ha_assist_" + } + + val viewModel: AssistShortcutViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + MdcTheme { + AssistShortcutView( + selectedServerId = viewModel.serverId, + servers = viewModel.servers, + supported = viewModel.supported, + pipelines = viewModel.pipelines, + onSetServer = viewModel::setServer, + onSubmit = this::setShortcutAndFinish + ) + } + } + } + + private fun setShortcutAndFinish(name: String, serverId: Int, pipelineId: String?, startListening: Boolean) { + val assistIntent = AssistActivity.newInstance( + context = this, + serverId = serverId, + pipelineId = pipelineId, + startListening = startListening, + fromFrontend = false + ).apply { + action = Intent.ACTION_VIEW + } + val shortcutInfo = ShortcutInfoCompat.Builder(this, "$SHORTCUT_PREFIX${UUID.randomUUID()}") + .setIntent(assistIntent) + .setShortLabel(name) + .setLongLabel(name) + .setIcon(IconCompat.createWithResource(this, R.mipmap.ic_assist_launcher)) + .build() + val resultIntent = ShortcutManagerCompat.createShortcutResultIntent(this, shortcutInfo) + setResult(Activity.RESULT_OK, resultIntent) + finish() + } +} diff --git a/app/src/main/java/io/homeassistant/companion/android/widgets/assist/AssistShortcutView.kt b/app/src/main/java/io/homeassistant/companion/android/widgets/assist/AssistShortcutView.kt new file mode 100644 index 000000000..64bd98701 --- /dev/null +++ b/app/src/main/java/io/homeassistant/companion/android/widgets/assist/AssistShortcutView.kt @@ -0,0 +1,140 @@ +package io.homeassistant.companion.android.widgets.assist + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Scaffold +import androidx.compose.material.Switch +import androidx.compose.material.SwitchDefaults +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.homeassistant.companion.android.common.data.websocket.impl.entities.AssistPipelineListResponse +import io.homeassistant.companion.android.database.server.Server +import io.homeassistant.companion.android.util.compose.ExposedDropdownMenu +import io.homeassistant.companion.android.util.compose.ServerExposedDropdownMenu +import io.homeassistant.companion.android.common.R as commonR + +@Composable +fun AssistShortcutView( + selectedServerId: Int, + servers: List, + supported: Boolean?, + pipelines: AssistPipelineListResponse?, + onSetServer: (Int) -> Unit, + onSubmit: (String, Int, String?, Boolean) -> Unit +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(commonR.string.assist_shortcut)) }, + backgroundColor = colorResource(commonR.color.colorBackground), + contentColor = colorResource(commonR.color.colorOnBackground) + ) + } + ) { padding -> + Box(modifier = Modifier.padding(padding).verticalScroll(rememberScrollState())) { + Column(modifier = Modifier.padding(all = 16.dp)) { + val assist = stringResource(commonR.string.assist) + var name by rememberSaveable { mutableStateOf(assist) } + var startListening by rememberSaveable { mutableStateOf(true) } + var pipelineId by rememberSaveable(selectedServerId) { mutableStateOf(null) } + + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text(stringResource(commonR.string.widget_text_hint_label)) }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + ) + if (servers.size > 1) { + ServerExposedDropdownMenu( + servers = servers, + current = selectedServerId, + onSelected = onSetServer, + modifier = Modifier.padding(bottom = 16.dp) + ) + } + if (supported == true) { + if (pipelines != null && pipelines.pipelines.isNotEmpty()) { + ExposedDropdownMenu( + label = stringResource(commonR.string.assist_pipeline), + keys = listOf( + stringResource( + commonR.string.assist_preferred_pipeline, + pipelines.pipelines.first { it.id == pipelines.preferredPipeline }.name + ) + ) + + pipelines.pipelines.map { it.name }, + currentIndex = pipelineId?.let { pid -> 1 + pipelines.pipelines.indexOfFirst { it.id == pid } } + ?: 0, + onSelected = { + pipelineId = if (it == 0) null else pipelines.pipelines[it - 1].id + } + ) + Spacer(modifier = Modifier.height(16.dp)) + } + Row( + modifier = Modifier.clickable { startListening = !startListening } + ) { + Text( + text = stringResource(commonR.string.assist_start_listening), + modifier = Modifier + .weight(1f) + .padding(end = 8.dp) + ) + Switch( + checked = startListening, + onCheckedChange = null, + colors = SwitchDefaults.colors(uncheckedThumbColor = colorResource(commonR.color.colorSwitchUncheckedThumb)) + ) + } + } else if (supported == false) { + Text( + stringResource( + commonR.string.no_assist_support, + "2023.5", + stringResource(commonR.string.no_assist_support_assist_pipeline) + ) + ) + } + + Button( + onClick = { + onSubmit( + name.ifBlank { assist }, + selectedServerId, + pipelineId, + startListening + ) + }, + enabled = supported == true, + modifier = Modifier + .fillMaxWidth() + .padding(top = 32.dp) + ) { + Text(stringResource(commonR.string.add_shortcut)) + } + } + } + } +} diff --git a/app/src/main/java/io/homeassistant/companion/android/widgets/assist/AssistShortcutViewModel.kt b/app/src/main/java/io/homeassistant/companion/android/widgets/assist/AssistShortcutViewModel.kt new file mode 100644 index 000000000..8039f392f --- /dev/null +++ b/app/src/main/java/io/homeassistant/companion/android/widgets/assist/AssistShortcutViewModel.kt @@ -0,0 +1,63 @@ +package io.homeassistant.companion.android.widgets.assist + +import android.app.Application +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import io.homeassistant.companion.android.common.data.servers.ServerManager +import io.homeassistant.companion.android.common.data.websocket.impl.entities.AssistPipelineListResponse +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class AssistShortcutViewModel @Inject constructor( + val serverManager: ServerManager, + application: Application +) : AndroidViewModel(application) { + + var serverId by mutableStateOf(ServerManager.SERVER_ID_ACTIVE) + private set + + var servers by mutableStateOf(serverManager.defaultServers) + private set + + var supported by mutableStateOf(null) + private set + + var pipelines by mutableStateOf(null) + private set + + init { + if (serverManager.isRegistered()) { + serverManager.getServer()?.id?.let { serverId = it } + getData() + } else { + supported = false + } + } + + fun setServer(serverId: Int) { + if (serverId == this.serverId) return + + this.serverId = serverId + getData() + } + + private fun getData() { + viewModelScope.launch { + // Loading states + supported = null + pipelines = null + + // Update data + supported = serverManager.getServer(serverId)?.version?.isAtLeast(2023, 5) == true && + serverManager.webSocketRepository(serverId).getConfig()?.components?.contains("assist_pipeline") == true + if (supported == true) { + pipelines = serverManager.webSocketRepository(serverId).getAssistPipelines() + } + } + } +} diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_assist_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_assist_launcher.xml new file mode 100644 index 000000000..07ecae105 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_assist_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/common/src/main/res/drawable/ic_assist_launcher_foreground.xml b/common/src/main/res/drawable/ic_assist_launcher_foreground.xml new file mode 100644 index 000000000..118dfe875 --- /dev/null +++ b/common/src/main/res/drawable/ic_assist_launcher_foreground.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index 679ba7e9d..cb8c81aad 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -1079,7 +1079,10 @@ How can I assist? Log in to Home Assistant to start using Assist To use Assist with your voice, allow Home Assistant to access the microphone + Assistant + Preferred assistant (%1$s) Send text + Assist shortcut Start listening Stop listening Please open the Home Assistant app and log in to start using Assist diff --git a/wear/src/main/res/drawable/ic_assist_launcher_foreground.xml b/wear/src/main/res/drawable/ic_assist_launcher_foreground.xml deleted file mode 100755 index 78cfe8e88..000000000 --- a/wear/src/main/res/drawable/ic_assist_launcher_foreground.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - \ No newline at end of file