From 06f97f09f6427f312d8fd2f591f1ec56d9c0c5e7 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Thu, 7 May 2026 11:55:08 +0200 Subject: [PATCH 1/2] Distinguish non-admin user from unsupported server in Thread sync When a non-admin account tried to sync Thread credentials, the app showed "The Home Assistant server does not support Thread", which is misleading. Surface a dedicated result and message indicating that managing Thread credentials requires an administrator account. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../companion/android/thread/ThreadManagerImpl.kt | 10 +++++++++- .../developer/DeveloperSettingsPresenterImpl.kt | 5 +++++ .../companion/android/thread/ThreadManager.kt | 1 + common/src/main/res/values/strings.xml | 1 + 4 files changed, 16 insertions(+), 1 deletion(-) diff --git a/app/src/full/kotlin/io/homeassistant/companion/android/thread/ThreadManagerImpl.kt b/app/src/full/kotlin/io/homeassistant/companion/android/thread/ThreadManagerImpl.kt index 6c2c5e70ce0..65f9d746d55 100644 --- a/app/src/full/kotlin/io/homeassistant/companion/android/thread/ThreadManagerImpl.kt +++ b/app/src/full/kotlin/io/homeassistant/companion/android/thread/ThreadManagerImpl.kt @@ -38,6 +38,11 @@ class ThreadManagerImpl @Inject constructor( override suspend fun coreSupportsThread(serverId: Int): Boolean { if (!serverManager.isRegistered() || serverManager.getServer(serverId)?.user?.isAdmin != true) return false + return serverHasThreadComponent(serverId) + } + + private suspend fun serverHasThreadComponent(serverId: Int): Boolean { + if (!serverManager.isRegistered() || serverManager.getServer(serverId) == null) return false val config = serverManager.webSocketRepository(serverId).getConfig() return config != null && config.components.contains("thread") && @@ -54,7 +59,10 @@ class ThreadManagerImpl @Inject constructor( scope: CoroutineScope, ): ThreadManager.SyncResult { if (!appSupportsThread()) return ThreadManager.SyncResult.AppUnsupported - if (!coreSupportsThread(serverId)) return ThreadManager.SyncResult.ServerUnsupported + if (!serverHasThreadComponent(serverId)) return ThreadManager.SyncResult.ServerUnsupported + if (serverManager.getServer(serverId)?.user?.isAdmin != true) { + return ThreadManager.SyncResult.ServerUserNotAdmin + } return if (exportOnly) { // Limited sync, only export non-app dataset exportSyncPreferredDataset(context) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/settings/developer/DeveloperSettingsPresenterImpl.kt b/app/src/main/kotlin/io/homeassistant/companion/android/settings/developer/DeveloperSettingsPresenterImpl.kt index 47edcd6ddd9..301d4ccfb17 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/settings/developer/DeveloperSettingsPresenterImpl.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/settings/developer/DeveloperSettingsPresenterImpl.kt @@ -79,6 +79,11 @@ class DeveloperSettingsPresenterImpl @Inject constructor( context.getString(commonR.string.thread_debug_result_unsupported_server), false, ) + is ThreadManager.SyncResult.ServerUserNotAdmin -> + view.onThreadDebugResult( + context.getString(commonR.string.thread_debug_result_user_not_admin), + false, + ) is ThreadManager.SyncResult.OnlyOnServer -> { if (syncResult.imported) { view.onThreadDebugResult( diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/thread/ThreadManager.kt b/app/src/main/kotlin/io/homeassistant/companion/android/thread/ThreadManager.kt index 3af998d53ff..97e39b1e0a7 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/thread/ThreadManager.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/thread/ThreadManager.kt @@ -11,6 +11,7 @@ interface ThreadManager { sealed class SyncResult { object AppUnsupported : SyncResult() object ServerUnsupported : SyncResult() + object ServerUserNotAdmin : SyncResult() object NotConnected : SyncResult() class OnlyOnServer(val imported: Boolean) : SyncResult() class OnlyOnDevice(val exportIntent: IntentSender?) : SyncResult() diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index 8f2e214a324..d432cc03762 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -1177,6 +1177,7 @@ No credentials to sync Removed old network from Home Assistant on this device The Home Assistant server does not support Thread + Managing Thread credentials requires an administrator account on the Home Assistant server Updated network from Home Assistant on this device Manually update device and server Thread credentials and verify results Imported credential From 4a559155af28f8703b68f2589359cee25c56d07c Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Thu, 7 May 2026 12:53:02 +0200 Subject: [PATCH 2/2] Address review feedback for non-admin Thread sync result - Make coreSupportsThread strictly a server-capability check; gate admin separately at call sites so the contract matches the function name and KDoc. - Surface ServerUserNotAdmin in the WebView/frontend Thread export flow via a new MatterThreadStep so users see a dedicated admin-required dialog instead of the generic Thread error. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../android/thread/ThreadManagerImpl.kt | 16 ++++++---------- .../companion/android/thread/ThreadManager.kt | 4 +++- .../android/webview/MatterThreadStep.kt | 1 + .../companion/android/webview/WebViewActivity.kt | 9 +++++++++ .../android/webview/WebViewPresenterImpl.kt | 4 ++++ common/src/main/res/values/strings.xml | 1 + 6 files changed, 24 insertions(+), 11 deletions(-) diff --git a/app/src/full/kotlin/io/homeassistant/companion/android/thread/ThreadManagerImpl.kt b/app/src/full/kotlin/io/homeassistant/companion/android/thread/ThreadManagerImpl.kt index 65f9d746d55..929fd12f5de 100644 --- a/app/src/full/kotlin/io/homeassistant/companion/android/thread/ThreadManagerImpl.kt +++ b/app/src/full/kotlin/io/homeassistant/companion/android/thread/ThreadManagerImpl.kt @@ -37,11 +37,6 @@ class ThreadManagerImpl @Inject constructor( !packageManager.isAutomotive() override suspend fun coreSupportsThread(serverId: Int): Boolean { - if (!serverManager.isRegistered() || serverManager.getServer(serverId)?.user?.isAdmin != true) return false - return serverHasThreadComponent(serverId) - } - - private suspend fun serverHasThreadComponent(serverId: Int): Boolean { if (!serverManager.isRegistered() || serverManager.getServer(serverId) == null) return false val config = serverManager.webSocketRepository(serverId).getConfig() return config != null && @@ -49,6 +44,9 @@ class ThreadManagerImpl @Inject constructor( HomeAssistantVersion.fromString(config.version)?.isAtLeast(2023, 3, 0) == true } + private fun userIsAdmin(serverId: Int): Boolean = + serverManager.getServer(serverId)?.user?.isAdmin == true + private suspend fun getDatasetsFromServer(serverId: Int): List? = serverManager.webSocketRepository(serverId).getThreadDatasets() @@ -59,10 +57,8 @@ class ThreadManagerImpl @Inject constructor( scope: CoroutineScope, ): ThreadManager.SyncResult { if (!appSupportsThread()) return ThreadManager.SyncResult.AppUnsupported - if (!serverHasThreadComponent(serverId)) return ThreadManager.SyncResult.ServerUnsupported - if (serverManager.getServer(serverId)?.user?.isAdmin != true) { - return ThreadManager.SyncResult.ServerUserNotAdmin - } + if (!coreSupportsThread(serverId)) return ThreadManager.SyncResult.ServerUnsupported + if (!userIsAdmin(serverId)) return ThreadManager.SyncResult.ServerUserNotAdmin return if (exportOnly) { // Limited sync, only export non-app dataset exportSyncPreferredDataset(context) @@ -304,7 +300,7 @@ class ThreadManagerImpl @Inject constructor( } override suspend fun sendThreadDatasetExportResult(result: ActivityResult, serverId: Int): String? { - if (result.resultCode == Activity.RESULT_OK && coreSupportsThread(serverId)) { + if (result.resultCode == Activity.RESULT_OK && coreSupportsThread(serverId) && userIsAdmin(serverId)) { val threadNetworkCredentials = ThreadNetworkCredentials.fromIntentSenderResultData(result.data!!) try { val added = serverManager.webSocketRepository( diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/thread/ThreadManager.kt b/app/src/main/kotlin/io/homeassistant/companion/android/thread/ThreadManager.kt index 97e39b1e0a7..8d8551ddc6d 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/thread/ThreadManager.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/thread/ThreadManager.kt @@ -30,7 +30,9 @@ interface ThreadManager { fun appSupportsThread(): Boolean /** - * Indicates if the server supports Thread credential management. + * Indicates if the server has the Thread component installed and is on a recent enough + * Home Assistant version for credential management. This does not consider whether the + * signed-in user is allowed to manage Thread credentials (which requires admin privileges). */ suspend fun coreSupportsThread(serverId: Int): Boolean diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/webview/MatterThreadStep.kt b/app/src/main/kotlin/io/homeassistant/companion/android/webview/MatterThreadStep.kt index 212bf4d73b9..02c5a4605e0 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/webview/MatterThreadStep.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/webview/MatterThreadStep.kt @@ -11,5 +11,6 @@ enum class MatterThreadStep { ERROR_MATTER_CANCELLED, ERROR_MATTER_OTHER, ERROR_THREAD_LOCAL_NETWORK, + ERROR_THREAD_USER_NOT_ADMIN, ERROR_THREAD_OTHER, } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt index 6a1ede4c700..e9c02f3bf05 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt @@ -857,6 +857,15 @@ class WebViewActivity : presenter.finishMatterThreadFlow() } + MatterThreadStep.ERROR_THREAD_USER_NOT_ADMIN -> { + alertDialog?.cancel() + AlertDialog.Builder(this@WebViewActivity) + .setMessage(commonR.string.thread_export_user_not_admin) + .setPositiveButton(commonR.string.ok, null) + .show() + presenter.finishMatterThreadFlow() + } + else -> {} // Do nothing } } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt index f6c05491366..72942980b4e 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt @@ -570,6 +570,10 @@ class WebViewPresenterImpl @Inject constructor( mutableMatterThreadStep.tryEmit(MatterThreadStep.ERROR_THREAD_LOCAL_NETWORK) } + is ThreadManager.SyncResult.ServerUserNotAdmin -> { + mutableMatterThreadStep.tryEmit(MatterThreadStep.ERROR_THREAD_USER_NOT_ADMIN) + } + else -> { mutableMatterThreadStep.tryEmit(MatterThreadStep.ERROR_THREAD_OTHER) } diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index d432cc03762..68867837396 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -1183,6 +1183,7 @@ Imported credential You don\'t have any credentials to import. You are not connected to a local network. Connect to Wi-Fi or ethernet to import Thread credentials. + Managing Thread credentials requires an administrator account on the Home Assistant server. Thread is currently unavailable Vibrate when selected Requires unlocked device