From 052fbe1d74681df4f408d437ba782b0dfe965e4f Mon Sep 17 00:00:00 2001 From: Timothy <6560631+TimoPtr@users.noreply.github.com> Date: Wed, 27 May 2026 17:10:09 +0200 Subject: [PATCH 1/2] Prevent unregistering actionReceiver twice and handle weird lifecycle --- .../service/AssistVoiceInteractionService.kt | 38 ++++++++++++- .../AssistVoiceInteractionServiceTest.kt | 56 +++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/assist/service/AssistVoiceInteractionService.kt b/app/src/main/kotlin/io/homeassistant/companion/android/assist/service/AssistVoiceInteractionService.kt index 0601410d784..bc06f73ab24 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/assist/service/AssistVoiceInteractionService.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/assist/service/AssistVoiceInteractionService.kt @@ -68,11 +68,27 @@ class AssistVoiceInteractionService : VoiceInteractionService() { } private var isServiceReady = false + /** + * One-way latch set once the service has begun tearing down (via [onShutdown] or [onDestroy]). + * + * The platform documents that [onReady] may be delivered after teardown and that no fixed + * lifecycle order can be relied upon. Because [serviceScope] is cancelled during teardown and + * cannot be reused, the instance can never function again, so any such late [onReady] is ignored. + */ + private var isTornDown = false + /** Non-null only while the receiver is registered (between [onReady] and [onShutdown]). */ private var actionReceiver: BroadcastReceiver? = null override fun onReady() { super.onReady() + if (isTornDown) { + // The system can deliver onReady even after onShutdown/onDestroy while it is winding the + // service down. Registering a receiver on the dying context would later crash in + // onShutdown, so ignore this spurious signal + Timber.w("Ignoring onReady delivered after shutdown") + return + } isServiceReady = true Timber.d("VoiceInteractionService is ready") actionReceiver = object : BroadcastReceiver() { @@ -102,14 +118,32 @@ class AssistVoiceInteractionService : VoiceInteractionService() { override fun onShutdown() { super.onShutdown() + isTornDown = true isServiceReady = false Timber.d("VoiceInteractionService is shutting down") - actionReceiver?.let(::unregisterReceiver) - actionReceiver = null + actionReceiver?.let { receiver -> + actionReceiver = null + try { + unregisterReceiver(receiver) + } catch (e: IllegalArgumentException) { + // On some devices the framework tears down the receiver registration before + // onShutdown() runs while the service instance (and this field) survives. There is + // no API to query whether a receiver is still registered, so swallowing this is the + // documented way to handle the resulting "Receiver not registered" error + Timber.w(e, "Action receiver was already unregistered") + } + } // Don't use stopListening() as it launches a coroutine that may not complete before cancel serviceScope.cancel() } + override fun onDestroy() { + // onShutdown is not guaranteed to run before onDestroy, so latch teardown here too to keep + // a late onReady from re-initializing an already-destroyed instance + isTornDown = true + super.onDestroy() + } + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { // Fallback for commands delivered via startService() when the service is already running handleAction(intent?.action) diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/assist/service/AssistVoiceInteractionServiceTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/assist/service/AssistVoiceInteractionServiceTest.kt index 3691fd7b1be..d1ab002f1c8 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/assist/service/AssistVoiceInteractionServiceTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/assist/service/AssistVoiceInteractionServiceTest.kt @@ -328,6 +328,62 @@ class AssistVoiceInteractionServiceTest { service.onShutdown() } + @Test + fun `Given service shut down when onReady delivered again then do not register receiver`() = runTest { + service.onReady() + advanceUntilIdle() + service.onShutdown() + advanceUntilIdle() + + service.onReady() + advanceUntilIdle() + + assertTrue(ACTION_START_LISTENING !in getRegisteredReceiverActions()) + } + + @Test + fun `Given service shut down when onReady delivered and wake word enabled then do not start listening`() = runTest { + coEvery { assistConfigManager.isWakeWordEnabled() } returns true + coEvery { assistConfigManager.getSelectedWakeWordModel() } returns microWakeWordModelConfigs[0] + + service.onShutdown() + advanceUntilIdle() + + service.onReady() + advanceUntilIdle() + + coVerify(exactly = 0) { wakeWordListener.start(any(), any()) } + } + + @Test + fun `Given service destroyed when onReady delivered again then do not register receiver`() = runTest { + service.onDestroy() + + service.onReady() + advanceUntilIdle() + + assertTrue(ACTION_START_LISTENING !in getRegisteredReceiverActions()) + } + + @Test + fun `Given receiver already unregistered when onShutdown then do not crash`() = runTest { + service.onReady() + advanceUntilIdle() + + // Simulate the framework removing the registration out from under the service + val application = ApplicationProvider.getApplicationContext() + val receiver = Shadows.shadowOf(application).registeredReceivers + .first { wrapper -> + (0 until wrapper.intentFilter.countActions()) + .any { wrapper.intentFilter.getAction(it) == ACTION_START_LISTENING } + } + .broadcastReceiver + application.unregisterReceiver(receiver) + + // Should not throw + service.onShutdown() + } + @Test fun `Given context when startListening then send START_LISTENING broadcast with package`() { assertAction(ACTION_START_LISTENING, AssistVoiceInteractionService::startListening) From dce23fd69a41c29a3337b2bb9cfeb4017a15b884 Mon Sep 17 00:00:00 2001 From: Timothy <6560631+TimoPtr@users.noreply.github.com> Date: Sat, 30 May 2026 19:41:48 +0200 Subject: [PATCH 2/2] Update app/src/main/kotlin/io/homeassistant/companion/android/assist/service/AssistVoiceInteractionService.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Joris Pelgröm --- .../assist/service/AssistVoiceInteractionService.kt | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/assist/service/AssistVoiceInteractionService.kt b/app/src/main/kotlin/io/homeassistant/companion/android/assist/service/AssistVoiceInteractionService.kt index bc06f73ab24..20e8b19f427 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/assist/service/AssistVoiceInteractionService.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/assist/service/AssistVoiceInteractionService.kt @@ -68,13 +68,7 @@ class AssistVoiceInteractionService : VoiceInteractionService() { } private var isServiceReady = false - /** - * One-way latch set once the service has begun tearing down (via [onShutdown] or [onDestroy]). - * - * The platform documents that [onReady] may be delivered after teardown and that no fixed - * lifecycle order can be relied upon. Because [serviceScope] is cancelled during teardown and - * cannot be reused, the instance can never function again, so any such late [onReady] is ignored. - */ + /** One-way latch set once the service has begun tearing down (via [onShutdown] or [onDestroy]). */ private var isTornDown = false /** Non-null only while the receiver is registered (between [onReady] and [onShutdown]). */