Skip to content

Commit 5655062

Browse files
TimoPtrjpelgrom
andauthored
Make wake word setup safer (#6520)
--------- Co-authored-by: Joris Pelgröm <jpelgrom@users.noreply.github.com>
1 parent ba3169b commit 5655062

File tree

27 files changed

+396
-41
lines changed

27 files changed

+396
-41
lines changed

app/src/full/kotlin/io/homeassistant/companion/android/FullApplicationModule.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ object FullApplicationModule {
4242
): PlayServicesAvailability {
4343
return PlayServicesAvailability {
4444
GoogleApiAvailability.getInstance()
45-
.isGooglePlayServicesAvailable(context) == ConnectionResult.SUCCESS
45+
.isGooglePlayServicesAvailable(context) != ConnectionResult.SUCCESS
4646
}
4747
}
4848
}

app/src/main/kotlin/io/homeassistant/companion/android/assist/service/AssistVoiceInteractionService.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,10 @@ class AssistVoiceInteractionService : VoiceInteractionService() {
121121
*/
122122
@SuppressLint("MissingPermission")
123123
private fun startListening() {
124+
if (!assistConfigManager.isWakeWordSupported()) {
125+
Timber.d("Wake word detection is not supported on this device")
126+
return
127+
}
124128
if (!hasRecordAudioPermission()) {
125129
Timber.w("RECORD_AUDIO permission not granted, cannot start listening")
126130
return

app/src/main/kotlin/io/homeassistant/companion/android/launch/LaunchActivity.kt

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import androidx.activity.viewModels
99
import androidx.appcompat.app.AppCompatActivity
1010
import androidx.compose.material3.SnackbarDuration
1111
import androidx.compose.material3.SnackbarHostState
12-
import androidx.compose.material3.SnackbarResult
1312
import androidx.compose.material3.SnackbarResult.ActionPerformed
1413
import androidx.compose.runtime.Composable
1514
import androidx.compose.runtime.LaunchedEffect
@@ -23,9 +22,9 @@ import androidx.navigation.NavController
2322
import androidx.navigation.compose.rememberNavController
2423
import dagger.hilt.android.AndroidEntryPoint
2524
import dagger.hilt.android.lifecycle.withCreationCallback
26-
import io.homeassistant.companion.android.BuildConfig
2725
import io.homeassistant.companion.android.common.R as commonR
2826
import io.homeassistant.companion.android.common.compose.theme.HATheme
27+
import io.homeassistant.companion.android.util.PLAY_SERVICES_FLAVOR_DOC_URL
2928
import io.homeassistant.companion.android.util.PlayServicesAvailability
3029
import io.homeassistant.companion.android.util.compose.HAApp
3130
import io.homeassistant.companion.android.util.compose.navigateToUri
@@ -34,7 +33,6 @@ import javax.inject.Inject
3433
import kotlinx.parcelize.Parcelize
3534

3635
private const val DEEP_LINK_KEY = "deep_link_key"
37-
private const val PLAY_SERVICES_FLAVOR_DOC_URL = "https://companion.home-assistant.io/docs/core/android-flavors/"
3836

3937
/**
4038
* Main entry point of the application, it is mostly responsible to hold the whole navigation of the application.
@@ -113,7 +111,7 @@ class LaunchActivity : AppCompatActivity() {
113111
val snackbarHostState = remember { SnackbarHostState() }
114112

115113
MissingPlayServicesNotice(
116-
isPlayServicesAvailable = playServicesAvailability.isAvailable(),
114+
isMissingRequiredPlayServices = playServicesAvailability.isMissingRequiredPlayServices(),
117115
snackbarHostState = snackbarHostState,
118116
navController = navController,
119117
)
@@ -136,14 +134,11 @@ class LaunchActivity : AppCompatActivity() {
136134

137135
@Composable
138136
private fun MissingPlayServicesNotice(
139-
isPlayServicesAvailable: Boolean,
137+
isMissingRequiredPlayServices: Boolean,
140138
snackbarHostState: SnackbarHostState,
141139
navController: NavController,
142140
) {
143-
val shouldShowPlayServicesSnackbar =
144-
BuildConfig.FLAVOR == "full" && !isPlayServicesAvailable
145-
146-
if (shouldShowPlayServicesSnackbar) {
141+
if (isMissingRequiredPlayServices) {
147142
val message = stringResource(commonR.string.play_services_unavailable_full_flavor)
148143
val learnMore = stringResource(commonR.string.learn_more)
149144
LaunchedEffect(message) {

app/src/main/kotlin/io/homeassistant/companion/android/settings/assist/AssistConfigManager.kt

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,23 @@ import io.homeassistant.companion.android.assist.service.AssistVoiceInteractionS
88
import io.homeassistant.companion.android.assist.wakeword.MicroWakeWordModelConfig
99
import io.homeassistant.companion.android.common.data.prefs.PrefsRepository
1010
import io.homeassistant.companion.android.common.util.SuspendLazy
11+
import io.homeassistant.companion.android.microfrontend.isMicroFrontendSupported
1112
import javax.inject.Inject
1213

1314
/**
1415
* Manager for Assist settings and wake word model information.
1516
*/
1617
interface AssistConfigManager {
1718
/**
18-
* Returns a list of all available wake word models.
19+
* Returns whether the device supports wake word detection.
20+
*
21+
* Wake word detection requires the MicroFrontend native library, which is only
22+
* available on 64-bit architectures (arm64-v8a, x86_64).
23+
*/
24+
fun isWakeWordSupported(): Boolean
25+
26+
/**
27+
* Returns a list of all available wake word models. On unsupported devices, an empty list is returned.
1928
*/
2029
suspend fun getAvailableModels(): List<MicroWakeWordModelConfig>
2130

@@ -33,10 +42,8 @@ interface AssistConfigManager {
3342
suspend fun setWakeWordEnabled(enabled: Boolean)
3443

3544
/**
36-
* Returns the currently selected wake word model.
37-
*
38-
* Returns null if no model is selected or if the previously selected model
39-
* is no longer available.
45+
* Returns the currently selected wake word model or null if no model is selected
46+
* or the previously selected model is no longer available.
4047
*/
4148
suspend fun getSelectedWakeWordModel(): MicroWakeWordModelConfig?
4249

@@ -57,7 +64,12 @@ class AssistConfigManagerImpl @Inject constructor(
5764

5865
private val models = SuspendLazy { MicroWakeWordModelConfig.loadAvailableModels(context) }
5966

60-
override suspend fun getAvailableModels(): List<MicroWakeWordModelConfig> = models.get()
67+
override fun isWakeWordSupported(): Boolean = isMicroFrontendSupported
68+
69+
override suspend fun getAvailableModels(): List<MicroWakeWordModelConfig> {
70+
if (!isWakeWordSupported()) return emptyList()
71+
return models.get()
72+
}
6173

6274
override suspend fun isWakeWordEnabled(): Boolean = prefsRepository.isWakeWordEnabled()
6375

app/src/main/kotlin/io/homeassistant/companion/android/settings/assist/AssistSettingsScreen.kt

Lines changed: 107 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package io.homeassistant.companion.android.settings.assist
22

33
import android.Manifest
4+
import android.content.Context
45
import android.content.Intent
56
import android.net.Uri
67
import android.provider.Settings
@@ -11,7 +12,6 @@ import androidx.compose.animation.fadeIn
1112
import androidx.compose.animation.fadeOut
1213
import androidx.compose.foundation.clickable
1314
import androidx.compose.foundation.layout.Arrangement
14-
import androidx.compose.foundation.layout.Box
1515
import androidx.compose.foundation.layout.Column
1616
import androidx.compose.foundation.layout.ColumnScope
1717
import androidx.compose.foundation.layout.PaddingValues
@@ -57,17 +57,22 @@ import com.google.accompanist.permissions.isGranted
5757
import com.google.accompanist.permissions.rememberPermissionState
5858
import io.homeassistant.companion.android.assist.wakeword.MicroWakeWordModelConfig
5959
import io.homeassistant.companion.android.common.R as commonR
60+
import io.homeassistant.companion.android.common.compose.composable.ButtonSize
61+
import io.homeassistant.companion.android.common.compose.composable.HABanner
6062
import io.homeassistant.companion.android.common.compose.composable.HAFilledButton
6163
import io.homeassistant.companion.android.common.compose.composable.HAHint
6264
import io.homeassistant.companion.android.common.compose.composable.HALabel
6365
import io.homeassistant.companion.android.common.compose.composable.HALoading
66+
import io.homeassistant.companion.android.common.compose.composable.HAPlainButton
6467
import io.homeassistant.companion.android.common.compose.composable.HASettingsCard
6568
import io.homeassistant.companion.android.common.compose.composable.HASwitch
6669
import io.homeassistant.companion.android.common.compose.composable.LabelVariant
6770
import io.homeassistant.companion.android.common.compose.theme.HADimens
6871
import io.homeassistant.companion.android.common.compose.theme.HATextStyle
6972
import io.homeassistant.companion.android.common.compose.theme.HAThemeForPreview
7073
import io.homeassistant.companion.android.common.compose.theme.LocalHAColorScheme
74+
import io.homeassistant.companion.android.common.util.openUri
75+
import io.homeassistant.companion.android.util.PLAY_SERVICES_FLAVOR_DOC_URL
7176
import io.homeassistant.companion.android.util.plus
7277
import io.homeassistant.companion.android.util.safeBottomPaddingValues
7378
import kotlinx.coroutines.launch
@@ -111,6 +116,8 @@ private fun rememberRecordAudioPermissionState(
111116
fun AssistSettingsScreen(viewModel: AssistSettingsViewModel, modifier: Modifier = Modifier) {
112117
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
113118
val snackbarHostState = remember { SnackbarHostState() }
119+
val context = LocalContext.current
120+
val coroutineContext = rememberCoroutineScope()
114121

115122
val togglePermissionState = rememberRecordAudioPermissionState(snackbarHostState) {
116123
viewModel.onToggleWakeWord(true)
@@ -148,6 +155,11 @@ fun AssistSettingsScreen(viewModel: AssistSettingsViewModel, modifier: Modifier
148155
onSelectWakeWord = viewModel::onSelectWakeWordModel,
149156
onStartTestWakeWord = { viewModel.setTestingWakeWord(true) },
150157
onStopTestWakeWord = { viewModel.setTestingWakeWord(false) },
158+
onLearnMorePlayServices = {
159+
coroutineContext.launch {
160+
openLeanMoreAboutFlavors(context, snackbarHostState)
161+
}
162+
},
151163
modifier = Modifier.padding(contentPadding),
152164
)
153165
}
@@ -164,6 +176,7 @@ internal fun AssistSettingsContent(
164176
onStartTestWakeWord: () -> Unit,
165177
onStopTestWakeWord: () -> Unit,
166178
modifier: Modifier = Modifier,
179+
onLearnMorePlayServices: () -> Unit = {},
167180
) {
168181
Column(
169182
modifier = modifier
@@ -197,14 +210,39 @@ internal fun AssistSettingsContent(
197210
HALabel(stringResource(commonR.string.experimental), variant = LabelVariant.WARNING)
198211
}
199212

200-
WakeWordSection(
201-
uiState = uiState,
202-
hasAudioPermission = hasAudioPermission,
203-
onToggleWakeWord = onToggleWakeWord,
204-
onSelectWakeWord = onSelectWakeWord,
205-
onStartTestWakeWord = onStartTestWakeWord,
206-
onStopTestWakeWord = onStopTestWakeWord,
207-
)
213+
if (uiState.showMissingPlayServicesHint) {
214+
HABanner(modifier = Modifier.fillMaxWidth()) {
215+
Text(
216+
text = stringResource(commonR.string.assist_wake_word_missing_play_services),
217+
style = HATextStyle.Body.copy(textAlign = TextAlign.Start),
218+
modifier = Modifier.weight(1f),
219+
)
220+
HAPlainButton(
221+
text = stringResource(commonR.string.learn_more),
222+
onClick = onLearnMorePlayServices,
223+
size = ButtonSize.SMALL,
224+
)
225+
}
226+
}
227+
228+
if (uiState.isWakeWordSupported) {
229+
WakeWordSection(
230+
uiState = uiState,
231+
hasAudioPermission = hasAudioPermission,
232+
onToggleWakeWord = onToggleWakeWord,
233+
onSelectWakeWord = onSelectWakeWord,
234+
onStartTestWakeWord = onStartTestWakeWord,
235+
onStopTestWakeWord = onStopTestWakeWord,
236+
)
237+
} else if (uiState.showHardwareNotSupportedHint) {
238+
HABanner(modifier = Modifier.fillMaxWidth()) {
239+
Text(
240+
text = stringResource(commonR.string.assist_wake_word_unsupported_device),
241+
style = HATextStyle.Body.copy(textAlign = TextAlign.Start),
242+
modifier = Modifier.weight(1f),
243+
)
244+
}
245+
}
208246
}
209247
}
210248
}
@@ -442,6 +480,19 @@ private fun WakeWordTestSection(
442480
}
443481
}
444482

483+
private suspend fun openLeanMoreAboutFlavors(context: Context, snackbarHostState: SnackbarHostState) {
484+
context.openUri(
485+
PLAY_SERVICES_FLAVOR_DOC_URL,
486+
onShowSnackbar = { message, action ->
487+
snackbarHostState.showSnackbar(
488+
message = message,
489+
actionLabel = action,
490+
duration = SnackbarDuration.Short,
491+
) == SnackbarResult.ActionPerformed
492+
},
493+
)
494+
}
495+
445496
@Preview
446497
@Composable
447498
private fun AssistSettingsContentPreview() {
@@ -487,3 +538,50 @@ private fun AssistSettingsContentNotDefaultPreview() {
487538
)
488539
}
489540
}
541+
542+
@Preview
543+
@Composable
544+
private fun AssistSettingsContentUnsupportedDevicePreview() {
545+
HAThemeForPreview {
546+
AssistSettingsContent(
547+
uiState = AssistSettingsUiState(
548+
isLoading = false,
549+
isDefaultAssistant = true,
550+
showHardwareNotSupportedHint = true,
551+
isWakeWordEnabled = false,
552+
selectedWakeWordModel = null,
553+
availableModels = emptyList(),
554+
),
555+
hasAudioPermission = true,
556+
onSetDefaultAssistant = {},
557+
onToggleWakeWord = {},
558+
onSelectWakeWord = {},
559+
onStartTestWakeWord = {},
560+
onStopTestWakeWord = {},
561+
)
562+
}
563+
}
564+
565+
@Preview
566+
@Composable
567+
private fun AssistSettingsContentMissingPlayServicesPreview() {
568+
HAThemeForPreview {
569+
AssistSettingsContent(
570+
uiState = AssistSettingsUiState(
571+
isLoading = false,
572+
isDefaultAssistant = true,
573+
showMissingPlayServicesHint = true,
574+
isWakeWordEnabled = false,
575+
selectedWakeWordModel = null,
576+
availableModels = emptyList(),
577+
),
578+
hasAudioPermission = true,
579+
onSetDefaultAssistant = {},
580+
onToggleWakeWord = {},
581+
onSelectWakeWord = {},
582+
onStartTestWakeWord = {},
583+
onStopTestWakeWord = {},
584+
onLearnMorePlayServices = {},
585+
)
586+
}
587+
}

app/src/main/kotlin/io/homeassistant/companion/android/settings/assist/AssistSettingsViewModel.kt

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import androidx.lifecycle.ViewModel
77
import androidx.lifecycle.viewModelScope
88
import dagger.hilt.android.lifecycle.HiltViewModel
99
import io.homeassistant.companion.android.assist.wakeword.MicroWakeWordModelConfig
10+
import io.homeassistant.companion.android.util.PlayServicesAvailability
1011
import javax.inject.Inject
1112
import kotlin.time.Duration.Companion.seconds
1213
import kotlinx.coroutines.Job
@@ -22,21 +23,26 @@ import kotlinx.coroutines.launch
2223
*/
2324
data class AssistSettingsUiState(
2425
val isLoading: Boolean = true,
26+
val showHardwareNotSupportedHint: Boolean = false,
27+
val showMissingPlayServicesHint: Boolean = false,
2528
val isDefaultAssistant: Boolean = false,
2629
val isWakeWordEnabled: Boolean = false,
2730
val selectedWakeWordModel: MicroWakeWordModelConfig? = null,
2831
val availableModels: List<MicroWakeWordModelConfig> = emptyList(),
2932
val isTestingWakeWord: Boolean = false,
3033
val wakeWordDetected: Boolean = false,
31-
)
34+
) {
35+
val isWakeWordSupported = !showHardwareNotSupportedHint && !showMissingPlayServicesHint
36+
}
3237

3338
@VisibleForTesting
3439
val WAKE_WORD_TEST_DEBOUNCE = 3.seconds
3540

3641
@HiltViewModel
37-
class AssistSettingsViewModel @Inject constructor(
42+
class AssistSettingsViewModel @Inject internal constructor(
3843
private val defaultAssistantManager: DefaultAssistantManager,
3944
private val assistConfigManager: AssistConfigManager,
45+
private val playServicesAvailability: PlayServicesAvailability,
4046
) : ViewModel() {
4147

4248
private val _uiState = MutableStateFlow(AssistSettingsUiState())
@@ -54,16 +60,20 @@ class AssistSettingsViewModel @Inject constructor(
5460
var isEnabled = assistConfigManager.isWakeWordEnabled()
5561
val selectedModel = assistConfigManager.getSelectedWakeWordModel() ?: models.firstOrNull()
5662
val isDefaultAssistant = defaultAssistantManager.isDefaultAssistant()
63+
val missingPlayServices = playServicesAvailability.isMissingRequiredPlayServices()
64+
val isHWWakeWordSupported = assistConfigManager.isWakeWordSupported()
65+
val isSupported = isHWWakeWordSupported && !missingPlayServices
5766

58-
if (!isDefaultAssistant && isEnabled) {
59-
// The assistant has changed and wake word cannot be enabled
67+
if ((!isDefaultAssistant || !isSupported) && isEnabled) {
6068
assistConfigManager.setWakeWordEnabled(false)
6169
isEnabled = false
6270
}
6371

6472
_uiState.update {
6573
it.copy(
6674
isLoading = false,
75+
showHardwareNotSupportedHint = !isHWWakeWordSupported,
76+
showMissingPlayServicesHint = missingPlayServices,
6777
isDefaultAssistant = defaultAssistantManager.isDefaultAssistant(),
6878
isWakeWordEnabled = isEnabled,
6979
selectedWakeWordModel = selectedModel,
Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
package io.homeassistant.companion.android.util
22

3+
const val PLAY_SERVICES_FLAVOR_DOC_URL = "https://companion.home-assistant.io/docs/core/android-flavors/"
4+
5+
/**
6+
* Abstraction to check whether Google Play Services are expected but unavailable.
7+
*
8+
* On the full flavor this performs the real availability check; on the minimal
9+
* flavor it always returns `false` because Play Services are not required.
10+
*/
311
internal fun interface PlayServicesAvailability {
4-
fun isAvailable(): Boolean
12+
13+
/**
14+
* Returns `true` when Google Play Services are required by the current
15+
* build flavor but are not available on the device.
16+
*/
17+
fun isMissingRequiredPlayServices(): Boolean
518
}

0 commit comments

Comments
 (0)