Skip to content
Open
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 @@ -256,32 +256,30 @@ class ThreadManagerImpl @Inject constructor(
}
}

private suspend fun appAddedIsPreferredCredentials(): Boolean {
private suspend fun appAddedIsPreferredCredentials(): Boolean = appAddedPreferredCredential() != null

@OptIn(ExperimentalStdlibApi::class)
private suspend fun appAddedPreferredCredential(): ThreadNetworkCredentials? {
val appCredentials = suspendCancellableCoroutine { cont ->
threadNetworkClient
.allCredentials
.addOnSuccessListener { if (cont.isActive) cont.resume(it) }
.addOnFailureListener { if (cont.isActive) cont.resume(null) }
}
return try {
appCredentials?.any {
val isPreferred = isPreferredCredentials(it)
if (isPreferred) {
Timber.d(
"Thread device prefers app added dataset: ${it.networkName} (PAN ${it.panId}, EXTPAN ${
String(
it.extendedPanId,
)
})",
)
}
isPreferred
} ?: false
appCredentials?.firstOrNull { isPreferredCredentials(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: CancellationException) {
throw e
} catch (e: Exception) {
Timber.e(e, "Thread app added credentials preferred check failed")
false
null
}
}

Expand Down Expand Up @@ -316,6 +314,90 @@ class ThreadManagerImpl @Inject constructor(
return null
}

override fun networkNameFromTlv(tlv: ByteArray): String? = try {
ThreadNetworkCredentials.fromActiveOperationalDataset(tlv).networkName
} catch (e: Exception) {
Timber.w(e, "Thread: cannot parse TLV to extract network name")
null
}

override suspend fun predictPreferredOutcome(tlv: ByteArray): ThreadManager.PreflightOutcome {
val prospective = try {
ThreadNetworkCredentials.fromActiveOperationalDataset(tlv)
} catch (e: Exception) {
Timber.w(e, "Thread preflight: cannot parse TLV")
return ThreadManager.PreflightOutcome.Unknown
}
val alreadyPreferred = try {
isPreferredCredentials(prospective)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.w(e, "Thread preflight: isPreferredCredentials failed")
return ThreadManager.PreflightOutcome.Unknown
}
if (alreadyPreferred) return ThreadManager.PreflightOutcome.AlreadyPreferred

val ourPreferred = try {
appAddedPreferredCredential()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.w(e, "Thread preflight: appAddedPreferredCredential failed")
return ThreadManager.PreflightOutcome.Unknown
}
if (ourPreferred != null) {
return ThreadManager.PreflightOutcome.DifferentAppPreferred(ourPreferred.networkName)
}

// No app-owned preferred credential. Probe how many credentials are stored to distinguish
// "nothing stored" (this add will likely become preferred) from "we own credentials but
// none is preferred — another app may own the preferred one" (ambiguous).
val ownedCount = suspendCancellableCoroutine { cont ->
threadNetworkClient
.allCredentials
.addOnSuccessListener { if (cont.isActive) cont.resume(it?.size ?: 0) }
.addOnFailureListener {
Timber.w(it, "Thread preflight: allCredentials failed")
if (cont.isActive) cont.resume(-1)
}
}
return if (ownedCount == 0) {
ThreadManager.PreflightOutcome.LikelyToBecomePreferred
} else {
ThreadManager.PreflightOutcome.Unknown
}
}

@OptIn(ExperimentalStdlibApi::class)
override suspend fun addCredentialToDevice(serverId: Int, tlv: ByteArray, borderAgentId: String): Boolean? {
// Sweep stale credentials before adding. Used to run at the top of fullSyncPreferredDataset;
// the HA -> Phone path is the natural new home for it.
deleteOrphanedThreadCredentials(serverId)

val idAsBytes = if (borderAgentId.length == 16) borderAgentId.toByteArray() else borderAgentId.hexToByteArray()
val threadBorderAgent = ThreadBorderAgent.newBuilder(idAsBytes).build()
val credentials = ThreadNetworkCredentials.fromActiveOperationalDataset(tlv)
suspendCancellableCoroutine { cont ->
threadNetworkClient
.addCredentials(threadBorderAgent, credentials)
.addOnSuccessListener { if (cont.isActive) cont.resume(Unit) }
.addOnFailureListener { if (cont.isActive) cont.resumeWithException(it) }
}
// Track the BA so the orphan path can clean it up if this server is later removed.
serverManager.integrationRepository(serverId).setThreadBorderAgentIds(
(serverManager.integrationRepository(serverId).getThreadBorderAgentIds() + borderAgentId).distinct(),
)
return try {
isPreferredCredentials(credentials)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.w(e, "Thread: post-add preferred check failed")
null
}
}

private suspend fun deleteOrphanedThreadCredentials(serverId: Int) {
if (serverManager.servers().all { it.version?.isAtLeast(2023, 9) == true }) {
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -901,6 +901,16 @@ internal class FrontendViewModel @VisibleForTesting constructor(
}
}

is FrontendHandlerEvent.StoreThreadCredentialsInPlatformKeychain -> {
viewModelScope.launch {
matterThreadHandler.onStoreThreadCredentialsInPlatformKeychain(
serverId = _viewState.value.serverId,
borderAgentId = result.borderAgentId,
tlv = result.tlv,
)
}
}

is FrontendHandlerEvent.ShowBarcodeScanner -> barcodeScannerHandler.show(
messageId = result.messageId,
title = result.title,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,26 @@ sealed interface FrontendDialog {
* @param message The message displayed in the dialog
* @param onConfirm Called when the user taps confirm
* @param onCancel Called when the user taps Cancel or dismisses
* @param moreInfoUrl Optional URL to documentation; when set, the dialog shows a
* "Learn more" action that opens it without closing the dialog
*/
data class Confirm(val message: String, val onConfirm: () -> Unit, val onCancel: () -> Unit) : FrontendDialog
data class Confirm(
val message: String,
val onConfirm: () -> Unit,
val onCancel: () -> Unit,
val moreInfoUrl: String? = null,
) : FrontendDialog

/**
* An informational dialog with a message and a single dismiss button.
*
* @param message The message displayed in the dialog
* @param onDismiss Called when the user dismisses the dialog (button tap or outside tap)
* @param moreInfoUrl Optional URL to documentation; when set, the dialog shows a
* "Learn more" action that opens it without closing the dialog
*/
data class Information(val message: String, val onDismiss: () -> Unit) : FrontendDialog
data class Information(val message: String, val onDismiss: () -> Unit, val moreInfoUrl: String? = null) :
FrontendDialog

/**
* An HTTP Basic Auth dialog with username, password, and remember fields.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,16 @@ internal class FrontendDialogManager @Inject constructor() {
*
* Returns `true` if the user confirmed, `false` if they cancelled. The slot is freed
* before returning, including on cancellation of the calling coroutine.
*
* Pass [moreInfoUrl] to offer a "Learn more" action that opens documentation without
* closing the dialog.
*/
suspend fun showConfirm(message: String): Boolean = queue.awaitResult { onResult ->
suspend fun showConfirm(message: String, moreInfoUrl: String? = null): Boolean = queue.awaitResult { onResult ->
FrontendDialog.Confirm(
message = message,
onConfirm = { onResult(true) },
onCancel = { onResult(false) },
moreInfoUrl = moreInfoUrl,
)
}

Expand All @@ -53,12 +57,16 @@ internal class FrontendDialogManager @Inject constructor() {
* dismisses it. There is no result to return; callers use this purely to surface a message
* (e.g. the frontend's `bar_code/notify`). The slot is freed before returning, including on
* cancellation of the calling coroutine.
*
* Pass [moreInfoUrl] to offer a "Learn more" action that opens documentation without
* closing the dialog.
*/
suspend fun showInformation(message: String) {
suspend fun showInformation(message: String, moreInfoUrl: String? = null) {
queue.awaitResult { onResult ->
FrontendDialog.Information(
message = message,
onDismiss = { onResult(Unit) },
moreInfoUrl = moreInfoUrl,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package io.homeassistant.companion.android.frontend.dialog

import androidx.compose.foundation.layout.Column
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import io.homeassistant.companion.android.common.R as commonR
Expand All @@ -15,17 +17,48 @@ internal fun InformationDialog(pendingDialog: FrontendDialog.Information) {
AlertDialog(
onDismissRequest = pendingDialog.onDismiss,
title = { Text(text = stringResource(commonR.string.app_name), style = HATextStyle.HeadlineMedium) },
text = { Text(text = pendingDialog.message, style = HATextStyle.Body) },
text = {
Column {
Text(text = pendingDialog.message, style = HATextStyle.Body)
MoreInfoButton(pendingDialog.moreInfoUrl)
}
},
confirmButton = {
HAPlainButton(stringResource(commonR.string.ok), pendingDialog.onDismiss)
},
)
}

/**
* A "Learn more" action that opens [moreInfoUrl] in the browser without closing the dialog.
* Renders nothing when [moreInfoUrl] is `null`.
*/
@Composable
internal fun MoreInfoButton(moreInfoUrl: String?) {
moreInfoUrl?.let { url ->
val uriHandler = LocalUriHandler.current
HAPlainButton(stringResource(commonR.string.learn_more), { uriHandler.openUri(url) })
}
}

@Composable
@Preview
private fun PreviewInformationDialog() {
HAThemeForPreview {
InformationDialog(FrontendDialog.Information("This code is already paired", onDismiss = {}))
}
}

@Composable
@Preview
private fun PreviewInformationDialogWithMoreInfo() {
HAThemeForPreview {
InformationDialog(
FrontendDialog.Information(
"\"My network\" has been added, but this phone still prefers a different Thread network.",
onDismiss = {},
moreInfoUrl = "https://companion.home-assistant.io/",
),
)
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.homeassistant.companion.android.frontend.dialog

import androidx.compose.foundation.layout.Column
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
Expand All @@ -15,7 +16,12 @@ internal fun SimpleConfirmDialog(pendingDialog: FrontendDialog.Confirm) {
AlertDialog(
onDismissRequest = pendingDialog.onCancel,
title = { Text(text = stringResource(commonR.string.app_name), style = HATextStyle.HeadlineMedium) },
text = { Text(text = pendingDialog.message, style = HATextStyle.Body) },
text = {
Column {
Text(text = pendingDialog.message, style = HATextStyle.Body)
MoreInfoButton(pendingDialog.moreInfoUrl)
}
},
confirmButton = {
HAPlainButton(stringResource(commonR.string.ok), pendingDialog.onConfirm)
},
Expand All @@ -32,3 +38,18 @@ private fun PreviewSimpleConfirmDialog() {
SimpleConfirmDialog(FrontendDialog.Confirm("Hello world", onConfirm = {}, onCancel = {}))
}
}

@Composable
@Preview
private fun PreviewSimpleConfirmDialogWithMoreInfo() {
HAThemeForPreview {
SimpleConfirmDialog(
FrontendDialog.Confirm(
"\"My network\" will be added to the Thread credentials stored on this phone. Continue?",
onConfirm = {},
onCancel = {},
moreInfoUrl = "https://companion.home-assistant.io/",
),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,37 @@ data class MatterCommissionMessage(override val id: Int? = null) : IncomingExter
@SerialName("thread/import_credentials")
data class ThreadImportCredentialsMessage(override val id: Int? = null) : IncomingExternalBusMessage

/**
* Inverse of [ThreadImportCredentialsMessage]: the frontend Thread panel's "Send credentials to
* phone" button on a preferred dataset row fires this with the active operational dataset
* already inlined.
*
* The app stores the credential in the device's Thread credential storage (Google Play
* Services); the message name follows the frontend/iOS naming, where the credentials land in
* the Apple Keychain. All
* fields are hex strings as sent by the frontend; the [activeOperationalDataset] decodes to the
* raw Thread TLV.
*
* Will not be sent by the frontend when the device reports
* [io.homeassistant.companion.android.frontend.externalbus.outgoing.ConfigResult.canTransferThreadCredentialsToKeychain] = `false`.
*
* @see <a href="https://github.com/home-assistant/frontend/blob/dev/src/panels/config/integrations/integration-panels/thread/thread-config-panel.ts">thread-config-panel.ts</a>
*/
@Serializable
@SerialName("thread/store_in_platform_keychain")
data class ThreadStoreInPlatformKeychainMessage(
override val id: Int? = null,
val payload: ThreadStoreInPlatformKeychainPayload,
) : IncomingExternalBusMessage

@Serializable
data class ThreadStoreInPlatformKeychainPayload(
@SerialName("mac_extended_address") val macExtendedAddress: String,
@SerialName("border_agent_id") val borderAgentId: String,
@SerialName("active_operational_dataset") val activeOperationalDataset: String,
@SerialName("extended_pan_id") val extendedPanId: String,
)

/**
* Message requesting the app to open the in-app barcode scanner overlay.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ object ConfigResultMessage {
val hasExoPlayer: Boolean = true,
val canCommissionMatter: Boolean,
val canImportThreadCredentials: Boolean,
val canTransferThreadCredentialsToKeychain: Boolean,
val hasAssist: Boolean = true,
val hasBarCodeScanner: Int,
val canSetupImprov: Boolean,
Expand All @@ -114,6 +115,9 @@ object ConfigResultMessage {
canWriteTag = hasNfc,
canCommissionMatter = canCommissionMatter,
canImportThreadCredentials = canExportThread,
// Same gate as canImportThreadCredentials: the HA -> Phone direction works on the
// same Android builds that already support the Phone -> HA direction.
canTransferThreadCredentialsToKeychain = canExportThread,
hasBarCodeScanner = hasBarCodeScanner,
canSetupImprov = canSetupImprov,
appVersion = appVersion.value,
Expand Down
Loading
Loading