Thread updates: add border agent ID, multiserver improvements (#3820)

* Add/update/delete Thread credentials by border agent ID

* Multiserver: only keep one HA credential, handle credentials for deleted servers

 - Make sure the app only stores one HA credential that matches the preferred credential for the server executing the sync.
 - Create an 'orphaned Thread credentials' system to be able to delete credentials for servers that have been previously deleted.

* Fix minimal
This commit is contained in:
Joris Pelgröm 2023-08-26 22:54:40 +02:00 committed by GitHub
parent 6c2e555e57
commit 6d4fa06545
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 124 additions and 13 deletions

View file

@ -28,7 +28,7 @@ class ThreadManagerImpl @Inject constructor(
companion object {
private const val TAG = "ThreadManagerImpl"
// ID is a placeholder while we wait for Google to remove the requirement to provide one
// ID is a placeholder used in previous app versions / for older Home Assistant versions
private const val BORDER_AGENT_ID = "0000000000000001"
}
@ -54,6 +54,8 @@ class ThreadManagerImpl @Inject constructor(
if (!appSupportsThread()) return ThreadManager.SyncResult.AppUnsupported
if (!coreSupportsThread(serverId)) return ThreadManager.SyncResult.ServerUnsupported
deleteOrphanedThreadCredentials(context, serverId)
val getDeviceDataset = scope.async { getPreferredDatasetFromDevice(context) }
val getCoreDatasets = scope.async { getDatasetsFromServer(serverId) }
val deviceThreadIntent = getDeviceDataset.await()
@ -62,7 +64,8 @@ class ThreadManagerImpl @Inject constructor(
return if (deviceThreadIntent == null && coreThreadDataset != null) {
try {
importDatasetFromServer(context, coreThreadDataset.datasetId, serverId)
importDatasetFromServer(context, coreThreadDataset.datasetId, coreThreadDataset.preferredBorderAgentId, serverId)
serverManager.integrationRepository(serverId).setThreadBorderAgentIds(listOf((coreThreadDataset.preferredBorderAgentId ?: BORDER_AGENT_ID)))
Log.d(TAG, "Thread import to device completed")
ThreadManager.SyncResult.OnlyOnServer(imported = true)
} catch (e: Exception) {
@ -83,13 +86,48 @@ class ThreadManagerImpl @Inject constructor(
var updated: Boolean? = null
if (!coreIsDevicePreferred) {
if (appIsDevicePreferred) {
// Update or remove the device preferred credential to match core state
// Update or remove the device preferred credential to match core state.
// The device credential store currently doesn't allow the user to choose
// which credential should be used. To prevent unexpected behavior, HA only
// contributes one credential at a time, which is for _this_ server.
try {
val localIds = serverManager.defaultServers.flatMap {
serverManager.integrationRepository(it.id).getThreadBorderAgentIds()
}.toMutableList()
if (localIds.isEmpty()) { // Prefers something from HA, must've been added before BA ID logic
localIds += BORDER_AGENT_ID
}
updated = if (coreThreadDataset.source != "Google") { // Credential from HA, update
importDatasetFromServer(context, coreThreadDataset.datasetId, serverId)
localIds.filter { it != coreThreadDataset.preferredBorderAgentId }.forEach { baId ->
try {
deleteThreadCredential(context, baId)
} catch (e: Exception) {
Log.e(TAG, "Unable to delete credential for border agent ID $baId", e)
}
}
importDatasetFromServer(context, coreThreadDataset.datasetId, coreThreadDataset.preferredBorderAgentId, serverId)
serverManager.defaultServers.forEach {
serverManager.integrationRepository(it.id).setThreadBorderAgentIds(
if (it.id == serverId) {
listOf(coreThreadDataset.preferredBorderAgentId ?: BORDER_AGENT_ID)
} else {
emptyList()
}
)
}
true
} else { // Imported from another app, so this shouldn't be managed by HA
deleteThreadCredential(context)
} else { // Core prefers imported from other app, this shouldn't be managed by HA
localIds.forEach { baId ->
try {
deleteThreadCredential(context, baId)
} catch (e: Exception) {
Log.e(TAG, "Unable to delete credential for border agent ID $baId", e)
}
}
serverManager.defaultServers.forEach {
serverManager.integrationRepository(it.id).setThreadBorderAgentIds(emptyList())
}
false
}
Log.d(TAG, "Thread update device completed")
@ -120,10 +158,21 @@ class ThreadManagerImpl @Inject constructor(
override suspend fun getPreferredDatasetFromServer(serverId: Int): ThreadDatasetResponse? =
getDatasetsFromServer(serverId)?.firstOrNull { it.preferred }
override suspend fun importDatasetFromServer(context: Context, datasetId: String, serverId: Int) {
override suspend fun importDatasetFromServer(
context: Context,
datasetId: String,
preferredBorderAgentId: String?,
serverId: Int
) {
val tlv = serverManager.webSocketRepository(serverId).getThreadDatasetTlv(datasetId)?.tlvAsByteArray
if (tlv != null) {
val threadBorderAgent = ThreadBorderAgent.newBuilder(BORDER_AGENT_ID.toByteArray()).build()
val borderAgentId = (
preferredBorderAgentId ?: run {
Log.w(TAG, "Adding dataset with placeholder border agent ID")
BORDER_AGENT_ID
}
).toByteArray()
val threadBorderAgent = ThreadBorderAgent.newBuilder(borderAgentId).build()
val threadNetworkCredentials = ThreadNetworkCredentials.fromActiveOperationalDataset(tlv)
suspendCoroutine { cont ->
ThreadNetwork.getClient(context).addCredentials(threadBorderAgent, threadNetworkCredentials)
@ -189,9 +238,22 @@ class ThreadManagerImpl @Inject constructor(
return null
}
private suspend fun deleteThreadCredential(context: Context) = suspendCoroutine { cont ->
// This only works because we currently always use the same border agent ID
val threadBorderAgent = ThreadBorderAgent.newBuilder(BORDER_AGENT_ID.toByteArray()).build()
private suspend fun deleteOrphanedThreadCredentials(context: Context, serverId: Int) {
val orphanedCredentials = serverManager.integrationRepository(serverId).getThreadBorderAgentIds()
if (orphanedCredentials.isEmpty()) return
orphanedCredentials.forEach {
try {
deleteThreadCredential(context, it)
} catch (e: Exception) {
Log.w(TAG, "Unable to delete credential for border agent ID $it", e)
}
}
serverManager.integrationRepository(serverId).clearOrphanedThreadBorderAgentIds()
}
private suspend fun deleteThreadCredential(context: Context, borderAgentId: String) = suspendCoroutine { cont ->
val threadBorderAgent = ThreadBorderAgent.newBuilder(borderAgentId.toByteArray()).build()
ThreadNetwork.getClient(context)
.removeCredentials(threadBorderAgent)
.addOnSuccessListener { cont.resume(true) }

View file

@ -48,10 +48,16 @@ interface ThreadManager {
/**
* Import a Thread dataset from the server to this device.
* @param datasetId The dataset ID as provided by the server
* @param preferredBorderAgentId The ID for the border agent that provides the dataset
* @throws Exception if a preferred dataset exists on the server, but it wasn't possible to
* import it
*/
suspend fun importDatasetFromServer(context: Context, datasetId: String, serverId: Int)
suspend fun importDatasetFromServer(
context: Context,
datasetId: String,
preferredBorderAgentId: String?,
serverId: Int
)
/**
* Start a flow to get the preferred Thread dataset from this device to export to the server.

View file

@ -24,7 +24,12 @@ class ThreadManagerImpl @Inject constructor() : ThreadManager {
override suspend fun getPreferredDatasetFromServer(serverId: Int): ThreadDatasetResponse? = null
override suspend fun importDatasetFromServer(context: Context, datasetId: String, serverId: Int) { }
override suspend fun importDatasetFromServer(
context: Context,
datasetId: String,
preferredBorderAgentId: String?,
serverId: Int
) { }
override suspend fun getPreferredDatasetFromDevice(context: Context): IntentSender? {
throw IllegalStateException("Thread is not supported with the minimal flavor")

View file

@ -68,6 +68,18 @@ interface IntegrationRepository {
suspend fun getLastUsedPipelineSttSupport(): Boolean
suspend fun setLastUsedPipeline(pipelineId: String, supportsStt: Boolean)
/** @return List of border agent IDs added to this device from the server */
suspend fun getThreadBorderAgentIds(): List<String>
/** Set the list of border agent IDs added to this device from the server */
suspend fun setThreadBorderAgentIds(ids: List<String>)
/** @return List of border agent IDs added to this device from a server that no longer exists */
suspend fun getOrphanedThreadBorderAgentIds(): List<String>
/** Clear the list of orphaned border agent IDs, to use after removing them from storage */
suspend fun clearOrphanedThreadBorderAgentIds()
}
@AssistedFactory

View file

@ -56,6 +56,7 @@ class IntegrationRepositoryImpl @AssistedInject constructor(
private const val PREF_APP_VERSION = "app_version" // Note: _not_ server-specific
private const val PREF_PUSH_TOKEN = "push_token" // Note: _not_ server-specific
private const val PREF_ORPHANED_THREAD_BORDER_AGENT_IDS = "orphaned_thread_border_agent_ids" // Note: _not_ server-specific
private const val PREF_CHECK_SENSOR_REGISTRATION_NEXT = "sensor_reg_last"
private const val PREF_SESSION_TIMEOUT = "session_timeout"
@ -64,6 +65,7 @@ class IntegrationRepositoryImpl @AssistedInject constructor(
private const val PREF_SEC_WARNING_NEXT = "sec_warning_last"
private const val PREF_LAST_USED_PIPELINE_ID = "last_used_pipeline"
private const val PREF_LAST_USED_PIPELINE_STT = "last_used_pipeline_stt"
private const val PREF_THREAD_BORDER_AGENT_IDS = "thread_border_agent_ids"
private const val TAG = "IntegrationRepository"
private const val RATE_LIMIT_URL = BuildConfig.RATE_LIMIT_URL
@ -169,6 +171,15 @@ class IntegrationRepositoryImpl @AssistedInject constructor(
localStorage.remove("${serverId}_$PREF_SEC_WARNING_NEXT")
localStorage.remove("${serverId}_$PREF_LAST_USED_PIPELINE_ID")
localStorage.remove("${serverId}_$PREF_LAST_USED_PIPELINE_STT")
// Thread credentials are managed in the app module and can't be deleted now, so store them
val threadBorderAgentIds = getThreadBorderAgentIds()
if (threadBorderAgentIds.any()) {
val orphanedBorderAgentIds = localStorage.getStringSet(PREF_ORPHANED_THREAD_BORDER_AGENT_IDS).orEmpty()
localStorage.putStringSet(PREF_ORPHANED_THREAD_BORDER_AGENT_IDS, orphanedBorderAgentIds + threadBorderAgentIds.toSet())
}
localStorage.remove("${serverId}_$PREF_THREAD_BORDER_AGENT_IDS")
// app version and push token is device-specific
}
@ -565,6 +576,20 @@ class IntegrationRepositoryImpl @AssistedInject constructor(
localStorage.putBoolean("${serverId}_$PREF_LAST_USED_PIPELINE_STT", supportsStt)
}
override suspend fun getThreadBorderAgentIds(): List<String> =
localStorage.getStringSet("${serverId}_$PREF_THREAD_BORDER_AGENT_IDS").orEmpty().toList()
override suspend fun setThreadBorderAgentIds(ids: List<String>) {
localStorage.putStringSet("${serverId}_$PREF_THREAD_BORDER_AGENT_IDS", ids.toSet())
}
override suspend fun getOrphanedThreadBorderAgentIds(): List<String> =
localStorage.getStringSet(PREF_ORPHANED_THREAD_BORDER_AGENT_IDS).orEmpty().toList()
override suspend fun clearOrphanedThreadBorderAgentIds() {
localStorage.remove(PREF_ORPHANED_THREAD_BORDER_AGENT_IDS)
}
override suspend fun getEntities(): List<Entity<Any>>? {
val response = webSocketRepository.getStates()

View file

@ -9,5 +9,6 @@ data class ThreadDatasetResponse(
val networkName: String,
val panId: String,
val preferred: Boolean,
val preferredBorderAgentId: String?, // only on core >= 2023.9, may still be null
val source: String
)