Skip to content

Support storing Thread credentials from Thread configuration panel - #7303

Open
agners wants to merge 6 commits into
home-assistant:mainfrom
agners:feat/thread-frontend-driven-import
Open

Support storing Thread credentials from Thread configuration panel#7303
agners wants to merge 6 commits into
home-assistant:mainfrom
agners:feat/thread-frontend-driven-import

Conversation

@agners

@agners agners commented Aug 5, 2026

Copy link
Copy Markdown
Member

Summary

Handle the thread/store_in_platform_keychain external 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.)

  • ThreadManager gains 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.
  • canTransferThreadCredentialsToKeychain is reported in the config response (same gate as the import direction) so the frontend only surfaces the button on supported devices.
  • FrontendMatterThreadHandler drives 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.
  • The Confirm and Information frontend 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).
  • The shared Matter/Thread progress dialog now uses a neutral "Working…" label instead of reusing the Developer Settings "Syncing…" string, since it covers commissioning, import, and this new flow.

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 syncPreferredDataset and its reconciliation-only result variants, leaving the two explicit primitives as the only credential paths.

Checklist

  • New or updated tests have been added to cover the changes following the testing guidelines.
  • The code follows the project's code style and best_practices.
  • The changes have been thoroughly tested, and edge cases have been considered.
  • Changes are backward compatible whenever feasible. Any breaking changes are documented in the changelog for users and/or in the code for developers depending on the relevance.

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 full flavor (Google Play Services Thread network client); on other builds the flow reports that Thread credential management isn't supported.

agners and others added 6 commits August 5, 2026 09:49
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>
Copilot AI lite review requested due to automatic review settings August 5, 2026 08:56
@agners

agners commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

A few screenshots showing the flow when adding the credentials via the new button.

Screenshot_20260805_094225 Screenshot_20260805_093648 Screenshot_20260805_093745 Screenshot_20260805_093904

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants