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 9ce4629e85f..91715a440fc 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 @@ -256,7 +256,10 @@ 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 @@ -264,24 +267,19 @@ class ThreadManagerImpl @Inject constructor( .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 } } @@ -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 { diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModel.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModel.kt index b4d76502b43..e3b294a53d2 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModel.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModel.kt @@ -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, diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/dialog/FrontendDialog.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/dialog/FrontendDialog.kt index c45136f72fc..10169e59e2f 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/dialog/FrontendDialog.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/dialog/FrontendDialog.kt @@ -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. diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/dialog/FrontendDialogManager.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/dialog/FrontendDialogManager.kt index 1462b32a425..00894e19617 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/dialog/FrontendDialogManager.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/dialog/FrontendDialogManager.kt @@ -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, ) } @@ -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, ) } } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/dialog/InformationDialog.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/dialog/InformationDialog.kt index 18d3c348de4..642a69aed75 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/dialog/InformationDialog.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/dialog/InformationDialog.kt @@ -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 @@ -15,13 +17,30 @@ 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() { @@ -29,3 +48,17 @@ private fun PreviewInformationDialog() { 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/", + ), + ) + } +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/dialog/SimpleConfirmDialog.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/dialog/SimpleConfirmDialog.kt index e66ae1f56f0..a8aaa7b8643 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/dialog/SimpleConfirmDialog.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/dialog/SimpleConfirmDialog.kt @@ -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 @@ -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) }, @@ -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/", + ), + ) + } +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/externalbus/incoming/IncomingExternalBusMessage.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/externalbus/incoming/IncomingExternalBusMessage.kt index 011b3445059..d5e0c4ac3f0 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/externalbus/incoming/IncomingExternalBusMessage.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/externalbus/incoming/IncomingExternalBusMessage.kt @@ -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 thread-config-panel.ts + */ +@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. * diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/externalbus/outgoing/OutgoingExternalBusMessage.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/externalbus/outgoing/OutgoingExternalBusMessage.kt index 525ea6d693a..7440cbaae38 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/externalbus/outgoing/OutgoingExternalBusMessage.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/externalbus/outgoing/OutgoingExternalBusMessage.kt @@ -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, @@ -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, diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/handler/FrontendHandlerEvent.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/handler/FrontendHandlerEvent.kt index 2db35879bda..37c3a7e3d48 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/handler/FrontendHandlerEvent.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/handler/FrontendHandlerEvent.kt @@ -124,6 +124,27 @@ sealed interface FrontendHandlerEvent { */ data object ImportThreadCredentials : FrontendHandlerEvent + /** + * Inverse of [ImportThreadCredentials]: the frontend Thread panel asked the app to store a + * Thread Active Operational Dataset in the device's Thread credential storage (Google Play + * Services). + * + * @param borderAgentId Hex string identifying the preferred border router that owns the + * network on the server side; the credential is added under this BA so the orphan path + * can later remove it. + * @param tlv Decoded raw Thread Active Operational Dataset bytes. + */ + data class StoreThreadCredentialsInPlatformKeychain(val borderAgentId: String, val tlv: ByteArray) : + FrontendHandlerEvent { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is StoreThreadCredentialsInPlatformKeychain) return false + return borderAgentId == other.borderAgentId && tlv.contentEquals(other.tlv) + } + + override fun hashCode(): Int = 31 * borderAgentId.hashCode() + tlv.contentHashCode() + } + /** * Frontend requested the app to open the barcode scanner overlay. * diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/handler/FrontendMessageHandler.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/handler/FrontendMessageHandler.kt index 710ba9aec24..5dea7e5573d 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/handler/FrontendMessageHandler.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/handler/FrontendMessageHandler.kt @@ -34,6 +34,7 @@ import io.homeassistant.companion.android.frontend.externalbus.incoming.OpenSett import io.homeassistant.companion.android.frontend.externalbus.incoming.TagWriteMessage import io.homeassistant.companion.android.frontend.externalbus.incoming.ThemeUpdateMessage import io.homeassistant.companion.android.frontend.externalbus.incoming.ThreadImportCredentialsMessage +import io.homeassistant.companion.android.frontend.externalbus.incoming.ThreadStoreInPlatformKeychainMessage import io.homeassistant.companion.android.frontend.externalbus.incoming.UnknownIncomingMessage import io.homeassistant.companion.android.frontend.externalbus.outgoing.ConfigResultMessage import io.homeassistant.companion.android.frontend.externalbus.outgoing.EntityAddToActionsResultMessage @@ -298,6 +299,28 @@ class FrontendMessageHandler @Inject constructor( FrontendHandlerEvent.ImportThreadCredentials } + is ThreadStoreInPlatformKeychainMessage -> { + Timber.d( + "thread/store_in_platform_keychain received with id: %s (BA=%s)", + message.id, + message.payload.borderAgentId, + ) + val tlv = try { + decodeHex(message.payload.activeOperationalDataset) + } catch (e: Exception) { + Timber.w(e, "thread/store_in_platform_keychain: cannot decode TLV hex") + null + } + if (tlv == null || message.payload.borderAgentId.isBlank()) { + FrontendHandlerEvent.UnknownMessage + } else { + FrontendHandlerEvent.StoreThreadCredentialsInPlatformKeychain( + borderAgentId = message.payload.borderAgentId, + tlv = tlv, + ) + } + } + is UnknownIncomingMessage -> { Timber.d("Unknown message type received: ${message.content}") FrontendHandlerEvent.UnknownMessage @@ -328,4 +351,7 @@ class FrontendMessageHandler @Inject constructor( ) externalBusRepository.send(response) } + + @OptIn(ExperimentalStdlibApi::class) + private fun decodeHex(hex: String): ByteArray = hex.hexToByteArray() } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/matterthread/FrontendMatterThreadHandler.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/matterthread/FrontendMatterThreadHandler.kt index 7f89b034247..a5ac899e2c6 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/matterthread/FrontendMatterThreadHandler.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/matterthread/FrontendMatterThreadHandler.kt @@ -1,8 +1,11 @@ package io.homeassistant.companion.android.frontend.matterthread +import android.content.Context import android.content.IntentSender import androidx.activity.result.ActivityResult +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.scopes.ViewModelScoped +import io.homeassistant.companion.android.common.R as commonR import io.homeassistant.companion.android.common.data.servers.ServerManager import io.homeassistant.companion.android.frontend.dialog.FrontendDialogManager import io.homeassistant.companion.android.frontend.externalbus.FrontendExternalBusRepository @@ -19,6 +22,12 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope import timber.log.Timber +/** + * Companion documentation for Thread credential management, linked from the HA → Phone store + * dialogs so users can read up on how Android's preferred Thread network behaves. + */ +private const val THREAD_DOCS_URL = "https://companion.home-assistant.io/docs/integrations/thread" + /** * Coordinates the Matter commissioning and Thread credential export flows. * Sits between the external-bus handler events @@ -54,6 +63,7 @@ internal class FrontendMatterThreadHandler @Inject constructor( private val dialogManager: FrontendDialogManager, private val externalBusRepository: FrontendExternalBusRepository, private val serverManager: ServerManager, + @param:ApplicationContext private val applicationContext: Context, ) { /** @@ -190,12 +200,141 @@ internal class FrontendMatterThreadHandler @Inject constructor( when (current) { is InFlight.Matter -> handleMatterIntentResult(result) is InFlight.Thread -> handleThreadIntentResult(result, current.serverId) + is InFlight.ThreadStore -> Timber.w( + "Matter/Thread intent result received during a ThreadStore flow; ignoring " + + "(this flow does not launch an IntentSender)", + ) + } + } finally { + inFlight.set(null) + } + } + + /** + * Drive the HA → Phone Thread credential add flow triggered by the frontend Thread panel's + * "Send credentials to phone" button. + * + * Unlike [onImportThreadCredentials], this flow does not launch a Play Services + * `IntentSender` — the credential is added directly via [ThreadManager.addCredentialToDevice]. + * The shape is therefore: + * 1. progress dialog while [ThreadManager.predictPreferredOutcome] reads the stored + * credentials state; + * 2. preflight confirm/information dialog based on the prediction (4 variants); + * 3. progress dialog while [ThreadManager.addCredentialToDevice] writes; + * 4. result information dialog reporting whether Play Services prefers the just-added + * credential. + * + * The result is always reported via [FrontendDialogManager.showInformation] (not a terminal + * dialog/snackbar) because every message references the network name, which the + * @StringRes-only [MatterThreadTerminal] cannot interpolate. + * + * No-op if another flow is already in-flight. + */ + suspend fun onStoreThreadCredentialsInPlatformKeychain(serverId: Int, borderAgentId: String, tlv: ByteArray) { + if (!inFlight.compareAndSet(null, InFlight.ThreadStore)) { + Timber.w("thread/store_in_platform_keychain ignored: another flow is in-flight") + return + } + try { + if (!threadManager.appSupportsThread()) { + dialogManager.showInformation( + message = applicationContext.getString(commonR.string.thread_store_unsupported), + moreInfoUrl = THREAD_DOCS_URL, + ) + return + } + val networkName = threadManager.networkNameFromTlv(tlv) + if (networkName == null) { + dialogManager.showInformation( + applicationContext.getString(commonR.string.thread_store_invalid_payload), + ) + return } + val outcome = withProgress { threadManager.predictPreferredOutcome(tlv) } + val proceed = when (outcome) { + ThreadManager.PreflightOutcome.AlreadyPreferred -> { + dialogManager.showInformation( + message = applicationContext.getString( + commonR.string.thread_store_already_preferred, + networkName, + ), + moreInfoUrl = THREAD_DOCS_URL, + ) + return + } + is ThreadManager.PreflightOutcome.DifferentAppPreferred -> + dialogManager.showConfirm( + message = applicationContext.getString( + commonR.string.thread_store_different_preferred, + networkName, + outcome.networkName, + ), + moreInfoUrl = THREAD_DOCS_URL, + ) + ThreadManager.PreflightOutcome.LikelyToBecomePreferred -> + dialogManager.showConfirm( + message = applicationContext.getString( + commonR.string.thread_store_likely_preferred, + networkName, + ), + moreInfoUrl = THREAD_DOCS_URL, + ) + ThreadManager.PreflightOutcome.Unknown -> + dialogManager.showConfirm( + message = applicationContext.getString( + commonR.string.thread_store_unknown_preferred, + networkName, + ), + moreInfoUrl = THREAD_DOCS_URL, + ) + } + if (!proceed) return + + val resultMessage = try { + val isPreferred = withProgress { + threadManager.addCredentialToDevice(serverId = serverId, tlv = tlv, borderAgentId = borderAgentId) + } + Timber.d("Thread store: added '%s' (preferred=%s)", networkName, isPreferred) + when (isPreferred) { + true -> applicationContext.getString(commonR.string.thread_store_added_preferred, networkName) + false -> applicationContext.getString(commonR.string.thread_store_added_not_preferred, networkName) + null -> applicationContext.getString(commonR.string.thread_store_added_unknown, networkName) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.w(e, "Thread store: failed to add credential '%s'", networkName) + applicationContext.getString(commonR.string.thread_store_failed, networkName, e.message ?: "") + } + dialogManager.showInformation(message = resultMessage, moreInfoUrl = THREAD_DOCS_URL) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.e(e, "Unexpected error storing Thread credentials on the device") + dialogManager.showInformation( + message = applicationContext.getString(commonR.string.thread_store_failed_unknown), + moreInfoUrl = THREAD_DOCS_URL, + ) } finally { inFlight.set(null) } } + /** + * Run [block] with the Matter/Thread progress dialog on screen for its duration. The + * progress dialog is dismissed by cancelling its job — the [FrontendDialogManager] frees the + * slot from [SingleSlotQueue.awaitResult]'s cancellation handler so the next dialog can + * follow immediately. + */ + private suspend fun withProgress(block: suspend () -> T): T = supervisorScope { + val job = launch { dialogManager.showMatterThreadProgress() } + try { + block() + } finally { + job.cancel() + } + } + private suspend fun handleMatterIntentResult(result: ActivityResult) { when (val outcome = matterManager.parseCommissioningIntentResult(result)) { is MatterManager.CommissioningRequestResult.Success -> { @@ -267,5 +406,13 @@ internal class FrontendMatterThreadHandler @Inject constructor( private sealed interface InFlight { data object Matter : InFlight data class Thread(val serverId: Int) : InFlight + + /** + * HA → Phone Thread credential add flow. No payload here: this flow does not produce an + * IntentSender, so [onMatterThreadIntentResult] never needs to dispatch on it. The + * variant only exists to occupy [inFlight] and block other flows from running + * concurrently. + */ + data object ThreadStore : InFlight } } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/matterthread/ui/MatterThreadDialogs.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/matterthread/ui/MatterThreadDialogs.kt index 6f856a49563..e6f60e091bb 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/matterthread/ui/MatterThreadDialogs.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/matterthread/ui/MatterThreadDialogs.kt @@ -31,7 +31,7 @@ internal fun MatterThreadProgressDialogContent() { Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { HALoading(modifier = Modifier.padding(top = HADimens.SPACE4)) Text( - text = stringResource(commonR.string.thread_debug_active), + text = stringResource(commonR.string.matter_thread_working), style = HATextStyle.Body, modifier = Modifier.padding(top = HADimens.SPACE4), ) 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 1a74231c51f..334535149f0 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 @@ -80,6 +80,43 @@ interface ThreadManager { ) : SyncResult() } + /** + * Predicted state of the Thread credentials stored on the device if a given credential were + * added now. + * + * Surfaced to the user before a write so they understand the post-add state: Play Services + * exposes no API to remove "preferred" status from a Thread credential once it has been + * promoted, so a wrongly-promoted credential is hard to recover from inside the app. + */ + sealed class PreflightOutcome { + /** The credential being inspected is already this device's preferred Thread credential. */ + object AlreadyPreferred : PreflightOutcome() + + /** + * Another credential the app added is already this device's preferred Thread network. + * Play Services typically keeps preferring it; adding a new credential is unlikely to + * change which network is preferred. + */ + data class DifferentAppPreferred(val networkName: String) : PreflightOutcome() + + /** + * The app has no credentials stored on this device. Adding a credential is likely to + * make it the device-preferred one (Play Services promotes the first credential added). + * + * This is a best-effort prediction: the API only exposes credentials owned by the + * calling app, so a preferred network owned by another app — or synced by Play Services + * from a Google border router set up with the user's account — is invisible here and + * would win over ours. + */ + object LikelyToBecomePreferred : PreflightOutcome() + + /** + * The device has Thread credentials but our app has none preferred. Another app may + * own the preferred credential; the outcome of adding ours is ambiguous. + */ + object Unknown : PreflightOutcome() + } + /** * Indicates if the app on this device supports Thread credential management. */ @@ -136,4 +173,33 @@ interface ThreadManager { * @return Network name that was sent and accepted, or `null` if not sent or accepted */ suspend fun sendThreadDatasetExportResult(result: ActivityResult, serverId: Int): String? + + /** + * Parse a Thread Active Operational Dataset TLV and return the embedded network name, or + * `null` if the TLV is malformed or Thread isn't supported by the current build. + */ + fun networkNameFromTlv(tlv: ByteArray): String? + + /** + * Predict what state the Thread credentials stored on the device will be in if [tlv] is + * added now. + * + * Used by the HA → Phone direction (the frontend Thread panel's "Send credentials to phone" + * button) to surface a meaningful confirmation before mutating the stored credentials. + */ + suspend fun predictPreferredOutcome(tlv: ByteArray): PreflightOutcome + + /** + * Add the given Thread credential ([tlv]) to the device's credential storage under + * [borderAgentId]. + * + * The BA-ID is appended to the per-server tracked list so the orphan-cleanup path can + * remove the credential when [serverId] is later removed. After the add the device's + * preferred credential is queried so the caller can report the actual outcome. + * + * @return `true` if Play Services now reports the just-added credential as preferred, + * `false` if it prefers a different credential, or `null` if the post-add state could not + * be determined. + */ + suspend fun addCredentialToDevice(serverId: Int, tlv: ByteArray, borderAgentId: String): Boolean? } diff --git a/app/src/main/res/xml/changelog_master.xml b/app/src/main/res/xml/changelog_master.xml index aa7d3a5d66b..deb6309746f 100755 --- a/app/src/main/res/xml/changelog_master.xml +++ b/app/src/main/res/xml/changelog_master.xml @@ -2,6 +2,7 @@ + Store Thread credentials from Home Assistant on this device via the frontend Thread panel Android 17: added assistant volume level sensor + notification command for control Health Connect sleep duration sensor now ignores awake and out of bed time Modernized settings for entity widgets and tiles diff --git a/app/src/minimal/kotlin/io/homeassistant/companion/android/thread/ThreadManagerImpl.kt b/app/src/minimal/kotlin/io/homeassistant/companion/android/thread/ThreadManagerImpl.kt index 8d96427c944..004b602275b 100644 --- a/app/src/minimal/kotlin/io/homeassistant/companion/android/thread/ThreadManagerImpl.kt +++ b/app/src/minimal/kotlin/io/homeassistant/companion/android/thread/ThreadManagerImpl.kt @@ -30,4 +30,11 @@ class ThreadManagerImpl @Inject constructor() : ThreadManager { } override suspend fun sendThreadDatasetExportResult(result: ActivityResult, serverId: Int): String? = null + + override fun networkNameFromTlv(tlv: ByteArray): String? = null + + override suspend fun predictPreferredOutcome(tlv: ByteArray): ThreadManager.PreflightOutcome = + ThreadManager.PreflightOutcome.Unknown + + override suspend fun addCredentialToDevice(serverId: Int, tlv: ByteArray, borderAgentId: String): Boolean? = null } diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/dialog/InformationDialogTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/dialog/InformationDialogTest.kt new file mode 100644 index 00000000000..84af586254b --- /dev/null +++ b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/dialog/InformationDialogTest.kt @@ -0,0 +1,100 @@ +package io.homeassistant.companion.android.frontend.dialog + +import android.content.Intent +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.HiltTestApplication +import io.homeassistant.companion.android.HiltComponentActivity +import io.homeassistant.companion.android.common.R as commonR +import io.homeassistant.companion.android.testing.unit.stringResource +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(application = HiltTestApplication::class) +@HiltAndroidTest +class InformationDialogTest { + + @get:Rule(order = 1) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 2) + val composeTestRule = createAndroidComposeRule() + + @Test + fun `Given dialog shown then title message and OK button are displayed`() { + composeTestRule.apply { + setContent { + InformationDialog( + FrontendDialog.Information(message = "Something happened", onDismiss = {}), + ) + } + + onNodeWithText(stringResource(commonR.string.app_name)).assertIsDisplayed() + onNodeWithText("Something happened").assertIsDisplayed() + onNodeWithText(stringResource(commonR.string.ok)).assertIsDisplayed() + } + } + + @Test + fun `Given dialog shown when OK clicked then onDismiss is called`() { + var dismissed = false + + composeTestRule.apply { + setContent { + InformationDialog( + FrontendDialog.Information(message = "Something happened", onDismiss = { dismissed = true }), + ) + } + + onNodeWithText(stringResource(commonR.string.ok)).performClick() + + assertTrue(dismissed) + } + } + + @Test + fun `Given dialog without moreInfoUrl then Learn more is not displayed`() { + composeTestRule.apply { + setContent { + InformationDialog( + FrontendDialog.Information(message = "Something happened", onDismiss = {}), + ) + } + + onNodeWithText(stringResource(commonR.string.learn_more)).assertDoesNotExist() + } + } + + @Test + fun `Given dialog with moreInfoUrl when Learn more clicked then URL opens and dialog stays`() { + composeTestRule.apply { + setContent { + InformationDialog( + FrontendDialog.Information( + message = "Something happened", + onDismiss = {}, + moreInfoUrl = "https://example.com/docs", + ), + ) + } + + onNodeWithText(stringResource(commonR.string.learn_more)).performClick() + + val startedIntent = Shadows.shadowOf(activity).nextStartedActivity + assertEquals(Intent.ACTION_VIEW, startedIntent.action) + assertEquals("https://example.com/docs", startedIntent.data.toString()) + onNodeWithText("Something happened").assertIsDisplayed() + } + } +} diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/dialog/SimpleConfirmDialogTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/dialog/SimpleConfirmDialogTest.kt index dfc72f48f16..e8b0faddae3 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/dialog/SimpleConfirmDialogTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/dialog/SimpleConfirmDialogTest.kt @@ -1,5 +1,6 @@ package io.homeassistant.companion.android.frontend.dialog +import android.content.Intent import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule @@ -11,11 +12,13 @@ import dagger.hilt.android.testing.HiltTestApplication import io.homeassistant.companion.android.HiltComponentActivity import io.homeassistant.companion.android.common.R as commonR import io.homeassistant.companion.android.testing.unit.stringResource +import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) @@ -70,6 +73,46 @@ class SimpleConfirmDialogTest { } } + @Test + fun `Given dialog without moreInfoUrl then Learn more is not displayed`() { + composeTestRule.apply { + setContent { + SimpleConfirmDialog( + FrontendDialog.Confirm( + message = "Are you sure?", + onConfirm = {}, + onCancel = {}, + ), + ) + } + + onNodeWithText(stringResource(commonR.string.learn_more)).assertDoesNotExist() + } + } + + @Test + fun `Given dialog with moreInfoUrl when Learn more clicked then URL opens and dialog stays`() { + composeTestRule.apply { + setContent { + SimpleConfirmDialog( + FrontendDialog.Confirm( + message = "Are you sure?", + onConfirm = {}, + onCancel = {}, + moreInfoUrl = "https://example.com/docs", + ), + ) + } + + onNodeWithText(stringResource(commonR.string.learn_more)).performClick() + + val startedIntent = Shadows.shadowOf(activity).nextStartedActivity + assertEquals(Intent.ACTION_VIEW, startedIntent.action) + assertEquals("https://example.com/docs", startedIntent.data.toString()) + onNodeWithText("Are you sure?").assertIsDisplayed() + } + } + @Test fun `Given dialog shown when Cancel clicked then onCancel is called`() { var cancelCalled = false diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/externalbus/outgoing/OutgoingExternalBusMessageTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/externalbus/outgoing/OutgoingExternalBusMessageTest.kt index c3188440546..ff55a9bcdb5 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/externalbus/outgoing/OutgoingExternalBusMessageTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/externalbus/outgoing/OutgoingExternalBusMessageTest.kt @@ -23,7 +23,7 @@ class OutgoingExternalBusMessageTest { ), ) assertEquals( - """{"type":"result","id":1,"success":true,"result":{"hasSettingsScreen":true,"canWriteTag":true,"hasExoPlayer":true,"canCommissionMatter":true,"canImportThreadCredentials":true,"hasAssist":true,"hasBarCodeScanner":0,"canSetupImprov":true,"downloadFileSupported":true,"appVersion":"1.0.0 (1)","hasEntityAddTo":true,"hasAssistSettings":true,"hasSplashscreen":true,"hasMatterStatusReport":true},"error":null}""", + """{"type":"result","id":1,"success":true,"result":{"hasSettingsScreen":true,"canWriteTag":true,"hasExoPlayer":true,"canCommissionMatter":true,"canImportThreadCredentials":true,"canTransferThreadCredentialsToKeychain":true,"hasAssist":true,"hasBarCodeScanner":0,"canSetupImprov":true,"downloadFileSupported":true,"appVersion":"1.0.0 (1)","hasEntityAddTo":true,"hasAssistSettings":true,"hasSplashscreen":true,"hasMatterStatusReport":true},"error":null}""", json, ) } diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/matterthread/FrontendMatterThreadHandlerTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/matterthread/FrontendMatterThreadHandlerTest.kt index ab03d6d3d9f..d843f46a66d 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/matterthread/FrontendMatterThreadHandlerTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/matterthread/FrontendMatterThreadHandlerTest.kt @@ -43,6 +43,7 @@ class FrontendMatterThreadHandlerTest { dialogManager = dialogManager, externalBusRepository = externalBusRepository, serverManager = serverManager, + applicationContext = mockk(relaxed = true), ) private fun givenServerVersion(version: HomeAssistantVersion?) { diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index 43c3371ab7f..5647c539b86 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -1212,6 +1212,18 @@ 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. Thread is currently unavailable + Thread credential management isn\'t supported on this build of the app. + Home Assistant didn\'t send a usable Thread credential. + "%1$s" is already this phone\'s preferred Thread network. Nothing to do. + This phone currently prefers "%2$s". Adding "%1$s" will store the credential, but the phone will probably keep preferring "%2$s" — Google Play Services decides which Thread network is preferred and the app cannot change that. Add anyway? + Home Assistant doesn\'t have any Thread credentials stored on this phone yet. Unless another app (such as Google Home or IKEA Home smart) has already set a preferred Thread network, adding "%1$s" will likely make it the preferred network — used when commissioning new Matter Thread devices from this phone. Preferred status can\'t be changed from inside the app afterwards. Continue? + "%1$s" will be added to the Thread credentials stored on this phone. Whether it becomes the preferred network depends on other Thread apps on this phone and any Thread border routers nearby. Continue? + "%1$s" has been added and is now this phone\'s preferred Thread network. + "%1$s" has been added, but this phone still prefers a different Thread network. + "%1$s" has been added. The phone\'s preferred Thread network couldn\'t be confirmed. + Couldn\'t add "%1$s" to this phone: %2$s + Couldn\'t add the Thread credential to this phone. + Working… Vibrate when selected Requires unlocked device You must be at least on Home Assistant %1$s and have the %2$s integration enabled