Skip to content

Commit e9e5430

Browse files
agnersclaude
andcommitted
Verify Thread dataset state after export and auto-promote when unset
Previously sendThreadDatasetExportResult treated a successful thread/add_dataset_tlv call as "credentials in sync", but the server never auto-promotes datasets added via that command. When the server already had a different preferred dataset (typically announced by a border router it manages), the next sync would re-detect the mismatch and prompt the user to export again, looping indefinitely. After a successful add, re-query the server's datasets and: - if no dataset is preferred, promote the just-exported one; - if a different dataset is preferred, leave it alone and report it; - if it is already preferred, report the in-sync state. The result type is now a sealed ExportResult so callers can distinguish "sent and now preferred" from "sent but server still prefers another network", and the developer settings screen shows a dedicated message for the latter case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d44930f commit e9e5430

6 files changed

Lines changed: 121 additions & 30 deletions

File tree

app/src/full/kotlin/io/homeassistant/companion/android/thread/ThreadManagerImpl.kt

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -295,19 +295,53 @@ class ThreadManagerImpl @Inject constructor(
295295
.addOnFailureListener { cont.resumeWithException(it) }
296296
}
297297

298-
override suspend fun sendThreadDatasetExportResult(result: ActivityResult, serverId: Int): String? {
299-
if (result.resultCode == Activity.RESULT_OK && coreSupportsThread(serverId)) {
300-
val threadNetworkCredentials = ThreadNetworkCredentials.fromIntentSenderResultData(result.data!!)
301-
try {
302-
val added = serverManager.webSocketRepository(
303-
serverId,
304-
).addThreadDataset(threadNetworkCredentials.activeOperationalDataset)
305-
if (added) return threadNetworkCredentials.networkName
306-
} catch (e: Exception) {
307-
Timber.e(e, "Error while executing server new Thread credentials request")
298+
@OptIn(ExperimentalStdlibApi::class)
299+
override suspend fun sendThreadDatasetExportResult(
300+
result: ActivityResult,
301+
serverId: Int,
302+
): ThreadManager.ExportResult {
303+
if (result.resultCode != Activity.RESULT_OK || !coreSupportsThread(serverId)) {
304+
return ThreadManager.ExportResult.NotSent
305+
}
306+
val credentials = ThreadNetworkCredentials.fromIntentSenderResultData(result.data!!)
307+
val webSocket = serverManager.webSocketRepository(serverId)
308+
val added = try {
309+
webSocket.addThreadDataset(credentials.activeOperationalDataset)
310+
} catch (e: Exception) {
311+
Timber.e(e, "Error while executing server new Thread credentials request")
312+
return ThreadManager.ExportResult.Failed
313+
}
314+
if (!added) return ThreadManager.ExportResult.Failed
315+
316+
// thread/add_dataset_tlv never promotes a dataset to preferred on the server side. To
317+
// surface an honest result we re-query and compare extended PAN IDs. If the server has
318+
// no preferred dataset at all (e.g. no border router announcing one), promote the
319+
// just-exported dataset; we never override an existing server preference.
320+
val deviceExtPan = credentials.extendedPanId.toHexString(HexFormat.UpperCase)
321+
val serverPrefersExported = try {
322+
val datasets = webSocket.getThreadDatasets()
323+
val preferred = datasets?.firstOrNull { it.preferred }
324+
when {
325+
preferred == null -> {
326+
val ours = datasets?.firstOrNull { it.extendedPanId.equals(deviceExtPan, ignoreCase = true) }
327+
if (ours != null && webSocket.setThreadPreferredDataset(ours.datasetId)) {
328+
Timber.d("Thread: promoted exported dataset to preferred on the server")
329+
true
330+
} else {
331+
false
332+
}
333+
}
334+
preferred.extendedPanId.equals(deviceExtPan, ignoreCase = true) -> true
335+
else -> false
308336
}
337+
} catch (e: Exception) {
338+
Timber.w(e, "Unable to verify Thread preferred dataset after export")
339+
null
309340
}
310-
return null
341+
return ThreadManager.ExportResult.Sent(
342+
networkName = credentials.networkName,
343+
serverPrefersExported = serverPrefersExported,
344+
)
311345
}
312346

313347
private suspend fun deleteOrphanedThreadCredentials(context: Context, serverId: Int) {

app/src/main/kotlin/io/homeassistant/companion/android/settings/developer/DeveloperSettingsPresenterImpl.kt

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -133,19 +133,39 @@ class DeveloperSettingsPresenterImpl @Inject constructor(
133133
) {
134134
mainScope.launch {
135135
try {
136-
val submitted = threadManager.sendThreadDatasetExportResult(result, serverId)
137-
if (submitted != null) {
138-
if (isDeviceOnly) {
139-
view.onThreadDebugResult(context.getString(commonR.string.thread_debug_result_exported), true)
140-
} else {
141-
// If we got permission while both had a dataset, the device prefers a different network
142-
val out = "${context.getString(
143-
commonR.string.thread_debug_result_mismatch,
144-
)} ${context.getString(commonR.string.thread_debug_result_mismatch_detail, submitted)}"
145-
view.onThreadDebugResult(out, null)
136+
when (val sent = threadManager.sendThreadDatasetExportResult(result, serverId)) {
137+
is ThreadManager.ExportResult.Sent -> {
138+
if (isDeviceOnly) {
139+
view.onThreadDebugResult(
140+
context.getString(commonR.string.thread_debug_result_exported),
141+
true,
142+
)
143+
} else if (sent.serverPrefersExported == true) {
144+
view.onThreadDebugResult(context.getString(commonR.string.thread_debug_result_match), true)
145+
} else if (sent.serverPrefersExported == false) {
146+
// The dataset was added on the server but a different one is still
147+
// preferred (typically a server-managed border router announces it).
148+
val out = "${context.getString(
149+
commonR.string.thread_debug_result_exported_not_preferred,
150+
)} ${context.getString(
151+
commonR.string.thread_debug_result_mismatch_detail,
152+
sent.networkName,
153+
)}"
154+
view.onThreadDebugResult(out, null)
155+
} else {
156+
val out = "${context.getString(
157+
commonR.string.thread_debug_result_mismatch,
158+
)} ${context.getString(
159+
commonR.string.thread_debug_result_mismatch_detail,
160+
sent.networkName,
161+
)}"
162+
view.onThreadDebugResult(out, null)
163+
}
146164
}
147-
} else {
148-
view.onThreadDebugResult(context.getString(commonR.string.thread_debug_result_error), false)
165+
ThreadManager.ExportResult.NotSent,
166+
ThreadManager.ExportResult.Failed,
167+
->
168+
view.onThreadDebugResult(context.getString(commonR.string.thread_debug_result_error), false)
149169
}
150170
} catch (e: Exception) {
151171
view.onThreadDebugResult(context.getString(commonR.string.thread_debug_result_error), false)

app/src/main/kotlin/io/homeassistant/companion/android/thread/ThreadManager.kt

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,27 @@ interface ThreadManager {
2323
object NoneHaveCredentials : SyncResult()
2424
}
2525

26+
sealed class ExportResult {
27+
/**
28+
* The activity result was not OK, the server doesn't support Thread, or there was no
29+
* dataset to send.
30+
*/
31+
object NotSent : ExportResult()
32+
33+
/** Sending the dataset to the server failed. */
34+
object Failed : ExportResult()
35+
36+
/**
37+
* The dataset was added on the server.
38+
* @param networkName Network name of the dataset that was sent
39+
* @param serverPrefersExported `true` if the server now reports the exported dataset as
40+
* its preferred one, `false` if the server still prefers a different dataset (typically
41+
* because a Thread border router managed by the server is announcing one), or `null` if
42+
* the post-export state could not be determined.
43+
*/
44+
data class Sent(val networkName: String, val serverPrefersExported: Boolean?) : ExportResult()
45+
}
46+
2647
/**
2748
* Indicates if the app on this device supports Thread credential management.
2849
*/
@@ -82,7 +103,15 @@ interface ThreadManager {
82103
/**
83104
* Process the result from [syncPreferredDataset] or [getPreferredDatasetFromDevice]'s intent
84105
* and add the Thread dataset, if any, to the server.
85-
* @return Network name that was sent and accepted, or `null` if not sent or accepted
106+
*
107+
* The `thread/add_dataset_tlv` server command never promotes a dataset to preferred. After a
108+
* successful add the server is queried again to determine the post-export state: if the
109+
* server has no preferred dataset, the just-exported one is promoted; if it has another
110+
* preferred dataset (typically because a server-managed border router announces it), no
111+
* override is performed.
112+
*
113+
* @return [ExportResult] describing whether the dataset was sent and, if so, whether the
114+
* server now prefers it
86115
*/
87-
suspend fun sendThreadDatasetExportResult(result: ActivityResult, serverId: Int): String?
116+
suspend fun sendThreadDatasetExportResult(result: ActivityResult, serverId: Int): ExportResult
88117
}

app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -603,12 +603,16 @@ class WebViewPresenterImpl @Inject constructor(
603603
mainScope.launch {
604604
val sent = threadUseCase.sendThreadDatasetExportResult(result, serverId)
605605
Timber.d(
606-
"Thread ${if (!sent.isNullOrBlank()) "sent credential for $sent" else "did not send credential"}",
606+
"Thread ${if (sent is ThreadManager.ExportResult.Sent) {
607+
"sent credential for ${sent.networkName} (server prefers exported: ${sent.serverPrefersExported})"
608+
} else {
609+
"did not send credential"
610+
}}",
607611
)
608-
if (sent.isNullOrBlank()) {
609-
mutableMatterThreadStep.tryEmit(MatterThreadStep.THREAD_NONE)
610-
} else {
612+
if (sent is ThreadManager.ExportResult.Sent) {
611613
mutableMatterThreadStep.tryEmit(MatterThreadStep.THREAD_SENT)
614+
} else {
615+
mutableMatterThreadStep.tryEmit(MatterThreadStep.THREAD_NONE)
612616
}
613617
}
614618
}

app/src/minimal/kotlin/io/homeassistant/companion/android/thread/ThreadManagerImpl.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,5 +36,8 @@ class ThreadManagerImpl @Inject constructor() : ThreadManager {
3636
throw IllegalStateException("Thread is not supported with the minimal flavor")
3737
}
3838

39-
override suspend fun sendThreadDatasetExportResult(result: ActivityResult, serverId: Int): String? = null
39+
override suspend fun sendThreadDatasetExportResult(
40+
result: ActivityResult,
41+
serverId: Int,
42+
): ThreadManager.ExportResult = ThreadManager.ExportResult.NotSent
4043
}

common/src/main/res/values/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1170,6 +1170,7 @@
11701170
<string name="thread_debug_active">Syncing…</string>
11711171
<string name="thread_debug_result_error">An unexpected error occurred while syncing</string>
11721172
<string name="thread_debug_result_exported">Added network from this device to Home Assistant</string>
1173+
<string name="thread_debug_result_exported_not_preferred">Added network from this device to Home Assistant, but Home Assistant still prefers a different network</string>
11731174
<string name="thread_debug_result_imported">Added network from Home Assistant to this device</string>
11741175
<string name="thread_debug_result_match">Home Assistant and this device use the same network</string>
11751176
<string name="thread_debug_result_mismatch">Home Assistant and this device prefer different networks</string>

0 commit comments

Comments
 (0)