Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
2fc7c27
Add microfrontend gradle module
TimoPtr Jan 22, 2026
9bf58c9
Add litert deps for full and minimal
TimoPtr Jan 22, 2026
0ee9ddd
PoC of AssistVoiceInteractionService and MicroWakeWord using Claude
TimoPtr Jan 22, 2026
5844a90
Adjust DevPlayground style
TimoPtr Jan 22, 2026
c23a060
Fix lint issues
TimoPtr Jan 22, 2026
6898d02
Revert unwanted commit
TimoPtr Jan 26, 2026
f04966a
Add other models and load configurations
TimoPtr Jan 26, 2026
e0ef0cd
Fix ndk typo
TimoPtr Jan 26, 2026
582ee68
Use MicroWakeWord json config
TimoPtr Jan 26, 2026
2c08d3c
Improve Kotlin code
TimoPtr Jan 26, 2026
639d493
Update Kotlin code for clarity and add tests
TimoPtr Jan 27, 2026
d0dded1
Merge branch 'main' into feature/wake_word
TimoPtr Jan 27, 2026
8eb0aa7
Update lockfile
TimoPtr Jan 27, 2026
6734641
Fix lint issue
TimoPtr Jan 27, 2026
6dc9b72
Fix test setup
TimoPtr Jan 28, 2026
7240608
Handle full flavor test
TimoPtr Jan 28, 2026
9d49b4e
Enable back strict mode
TimoPtr Jan 28, 2026
c7430b9
Merge remote-tracking branch 'origin/main' into feature/wake_word
TimoPtr Jan 28, 2026
a39bd8a
Apply suggestions from code review
TimoPtr Jan 30, 2026
4f537ca
Merge remote-tracking branch 'origin/main' into feature/wake_word
TimoPtr Jan 30, 2026
ca89655
Cleanup docs
TimoPtr Jan 30, 2026
9daf8f4
Bump lockfile
TimoPtr Jan 30, 2026
78c443b
Merge branch 'main' into feature/wake_word
TimoPtr Feb 5, 2026
6faa7e9
Update lockfile
TimoPtr Feb 5, 2026
3ae2386
Apply PR suggestions and add a test to avoid duplicate channels
TimoPtr Feb 5, 2026
011ca85
Remove automotive service
TimoPtr Feb 5, 2026
039d041
Merge branch 'main' into feature/wake_word
TimoPtr Feb 9, 2026
9e80b5a
Update lockfiles
TimoPtr Feb 9, 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
10 changes: 5 additions & 5 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -247,23 +247,23 @@ jobs:
arch: x86_64
profile: "Nexus 5"
target: "google_apis"
gradle_target: ":app:connectedFullDebugAndroidTest :app:connectedMinimalDebugAndroidTest :common:connectedDebugAndroidTest"
gradle_target: ":app:connectedFullDebugAndroidTest :app:connectedMinimalDebugAndroidTest :common:connectedDebugAndroidTest :microfrontend:connectedDebugAndroidTest"
- api-level: 36
arch: x86_64
profile: "pixel_7"
target: "google_apis"
gradle_target: ":app:connectedFullDebugAndroidTest :app:connectedMinimalDebugAndroidTest :common:connectedDebugAndroidTest"
gradle_target: ":app:connectedFullDebugAndroidTest :app:connectedMinimalDebugAndroidTest :common:connectedDebugAndroidTest :microfrontend:connectedDebugAndroidTest"
- api-level: 33
arch: x86_64
profile: "automotive_1024p_landscape"
target: "android-automotive"
gradle_target: ":automotive:connectedFullDebugAndroidTest :automotive:connectedMinimalDebugAndroidTest :common:connectedDebugAndroidTest"
gradle_target: ":automotive:connectedFullDebugAndroidTest :automotive:connectedMinimalDebugAndroidTest :common:connectedDebugAndroidTest :microfrontend:connectedDebugAndroidTest"
- api-level: "34"
system-image-api-level: "34-ext9"
arch: x86_64
profile: "automotive_1024p_landscape"
target: "android-automotive"
gradle_target: ":automotive:connectedFullDebugAndroidTest :automotive:connectedMinimalDebugAndroidTest :common:connectedDebugAndroidTest"
gradle_target: ":automotive:connectedFullDebugAndroidTest :automotive:connectedMinimalDebugAndroidTest :common:connectedDebugAndroidTest :microfrontend:connectedDebugAndroidTest"
- api-level: 26
arch: x86
profile: "wearos_square"
Expand All @@ -273,7 +273,7 @@ jobs:
arch: x86_64
profile: "wearos_small_round"
target: "android-wear"
gradle_target: ":wear:connectedDebugAndroidTest :common:connectedDebugAndroidTest"
gradle_target: ":wear:connectedDebugAndroidTest :common:connectedDebugAndroidTest :microfrontend:connectedDebugAndroidTest"
steps:
- name: Delete unnecessary tools 🔧
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
# Compiled class file
*.class

