Support storing Thread credentials from Thread configuration panel - #7303
Open
agners wants to merge 6 commits into
Open
Support storing Thread credentials from Thread configuration panel#7303agners wants to merge 6 commits into
agners wants to merge 6 commits into
Conversation
The frontend Thread panel's "Send credentials to phone" button is the inverse of the existing "Send credentials to Home Assistant" flow: instead of reading a credential off the device and uploading it, the frontend ships a Thread Active Operational Dataset down via the external bus and the app stores it in Google Play Services. Add three primitives on ThreadManager that the new flow needs: - networkNameFromTlv(tlv): parse the embedded network name without going through Play Services. Used to label dialogs before mutating the stored credentials. - predictPreferredOutcome(tlv): classify the prospective add against the credentials currently stored on the device - AlreadyPreferred, DifferentAppPreferred(name), LikelyToBecomePreferred, or Unknown. Surfaced to the user before the write because Play Services has no API to remove "preferred" status from a credential once promoted. - addCredentialToDevice(serverId, tlv, borderAgentId): wraps ThreadNetworkClient.addCredentials, persists the BA-ID into the per-server tracked list (so the orphan path can clean it up if the server is later removed), and returns the post-add isPreferredCredentials verdict. Refactor appAddedIsPreferredCredentials so it shares the stored credentials read with a new appAddedPreferredCredential helper that returns the matched credential. Format the "device prefers app added dataset" debug log extended-PAN-ID as upper-case hex - it was previously emitting raw bytes via String(ByteArray) which produced control characters. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The frontend Thread panel's "Send credentials to phone" button on a preferred-dataset row fires a thread/store_in_platform_keychain external bus message with the active operational dataset inlined. The app stores it in the device's Thread credentials storage via Google Play Services. (The message name follows the frontend/iOS naming, where the credentials land in the Apple Keychain; Google's Thread SDK only describes its storage as "similar to a digital keychain".) Wire it through the existing pr-6951 scaffolding: - Add ThreadStoreInPlatformKeychainMessage to the typed incoming bus message hierarchy alongside ThreadImportCredentialsMessage. - Add canTransferThreadCredentialsToKeychain to ConfigResult so the frontend button surfaces on Android (same support gate as the inverse direction). - Add FrontendHandlerEvent.StoreThreadCredentialsInPlatformKeychain carrying the decoded TLV and the preferred border-agent ID. - Extend FrontendMatterThreadHandler with onStoreThreadCredentialsInPlatformKeychain and an InFlight.ThreadStore variant so it blocks concurrent flows just like the other directions. - Use dialogManager.showMatterThreadProgress() to cover the long Play Services round-trip in two places: predictPreferredOutcome and addCredentialToDevice. - Use dialogManager.showInformation/showConfirm to surface the four preflight states (already-preferred, different-app-preferred, likely-to-become-preferred, unknown) and the four result states (added-preferred, added-not-preferred, added-unknown, failed) with the network name interpolated — MatterThreadTerminal's @stringres shape doesn't carry interpolation parameters, so these go through Information rather than MatterThreadTerminalDialog. The result strings are deliberately blunt about Play Services' opacity: addCredentials returns success without guaranteeing the credential becomes preferred, and there is no in-app way to clear "preferred" status once Play Services has set it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MatterThreadProgressDialogContent was reusing thread_debug_active
("Syncing…"), a string authored for the old Developer Settings
"Sync Thread credentials" flow that literally reconciles both sides
of a Thread network. That flow is only reached from the developer
settings entry now; the progress dialog is shown for every
Matter/Thread flow the frontend triggers — Matter commissioning,
Phone → HA Thread export, and the HA → Phone Thread credential add —
none of which are syncs.
Introduce a neutral matter_thread_working string ("Working…") and use
it for the progress dialog. thread_debug_active stays where the
Developer Settings sync flow still uses it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
FrontendDialog.Confirm and Information gain an optional moreInfoUrl. When set, the dialog renders a "Learn more" plain button below the message that opens the URL in the browser without closing the dialog, so users can read documentation and come back to answer. The link is asserted through the started ACTION_VIEW intent because a Compose Dialog composes its content in a separate window root that re-provides the platform LocalUriHandler, so a fake handler provided around the dialog composable never reaches the button. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All dialogs of the thread/store_in_platform_keychain flow now carry a "Learn more" link to the companion documentation (https://companion.home-assistant.io/docs/integrations/thread, to be added with this feature) where the behavior of Android's preferred Thread network can be explained in depth. Reword thread_store_likely_preferred: it fired when *the app* has no stored credentials, but claimed *the phone* has no preferred network. Google Play services only exposes credentials owned by the calling app, and the preferred network may be owned by another app (Google Home, IKEA Home smart, ...) or synced by Play services from a Google border router set up with the user's account — both invisible to us. The new text states what we actually know and keeps the warning that preferred status cannot be changed from inside the app. The LikelyToBecomePreferred KDoc documents the same blind spot. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Member
Author
Contributor
There was a problem hiding this comment.
Pull request overview
Implements the Android side of the frontend Thread panel’s “Send credentials to phone” action by handling the thread/store_in_platform_keychain external bus message, running a preflight/confirmation flow, and storing the received operational dataset into the device’s Thread credential storage (Play Services on full flavor).
Changes:
- Added a new HA → Phone Thread credential store flow in
FrontendMatterThreadHandler, including preflight outcome messaging, progress dialog usage, and result dialogs. - Extended external bus message models and config response (
canTransferThreadCredentialsToKeychain) to support the frontend’s capability gating. - Enhanced frontend dialogs (
Confirm/Information) with an optional “Learn more” link and added Robolectric/Compose test coverage.
Reviewed changes
Copilot reviewed 20 out of 20 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| common/src/main/res/values/strings.xml | Adds new user-facing strings for the Thread store flow and a neutral “Working…” label for shared progress UI. |
| app/src/main/res/xml/changelog_master.xml | Changelog entry for the new Thread credential store capability. |
| app/src/main/kotlin/io/homeassistant/companion/android/thread/ThreadManager.kt | Adds Thread store-related APIs and models (preflight outcome, TLV network name parsing, add-to-device). |
| app/src/full/kotlin/io/homeassistant/companion/android/thread/ThreadManagerImpl.kt | Implements TLV parsing, preferred-outcome prediction, and credential add + per-server BA tracking in full flavor. |
| app/src/minimal/kotlin/io/homeassistant/companion/android/thread/ThreadManagerImpl.kt | Stubs the new ThreadManager APIs for minimal flavor (unsupported). |
| app/src/main/kotlin/io/homeassistant/companion/android/frontend/matterthread/FrontendMatterThreadHandler.kt | Drives the HA → Phone store flow with progress + confirm/info dialogs, and ties it into in-flight flow coordination. |
| app/src/main/kotlin/io/homeassistant/companion/android/frontend/matterthread/ui/MatterThreadDialogs.kt | Updates progress dialog label to use the new “Working…” string. |
| app/src/main/kotlin/io/homeassistant/companion/android/frontend/handler/FrontendMessageHandler.kt | Decodes thread/store_in_platform_keychain payload TLV hex and maps it to a new handler event. |
| app/src/main/kotlin/io/homeassistant/companion/android/frontend/handler/FrontendHandlerEvent.kt | Adds a new event type for storing Thread credentials on-device with proper equals/hash for ByteArray. |
| app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModel.kt | Dispatches the new store event to FrontendMatterThreadHandler. |
| app/src/main/kotlin/io/homeassistant/companion/android/frontend/externalbus/outgoing/OutgoingExternalBusMessage.kt | Adds canTransferThreadCredentialsToKeychain to config results (same gate as import). |
| app/src/main/kotlin/io/homeassistant/companion/android/frontend/externalbus/incoming/IncomingExternalBusMessage.kt | Adds the ThreadStoreInPlatformKeychainMessage + payload model and KDoc. |
| app/src/main/kotlin/io/homeassistant/companion/android/frontend/dialog/FrontendDialog.kt | Extends Confirm and Information dialog models with an optional moreInfoUrl. |
| app/src/main/kotlin/io/homeassistant/companion/android/frontend/dialog/FrontendDialogManager.kt | Adds optional moreInfoUrl plumbing for confirm/information dialogs. |
| app/src/main/kotlin/io/homeassistant/companion/android/frontend/dialog/SimpleConfirmDialog.kt | Renders a “Learn more” action in confirm dialogs when a URL is provided. |
| app/src/main/kotlin/io/homeassistant/companion/android/frontend/dialog/InformationDialog.kt | Renders a “Learn more” action in information dialogs and adds a reusable MoreInfoButton. |
| app/src/test/kotlin/io/homeassistant/companion/android/frontend/matterthread/FrontendMatterThreadHandlerTest.kt | Updates handler test wiring for the new injected application context. |
| app/src/test/kotlin/io/homeassistant/companion/android/frontend/externalbus/outgoing/OutgoingExternalBusMessageTest.kt | Updates expected config JSON to include canTransferThreadCredentialsToKeychain. |
| app/src/test/kotlin/io/homeassistant/companion/android/frontend/dialog/SimpleConfirmDialogTest.kt | Adds tests for presence/behavior of “Learn more” in confirm dialogs. |
| app/src/test/kotlin/io/homeassistant/companion/android/frontend/dialog/InformationDialogTest.kt | New tests for information dialog UI and “Learn more” behavior. |
Suppressed comments (2)
app/src/main/kotlin/io/homeassistant/companion/android/frontend/matterthread/FrontendMatterThreadHandler.kt:251
- The invalid-payload information dialog in this flow doesn’t include the documentation link, but the PR description states all dialogs in this HA → Phone store flow should offer “Learn more”. Consider passing moreInfoUrl here as well so the user can understand what happened and how Thread credential preference works.
if (networkName == null) {
dialogManager.showInformation(
applicationContext.getString(commonR.string.thread_store_invalid_payload),
)
return
app/src/main/kotlin/io/homeassistant/companion/android/frontend/matterthread/FrontendMatterThreadHandler.kt:308
- thread_store_failed includes an error-detail placeholder, but this code passes an empty string when e.message is null/blank, producing a user-facing message that ends with a dangling “: ”. Consider falling back to thread_store_failed_unknown (or another non-detailed string) when there’s no usable detail.
} catch (e: Exception) {
Timber.w(e, "Thread store: failed to add credential '%s'", networkName)
applicationContext.getString(commonR.string.thread_store_failed, networkName, e.message ?: "")
}
Comment on lines
+208
to
+210
| } finally { | ||
| inFlight.set(null) | ||
| } |
Comment on lines
+96
to
+100
| * 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() |
| * raw Thread TLV. | ||
| * | ||
| * Will not be sent by the frontend when the device reports | ||
| * [io.homeassistant.companion.android.frontend.externalbus.outgoing.ConfigResult.canTransferThreadCredentialsToKeychain] = `false`. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.




