Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -295,19 +295,53 @@ class ThreadManagerImpl @Inject constructor(
.addOnFailureListener { cont.resumeWithException(it) }
}

override suspend fun sendThreadDatasetExportResult(result: ActivityResult, serverId: Int): String? {
if (result.resultCode == Activity.RESULT_OK && coreSupportsThread(serverId)) {
val threadNetworkCredentials = ThreadNetworkCredentials.fromIntentSenderResultData(result.data!!)
try {
val added = serverManager.webSocketRepository(
serverId,
).addThreadDataset(threadNetworkCredentials.activeOperationalDataset)
if (added) return threadNetworkCredentials.networkName
} catch (e: Exception) {
Timber.e(e, "Error while executing server new Thread credentials request")
@OptIn(ExperimentalStdlibApi::class)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@OptIn(ExperimentalStdlibApi::class)

Not needed anymore (you can probably also remove it in the rest of the file)

override suspend fun sendThreadDatasetExportResult(
Copy link
Copy Markdown
Member

@TimoPtr TimoPtr May 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you are up for the task we could start unit testing this function. Claude is pretty good at this exercise on Android.

result: ActivityResult,
serverId: Int,
): ThreadManager.ExportResult {
if (result.resultCode != Activity.RESULT_OK || !coreSupportsThread(serverId)) {
return ThreadManager.ExportResult.NotSent
}
val credentials = ThreadNetworkCredentials.fromIntentSenderResultData(result.data!!)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can avoid crashing here by checking if data is null and returning NotSent.

val webSocket = serverManager.webSocketRepository(serverId)
val added = try {
webSocket.addThreadDataset(credentials.activeOperationalDataset)
} catch (e: Exception) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a coroutine, we have a bad design in the app by catching all Exception but we can't change it now. But because of that we have to reThrow CancellationException otherwise it let the scope into an invalid state.

Timber.e(e, "Error while executing server new Thread credentials request")
return ThreadManager.ExportResult.Failed
}
if (!added) return ThreadManager.ExportResult.Failed

// thread/add_dataset_tlv never promotes a dataset to preferred on the server side. To
// surface an honest result we re-query and compare extended PAN IDs. If the server has
// no preferred dataset at all (e.g. no border router announcing one), promote the
// just-exported dataset; we never override an existing server preference.
val deviceExtPan = credentials.extendedPanId.toHexString(HexFormat.UpperCase)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
val deviceExtPan = credentials.extendedPanId.toHexString(HexFormat.UpperCase)
val deviceExtPan = credentials.extendedPanId.toHexString()

Since you ignore the case bellow we don't care

val serverPrefersExported = try {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The whole logic could be extracted into a private function.

val datasets = webSocket.getThreadDatasets()
val preferred = datasets?.firstOrNull { it.preferred }
when {
preferred == null -> {
val ours = datasets?.firstOrNull { it.extendedPanId.equals(deviceExtPan, ignoreCase = true) }
if (ours != null && webSocket.setThreadPreferredDataset(ours.datasetId)) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably need to check if core has this endpoint no? Or the min version we use for thread already contains this endpoint?

Timber.d("Thread: promoted exported dataset to preferred on the server")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Timber.d("Thread: promoted exported dataset to preferred on the server")
Timber.d("Promote exported dataset to preferred network on the server")

true
} else {
false
Comment on lines +322 to +331
}
}
preferred.extendedPanId.equals(deviceExtPan, ignoreCase = true) -> true
else -> false
Comment on lines +321 to +335
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
val serverPrefersExported = try {
val datasets = webSocket.getThreadDatasets()
val preferred = datasets?.firstOrNull { it.preferred }
when {
preferred == null -> {
val ours = datasets?.firstOrNull { it.extendedPanId.equals(deviceExtPan, ignoreCase = true) }
if (ours != null && webSocket.setThreadPreferredDataset(ours.datasetId)) {
Timber.d("Thread: promoted exported dataset to preferred on the server")
true
} else {
false
}
}
preferred.extendedPanId.equals(deviceExtPan, ignoreCase = true) -> true
else -> false
val serverPrefersExported = try {
val datasets = webSocket.getThreadDatasets().orEmpty()
val preferred = datasets.firstOrNull { it.preferred }
if (preferred != null) {
preferred.extendedPanId.equals(deviceExtPan, ignoreCase = true)
} else {
val ours = datasets.firstOrNull { it.extendedPanId.equals(deviceExtPan, ignoreCase = true) }
if (ours != null && webSocket.setThreadPreferredDataset(ours.datasetId)) {
Timber.d("Thread: promoted exported dataset to preferred on the server")
true
} else {
false
}
}

The when using instead of a if is sometimes less readable IMO.

}
} catch (e: Exception) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here we need to rethrow

Timber.w(e, "Unable to verify Thread preferred dataset after export")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could also be setting the prefered dataset since you potentially makes 2 call.

null
}
return null
return ThreadManager.ExportResult.Sent(
networkName = credentials.networkName,
serverPrefersExported = serverPrefersExported,
)
}

private suspend fun deleteOrphanedThreadCredentials(context: Context, serverId: Int) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,19 +133,39 @@ class DeveloperSettingsPresenterImpl @Inject constructor(
) {
mainScope.launch {
try {
val submitted = threadManager.sendThreadDatasetExportResult(result, serverId)
if (submitted != null) {
if (isDeviceOnly) {
view.onThreadDebugResult(context.getString(commonR.string.thread_debug_result_exported), true)
} else {
// If we got permission while both had a dataset, the device prefers a different network
val out = "${context.getString(
commonR.string.thread_debug_result_mismatch,
)} ${context.getString(commonR.string.thread_debug_result_mismatch_detail, submitted)}"
view.onThreadDebugResult(out, null)
when (val sent = threadManager.sendThreadDatasetExportResult(result, serverId)) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like it can be simplified maybe something like this

                when (val sent = threadManager.sendThreadDatasetExportResult(result, serverId)) {
                    is ThreadManager.ExportResult.Sent -> when {
                        isDeviceOnly -> view.onThreadDebugResult(
                            context.getString(commonR.string.thread_debug_result_exported),
                            true,
                        )
                        sent.serverPrefersExported == true -> view.onThreadDebugResult(
                            context.getString(commonR.string.thread_debug_result_match),
                            true,
                        )
                        else -> {
                            val headlineRes = if (sent.serverPrefersExported == false) {
                                commonR.string.thread_debug_result_exported_not_preferred
                            } else {
                                commonR.string.thread_debug_result_mismatch
                            }
                            val detail = context.getString(
                                commonR.string.thread_debug_result_mismatch_detail,
                                sent.networkName,
                            )
                            view.onThreadDebugResult("${context.getString(headlineRes)} $detail", null)
                        }
                    }

is ThreadManager.ExportResult.Sent -> {
if (isDeviceOnly) {
view.onThreadDebugResult(
context.getString(commonR.string.thread_debug_result_exported),
true,
)
} else if (sent.serverPrefersExported == true) {
view.onThreadDebugResult(context.getString(commonR.string.thread_debug_result_match), true)
} else if (sent.serverPrefersExported == false) {
// The dataset was added on the server but a different one is still
// preferred (typically a server-managed border router announces it).
val out = "${context.getString(
commonR.string.thread_debug_result_exported_not_preferred,
)} ${context.getString(
commonR.string.thread_debug_result_mismatch_detail,
sent.networkName,
)}"
view.onThreadDebugResult(out, null)
} else {
val out = "${context.getString(
commonR.string.thread_debug_result_mismatch,
)} ${context.getString(
commonR.string.thread_debug_result_mismatch_detail,
sent.networkName,
)}"
view.onThreadDebugResult(out, null)
Comment on lines +156 to +162
}
}
} else {
view.onThreadDebugResult(context.getString(commonR.string.thread_debug_result_error), false)
ThreadManager.ExportResult.NotSent,
ThreadManager.ExportResult.Failed,
->
view.onThreadDebugResult(context.getString(commonR.string.thread_debug_result_error), false)
}
} catch (e: Exception) {
view.onThreadDebugResult(context.getString(commonR.string.thread_debug_result_error), false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,27 @@ interface ThreadManager {
object NoneHaveCredentials : SyncResult()
}

sealed class ExportResult {
/**
* The activity result was not OK, the server doesn't support Thread, or there was no
* dataset to send.
*/
object NotSent : ExportResult()

/** Sending the dataset to the server failed. */
object Failed : ExportResult()

/**
* The dataset was added on the server.
* @param networkName Network name of the dataset that was sent
* @param serverPrefersExported `true` if the server now reports the exported dataset as
* its preferred one, `false` if the server still prefers a different dataset (typically
* because a Thread border router managed by the server is announcing one), or `null` if
* the post-export state could not be determined.
*/
data class Sent(val networkName: String, val serverPrefersExported: Boolean?) : ExportResult()
}

/**
* Indicates if the app on this device supports Thread credential management.
*/
Expand Down Expand Up @@ -82,7 +103,15 @@ interface ThreadManager {
/**
* Process the result from [syncPreferredDataset] or [getPreferredDatasetFromDevice]'s intent
* and add the Thread dataset, if any, to the server.
* @return Network name that was sent and accepted, or `null` if not sent or accepted
*
* The `thread/add_dataset_tlv` server command never promotes a dataset to preferred. After a
* successful add the server is queried again to determine the post-export state: if the
* server has no preferred dataset, the just-exported one is promoted; if it has another
* preferred dataset (typically because a server-managed border router announces it), no
* override is performed.
*
* @return [ExportResult] describing whether the dataset was sent and, if so, whether the
* server now prefers it
*/
suspend fun sendThreadDatasetExportResult(result: ActivityResult, serverId: Int): String?
suspend fun sendThreadDatasetExportResult(result: ActivityResult, serverId: Int): ExportResult
}
Original file line number Diff line number Diff line change
Expand Up @@ -603,12 +603,16 @@ class WebViewPresenterImpl @Inject constructor(
mainScope.launch {
val sent = threadUseCase.sendThreadDatasetExportResult(result, serverId)
Timber.d(
"Thread ${if (!sent.isNullOrBlank()) "sent credential for $sent" else "did not send credential"}",
"Thread ${if (sent is ThreadManager.ExportResult.Sent) {
"sent credential for ${sent.networkName} (server prefers exported: ${sent.serverPrefersExported})"
} else {
"did not send credential"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"did not send credential"
"did not send credential reason $sent"

}}",
)
if (sent.isNullOrBlank()) {
mutableMatterThreadStep.tryEmit(MatterThreadStep.THREAD_NONE)
} else {
if (sent is ThreadManager.ExportResult.Sent) {
mutableMatterThreadStep.tryEmit(MatterThreadStep.THREAD_SENT)
} else {
mutableMatterThreadStep.tryEmit(MatterThreadStep.THREAD_NONE)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,8 @@ class ThreadManagerImpl @Inject constructor() : ThreadManager {
throw IllegalStateException("Thread is not supported with the minimal flavor")
}

override suspend fun sendThreadDatasetExportResult(result: ActivityResult, serverId: Int): String? = null
override suspend fun sendThreadDatasetExportResult(
result: ActivityResult,
serverId: Int,
): ThreadManager.ExportResult = ThreadManager.ExportResult.NotSent
}
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,21 @@ interface WebSocketRepository {

/**
* Add a new set of Thread network credentials to the server.
*
* Note: this command is idempotent for an already-stored TLV and never promotes the dataset
* to the server's preferred one. Use [setThreadPreferredDataset] to explicitly promote.
*
* @return `true` if the server indicated success
*/
suspend fun addThreadDataset(tlv: ByteArray): Boolean

/**
* Set the server's preferred Thread dataset.
* @param datasetId The dataset ID as provided by the server
* @return `true` if the server indicated success
*/
suspend fun setThreadPreferredDataset(datasetId: String): Boolean

/**
* Get an Assist response for the given text input. For core >= 2023.5, use [runAssistPipelineForText]
* instead.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,16 @@ class WebSocketRepositoryImpl internal constructor(
return response?.success == true
}

override suspend fun setThreadPreferredDataset(datasetId: String): Boolean {
val response = webSocketCore.sendMessage(
mapOf(
"type" to "thread/set_preferred_dataset",
"dataset_id" to datasetId,
Comment on lines +423 to +427
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed

),
)
return response?.success == true
}

/**
* Update server entry in [serverManager] with information from a [CurrentUserResponse] like user
* name and admin status.
Expand Down
1 change: 1 addition & 0 deletions common/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1170,6 +1170,7 @@
<string name="thread_debug_active">Syncing…</string>
<string name="thread_debug_result_error">An unexpected error occurred while syncing</string>
<string name="thread_debug_result_exported">Added network from this device to Home Assistant</string>
<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>
<string name="thread_debug_result_imported">Added network from Home Assistant to this device</string>
<string name="thread_debug_result_match">Home Assistant and this device use the same network</string>
<string name="thread_debug_result_mismatch">Home Assistant and this device prefer different networks</string>
Expand Down
Loading