### CPP ###
**/.cxx/*

# Log file
*.log

Expand Down
136 changes: 77 additions & 59 deletions app/gradle.lockfile

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package io.homeassistant.companion.android.assist.wakeword

import android.content.Context
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import io.homeassistant.companion.android.BuildConfig
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Test
import org.junit.runner.RunWith
import timber.log.Timber

@RunWith(AndroidJUnit4::class)
class MicroWakeWordModelTest {

private val appContext: Context
get() = InstrumentationRegistry.getInstrumentation().targetContext

/**
* Initializes TfLite and returns true if successful.
* Returns false if initialization fails (e.g., full flavor without GMS).
*/
private suspend fun tryInitializeTfLite(): Boolean {
return try {
TfLiteInitializerImpl().initialize(appContext)
true
} catch (e: Exception) {
Timber.w(e, "TfLite initialization failed, skipping test")
if (BuildConfig.FLAVOR == "full") {
false
} else {
// In minimal the test should run since we use the embedded version of TfLite
throw e
}
}
}

@Test
fun loadsModelsFromAppAssets_verify_models_config_files() = runTest {
val models = MicroWakeWordModelConfig.loadAvailableModels(appContext)

assertTrue("Expected at least one wake word model", models.isNotEmpty())

// Verify each model has required fields populated
for (model in models) {
assertTrue("Wake word should not be blank", model.wakeWord.isNotBlank())
assertTrue("Author should not be blank", model.author.isNotBlank())
assertTrue("Website should not be blank", model.website.isNotBlank())
assertTrue("Model file name should not be blank", model.model.isNotBlank())
assertTrue("Trained languages should not be empty", model.trainedLanguages.isNotEmpty())
assertTrue("Version should be positive", model.version > 0)
assertTrue("Probability cutoff should be between 0 and 1", model.micro.probabilityCutoff in 0f..1f)
assertTrue("Sliding window size should be positive", model.micro.slidingWindowSize > 0)
assertTrue("Feature step size should be positive", model.micro.featureStepSize > 0)
}
}

@Test
fun microWakeWord_loadsAndProcessesAudio_withAllModels() = runTest {
assumeTrue("TfLite not available", tryInitializeTfLite())
val models = MicroWakeWordModelConfig.loadAvailableModels(appContext)

for (model in models) {
val detector = MicroWakeWord.create(appContext, model)

try {
// Process silent audio (160 samples = 10ms at 16kHz)
val silentAudio = ShortArray(160)
val detected = detector.processAudio(silentAudio)

// Silent audio should not trigger detection
assertFalse(
"Silent audio should not trigger '${model.wakeWord}' detection",
detected,
)
} finally {
detector.close()
}
}
}

@Test
fun microWakeWord_canResetState_withoutCrashing() = runTest {
assumeTrue("TfLite not available", tryInitializeTfLite())
val models = MicroWakeWordModelConfig.loadAvailableModels(appContext)
val model = models.first()

val detector = MicroWakeWord.create(appContext, model)
try {
// Process some audio
detector.processAudio(ShortArray(160))
detector.processAudio(ShortArray(160))

// Reset should not crash
detector.reset()

// Should be able to process more audio after reset
val detected = detector.processAudio(ShortArray(160))
assertFalse(detected)
} finally {
detector.close()
}
}
}
Original file line number Diff line number Diff line change
@@ -1,29 +1,40 @@
package io.homeassistant.companion.android.developer

import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.foundation.layout.safeContent
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import io.homeassistant.companion.android.assist.service.AssistVoiceInteractionService
import io.homeassistant.companion.android.barcode.BarcodeScannerActivity
import io.homeassistant.companion.android.common.compose.composable.ButtonVariant
import io.homeassistant.companion.android.common.compose.composable.HAFilledButton
import io.homeassistant.companion.android.common.compose.theme.HATheme
import io.homeassistant.companion.android.common.compose.theme.HAThemeForPreview
import io.homeassistant.companion.android.common.util.FailFast
import io.homeassistant.companion.android.developer.catalog.HAComposeCatalogActivity
import io.homeassistant.companion.android.settings.SettingsActivity
import io.homeassistant.companion.android.util.compose.HomeAssistantAppTheme
import io.homeassistant.companion.android.util.enableEdgeToEdgeCompat
import kotlinx.coroutines.launch

