From 3508c4661517d3eb1ef655cdea888b1fa65c4e6f Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Thu, 7 May 2026 15:44:36 +0200 Subject: [PATCH 1/2] Verify Thread dataset state after device import ThreadNetworkClient.addCredentials only stores a credential under the given border agent; it does not expose any way to set the preferred dataset, and Play Services may keep preferring an older app-added credential. The full sync's update branch was treating a successful addCredentials as "device updated" and reporting success, even when the device's preferred dataset was unchanged. The next sync detected the same mismatch and looped. After importDatasetFromServer succeeds, query isPreferredCredentials for the just-imported TLV and propagate the result via a new deviceNowPrefersCore field on AllHaveCredentials. The developer settings screen shows a dedicated message when the import didn't take effect so the user knows the loop won't resolve itself. Also fix a cosmetic log issue: the "device prefers app added dataset" line was printing the extended PAN ID via String(ByteArray), which emits control characters. Format it as upper-case hex instead. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../android/thread/ThreadManagerImpl.kt | 29 +++++++++++++++++-- .../DeveloperSettingsPresenterImpl.kt | 10 ++++--- .../companion/android/thread/ThreadManager.kt | 1 + common/src/main/res/values/strings.xml | 1 + 4 files changed, 34 insertions(+), 7 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 6c2c5e70ce0..3e68b8f87ca 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 @@ -135,6 +135,7 @@ class ThreadManagerImpl @Inject constructor( var exportFromDevice = false var updated: Boolean? = null + var deviceNowPrefersCore: Boolean? = null if (!coreIsDevicePreferred) { if (appIsDevicePreferred) { // Update or remove the device preferred credential to match core state. @@ -169,6 +170,24 @@ class ThreadManagerImpl @Inject constructor( ) } Timber.d("Thread update device completed: deleted ${localIds.size} datasets, updated 1") + // ThreadNetworkClient.addCredentials does not promote the new + // credential to preferred. Verify what Play Services chose so the + // caller can report honestly instead of assuming success. + deviceNowPrefersCore = try { + isPreferredDatasetByDevice(context, coreThreadDataset.datasetId, serverId) + } catch (e: Exception) { + Timber.w(e, "Unable to verify preferred dataset after import") + null + } + Timber.d( + "Thread: after import device ${ + when (deviceNowPrefersCore) { + true -> "now prefers" + false -> "still doesn't prefer" + null -> "preference state unknown for" + } + } core preferred dataset", + ) true } else { // Core prefers imported from other app, this shouldn't be managed by HA localIds.forEach { baId -> @@ -197,6 +216,7 @@ class ThreadManagerImpl @Inject constructor( matches = coreIsDevicePreferred, fromApp = appIsDevicePreferred, updated = updated, + deviceNowPrefersCore = deviceNowPrefersCore, exportIntent = if (exportFromDevice) deviceThreadIntent else null, ) } catch (e: Exception) { @@ -205,6 +225,7 @@ class ThreadManagerImpl @Inject constructor( matches = null, fromApp = null, updated = null, + deviceNowPrefersCore = null, exportIntent = null, ) } @@ -262,6 +283,7 @@ class ThreadManagerImpl @Inject constructor( } } + @OptIn(ExperimentalStdlibApi::class) private suspend fun appAddedIsPreferredCredentials(context: Context): Boolean { val appCredentials = suspendCoroutine { cont -> ThreadNetwork.getNetworkClient(context) @@ -274,9 +296,10 @@ class ThreadManagerImpl @Inject constructor( val isPreferred = isPreferredCredentials(context, it) if (isPreferred) { Timber.d( - "Thread device prefers app added dataset: ${it.networkName} (PAN ${it.panId}, EXTPAN ${String( - it.extendedPanId, - )})", + "Thread device prefers app added dataset: %s (PAN %s, EXTPAN %s)", + it.networkName, + it.panId, + it.extendedPanId.toHexString(HexFormat.UpperCase), ) } isPreferred 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..a1c8197b341 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 @@ -100,10 +100,12 @@ class DeveloperSettingsPresenterImpl @Inject constructor( } else if (syncResult.matches == true) { view.onThreadDebugResult(context.getString(commonR.string.thread_debug_result_match), true) } else if (syncResult.fromApp == true && syncResult.updated == true) { - view.onThreadDebugResult( - context.getString(commonR.string.thread_debug_result_updated), - true, - ) + val message = if (syncResult.deviceNowPrefersCore == false) { + context.getString(commonR.string.thread_debug_result_updated_not_preferred) + } else { + context.getString(commonR.string.thread_debug_result_updated) + } + view.onThreadDebugResult(message, syncResult.deviceNowPrefersCore != false) } else if (syncResult.fromApp == true && syncResult.updated == false) { view.onThreadDebugResult( context.getString(commonR.string.thread_debug_result_removed), 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..333b1719fb5 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 @@ -18,6 +18,7 @@ interface ThreadManager { val matches: Boolean?, val fromApp: Boolean?, val updated: Boolean?, + val deviceNowPrefersCore: Boolean?, val exportIntent: IntentSender?, ) : SyncResult() object NoneHaveCredentials : SyncResult() diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index 8f2e214a324..0d38e7ecfbb 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -1178,6 +1178,7 @@ Removed old network from Home Assistant on this device The Home Assistant server does not support Thread Updated network from Home Assistant on this device + Updated network from Home Assistant on this device, but the device still prefers a different network Manually update device and server Thread credentials and verify results Imported credential You don\'t have any credentials to import. From dc40ed56b4c9ea92286bae21553723498f0acef1 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Thu, 7 May 2026 19:23:09 +0200 Subject: [PATCH 2/2] Include the still-preferred network name in the import-not-promoted message When the device still prefers a different network after import, look up which app-added credential is currently preferred and surface its network name so the user can identify the lingering credential. Refactor appAddedIsPreferredCredentials into appAddedPreferredCredential so the preferred credential is reused for both the boolean check and the network-name lookup, and propagate the name via a new devicePreferredNetworkName field on AllHaveCredentials. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../android/thread/ThreadManagerImpl.kt | 35 ++++++++++--------- .../DeveloperSettingsPresenterImpl.kt | 11 +++++- .../companion/android/thread/ThreadManager.kt | 1 + 3 files changed, 30 insertions(+), 17 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 3e68b8f87ca..e0ff31df87b 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 @@ -78,7 +78,7 @@ class ThreadManagerImpl @Inject constructor( return if (getDeviceDataset == null) { ThreadManager.SyncResult.NoneHaveCredentials } else { - val appIsDevicePreferred = appAddedIsPreferredCredentials(context) + val appIsDevicePreferred = appAddedPreferredCredential(context) != null Timber.d("Thread: device ${if (appIsDevicePreferred) "prefers" else "doesn't prefer" } dataset from app") return if (appIsDevicePreferred) { @@ -128,7 +128,8 @@ class ThreadManagerImpl @Inject constructor( Timber.d( "Thread: device ${if (coreIsDevicePreferred) "prefers" else "doesn't prefer" } core preferred dataset", ) - val appIsDevicePreferred = coreIsDevicePreferred || appAddedIsPreferredCredentials(context) + val appPreferredCredential = if (coreIsDevicePreferred) null else appAddedPreferredCredential(context) + val appIsDevicePreferred = coreIsDevicePreferred || appPreferredCredential != null Timber.d( "Thread: device ${if (appIsDevicePreferred) "prefers" else "doesn't prefer" } dataset from app", ) @@ -136,6 +137,7 @@ class ThreadManagerImpl @Inject constructor( var exportFromDevice = false var updated: Boolean? = null var deviceNowPrefersCore: Boolean? = null + var devicePreferredNetworkName: String? = null if (!coreIsDevicePreferred) { if (appIsDevicePreferred) { // Update or remove the device preferred credential to match core state. @@ -188,6 +190,9 @@ class ThreadManagerImpl @Inject constructor( } } core preferred dataset", ) + if (deviceNowPrefersCore == false) { + devicePreferredNetworkName = appAddedPreferredCredential(context)?.networkName + } true } else { // Core prefers imported from other app, this shouldn't be managed by HA localIds.forEach { baId -> @@ -217,6 +222,7 @@ class ThreadManagerImpl @Inject constructor( fromApp = appIsDevicePreferred, updated = updated, deviceNowPrefersCore = deviceNowPrefersCore, + devicePreferredNetworkName = devicePreferredNetworkName, exportIntent = if (exportFromDevice) deviceThreadIntent else null, ) } catch (e: Exception) { @@ -226,6 +232,7 @@ class ThreadManagerImpl @Inject constructor( fromApp = null, updated = null, deviceNowPrefersCore = null, + devicePreferredNetworkName = null, exportIntent = null, ) } @@ -284,7 +291,7 @@ class ThreadManagerImpl @Inject constructor( } @OptIn(ExperimentalStdlibApi::class) - private suspend fun appAddedIsPreferredCredentials(context: Context): Boolean { + private suspend fun appAddedPreferredCredential(context: Context): ThreadNetworkCredentials? { val appCredentials = suspendCoroutine { cont -> ThreadNetwork.getNetworkClient(context) .allCredentials @@ -292,21 +299,17 @@ class ThreadManagerImpl @Inject constructor( .addOnFailureListener { cont.resume(null) } } return try { - appCredentials?.any { - val isPreferred = isPreferredCredentials(context, it) - if (isPreferred) { - Timber.d( - "Thread device prefers app added dataset: %s (PAN %s, EXTPAN %s)", - it.networkName, - it.panId, - it.extendedPanId.toHexString(HexFormat.UpperCase), - ) - } - isPreferred - } ?: false + appCredentials?.firstOrNull { isPreferredCredentials(context, it) }?.also { + Timber.d( + "Thread device prefers app added dataset: %s (PAN %s, EXTPAN %s)", + it.networkName, + it.panId, + it.extendedPanId.toHexString(HexFormat.UpperCase), + ) + } } catch (e: Exception) { Timber.e(e, "Thread app added credentials preferred check failed") - false + null } } 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 a1c8197b341..63703acd15b 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 @@ -101,7 +101,16 @@ class DeveloperSettingsPresenterImpl @Inject constructor( view.onThreadDebugResult(context.getString(commonR.string.thread_debug_result_match), true) } else if (syncResult.fromApp == true && syncResult.updated == true) { val message = if (syncResult.deviceNowPrefersCore == false) { - context.getString(commonR.string.thread_debug_result_updated_not_preferred) + val base = context.getString(commonR.string.thread_debug_result_updated_not_preferred) + val name = syncResult.devicePreferredNetworkName + if (name != null) { + "$base ${context.getString( + commonR.string.thread_debug_result_mismatch_detail, + name, + )}" + } else { + base + } } else { context.getString(commonR.string.thread_debug_result_updated) } 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 333b1719fb5..8bd7ad762d2 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 @@ -19,6 +19,7 @@ interface ThreadManager { val fromApp: Boolean?, val updated: Boolean?, val deviceNowPrefersCore: Boolean?, + val devicePreferredNetworkName: String?, val exportIntent: IntentSender?, ) : SyncResult() object NoneHaveCredentials : SyncResult()