Summary
Handle the
thread/store_in_platform_keychainexternal bus message, the inverse of the existing Phone → HA Thread credential import (#6951): the frontend Thread panel's "Send credentials to phone" button sends the active operational dataset to the app, which stores it in the device's Thread credential storage via Google Play Services. (The bus message name follows the frontend/iOS naming, where the credentials land in the Apple Keychain.)ThreadManagergains the HA → Phone primitives: parsing the network name from the TLV (to label dialogs before any storage mutation), predicting whether the credential will become the device's preferred one, and adding the credential while tracking its border agent ID per server so orphan cleanup keeps working.canTransferThreadCredentialsToKeychainis reported in the config response (same gate as the import direction) so the frontend only surfaces the button on supported devices.FrontendMatterThreadHandlerdrives the flow with a progress dialog over the Play Services round-trips, a confirmation step before the write, and a result dialog afterwards. Because Play Services offers no way to remove "preferred" status once set, the user is warned up front when adding would demote another credential, and the result dialogs are explicit about whether the credential actually became preferred. The preflight texts only claim what the app can actually observe — Play Services hides credentials owned by other apps, so the "will likely become preferred" case is hedged accordingly.ConfirmandInformationfrontend dialogs support an optional "Learn more" link; all dialogs of this flow link to the companion documentation (a new Thread page explaining Android's preferred-network behavior, added via the documentation PR below).Relationship to the existing credential sync
The app has an older, automatic bidirectional reconciliation in Settings > Companion app > Troubleshooting > Sync Thread credentials (
ThreadManager.syncPreferredDataset): it imports the server dataset when the device has none, offers exporting the device's preferred dataset when the server has none, and reconciles/cleans up stale app-added datasets when both sides have one. Its silent decision-making has been a recurring source of confusion (see #4146: sync reports success while the device keeps preferring a stale network).The frontend-driven flows replace that model with two explicit, user-initiated directions: Phone → HA (#6951, one-way
exportPreferredDataset) and HA → Phone (this PR), each with its own consent/preflight messaging. This PR reuses the sync's building blocks where they fit — the orphaned-credential cleanup now also runs on the HA → Phone add path, and the per-server border-agent tracking list is shared.The Troubleshooting sync is deliberately left untouched here. Once both explicit directions have shipped (app and frontend), a follow-up can remove it together with
syncPreferredDatasetand its reconciliation-only result variants, leaving the two explicit primitives as the only credential paths.Checklist
Link to pull request in documentation repositories
User Documentation: home-assistant/companion.home-assistant#
Any other notes
No frontend changes are needed: the "Send credentials to phone" button already exists — added for iOS in home-assistant/frontend#20743 and moved to the main Thread configuration panel in home-assistant/frontend#21066 — and is shown once the app reports
canTransferThreadCredentialsToKeychain. This PR implements the Android side of that existing external bus message.The feature only works on the
fullflavor (Google Play Services Thread network client); on other builds the flow reports that Thread credential management isn't supported.