/**
* This activity is meant to host a playground for development purposes.
Expand All @@ -40,7 +51,7 @@ class DevPlaygroundActivity : AppCompatActivity() {
enableEdgeToEdgeCompat()

setContent {
HomeAssistantAppTheme {
HATheme {
DevPlayGroundScreen(this)
}
}
Expand All @@ -51,51 +62,117 @@ private class DummyException : Throwable()

@Composable
private fun DevPlayGroundScreen(context: Context? = null) {
Column(
modifier = Modifier
.padding(WindowInsets.systemBars.asPaddingValues())
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Button(modifier = Modifier.padding(top = 16.dp), onClick = {
throw DummyException()
}) {
Text("Crash the app")
}
Button(modifier = Modifier.padding(top = 16.dp), onClick = {
context?.run { startActivity(Intent(context, DemoExoPlayerActivity::class.java)) }
}) {
Text("Demo ExoPlayer")
}
Button(modifier = Modifier.padding(top = 16.dp), onClick = {
context?.run { startActivity(SettingsActivity.newInstance(context)) }
}) {
Text("Start Settings")
}
Button(modifier = Modifier.padding(top = 16.dp), onClick = {
context?.run { startActivity(BarcodeScannerActivity.newInstance(this, 0, "Title", "Subtitle", "Action")) }
}) {
Text("Start barcode")
}
Button(modifier = Modifier.padding(top = 16.dp), onClick = {
FailFast.failWhen(true) {
"This should stop the process."
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()

HATheme {
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
contentWindowInsets = WindowInsets.safeContent,
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.padding(horizontal = 16.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
HAFilledButton(
text = "Crash the app",
onClick = { throw DummyException() },
variant = ButtonVariant.DANGER,
modifier = Modifier.padding(top = 16.dp),
)
HAFilledButton(
text = "Demo ExoPlayer",
onClick = {
context?.run { startActivity(Intent(context, DemoExoPlayerActivity::class.java)) }
},
)
HAFilledButton(
text = "Start Settings",
onClick = {
context?.run { startActivity(SettingsActivity.newInstance(context)) }
},
)
HAFilledButton(
text = "Start barcode",
onClick = {
context?.run {
startActivity(BarcodeScannerActivity.newInstance(this, 0, "Title", "Subtitle", "Action"))
}
},
)
HAFilledButton(
text = "Fail fast",
onClick = {
FailFast.failWhen(true) {
"This should stop the process."
}
},
variant = ButtonVariant.DANGER,
)
HAFilledButton(
text = "Start HA Compose Catalog",
onClick = {
context?.run { startActivity(Intent(this, HAComposeCatalogActivity::class.java)) }
},
)
HAFilledButton(
text = "Check VoiceInteractionService",
onClick = {
context?.let { ctx ->
val isActive = AssistVoiceInteractionService.isActiveService(ctx)
scope.launch {
snackbarHostState.showSnackbar("VoiceInteractionService active: $isActive")
}
}
},
)
HAFilledButton(
text = "Start Wake Word Detection",
onClick = {
context?.let { ctx ->
val hasPermission = ContextCompat.checkSelfPermission(
ctx,
Manifest.permission.RECORD_AUDIO,
) == PackageManager.PERMISSION_GRANTED

if (hasPermission) {
AssistVoiceInteractionService.startListening(ctx)
scope.launch {
snackbarHostState.showSnackbar("Listening for wake word")
}
} else {
scope.launch {
snackbarHostState.showSnackbar("Microphone permission not granted")
}
}
}
},
)
HAFilledButton(
text = "Stop Wake Word Detection",
onClick = {
context?.let { ctx ->
AssistVoiceInteractionService.stopListening(ctx)
scope.launch {
snackbarHostState.showSnackbar("Stopped listening")
}
}
},
variant = ButtonVariant.WARNING,
)
}
}) {
Text("Fail fast")
}
Button(modifier = Modifier.padding(top = 16.dp), onClick = {
context?.run { startActivity(Intent(this, HAComposeCatalogActivity::class.java)) }
}) {
Text("Start HA Compose Catalog")
}
}
}

@Preview
@Composable
private fun DevPlayGroundScreenPreview() {
HomeAssistantAppTheme {
HAThemeForPreview {
DevPlayGroundScreen()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package io.homeassistant.companion.android.assist.wakeword

import android.content.Context
import com.google.android.gms.tflite.java.TfLite
import kotlinx.coroutines.tasks.await
import timber.log.Timber

/**
* TFLite initializer for the full flavor using Google Play Services.
*
* Play Services TFLite downloads the runtime on demand, which requires
* async initialization before the interpreter can be used.
*/
class TfLiteInitializerImpl : TfLiteInitializer {

override suspend fun initialize(context: Context) {
Timber.d("Initializing TFLite via Play Services")
try {
TfLite.initialize(context).await()
Timber.d("TFLite Play Services initialized successfully")
} catch (e: Exception) {
Timber.e(e, "Failed to initialize TFLite Play Services")
throw e
}
}
}
Loading
Loading