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 6c2c5e70ce0..09d91c65322 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 @@ -15,6 +15,7 @@ import com.google.android.gms.threadnetwork.ThreadNetworkStatusCodes import io.homeassistant.companion.android.common.data.HomeAssistantVersion import io.homeassistant.companion.android.common.data.servers.ServerManager import io.homeassistant.companion.android.common.data.websocket.impl.entities.ThreadDatasetResponse +import io.homeassistant.companion.android.common.data.websocket.impl.entities.ThreadDatasetTlvResponse import io.homeassistant.companion.android.common.util.isAutomotive import javax.inject.Inject import kotlin.coroutines.resume @@ -114,6 +115,7 @@ class ThreadManagerImpl @Inject constructor( serverManager.integrationRepository(serverId).setThreadBorderAgentIds(listOf(it)) } // else added using placeholder, will be removed when core is updated Timber.d("Thread import to device completed") + serverManager.integrationRepository(serverId).setThreadCredentialsSynced(true) ThreadManager.SyncResult.OnlyOnServer(imported = true) } catch (e: Exception) { Timber.e(e, "Thread import to device failed") @@ -160,13 +162,18 @@ class ThreadManagerImpl @Inject constructor( serverId, ) serverManager.servers().forEach { - serverManager.integrationRepository(it.id).setThreadBorderAgentIds( - if (it.id == serverId && coreThreadDataset.preferredBorderAgentId != null) { - listOf(coreThreadDataset.preferredBorderAgentId!!) - } else { - emptyList() - }, - ) + val syncedThis = + it.id == serverId && coreThreadDataset.preferredBorderAgentId != null + serverManager.integrationRepository(it.id).apply { + setThreadBorderAgentIds( + if (syncedThis) { + listOf(coreThreadDataset.preferredBorderAgentId!!) + } else { + emptyList() + }, + ) + setThreadCredentialsSynced(syncedThis) + } } Timber.d("Thread update device completed: deleted ${localIds.size} datasets, updated 1") true @@ -179,7 +186,10 @@ class ThreadManagerImpl @Inject constructor( } } serverManager.servers().forEach { - serverManager.integrationRepository(it.id).setThreadBorderAgentIds(emptyList()) + serverManager.integrationRepository(it.id).apply { + setThreadBorderAgentIds(emptyList()) + setThreadCredentialsSynced(false) + } } Timber.d("Thread update device completed: deleted ${localIds.size} datasets") false @@ -213,6 +223,9 @@ class ThreadManagerImpl @Inject constructor( } } + override suspend fun hasSyncedPreferredDataset(serverId: Int): Boolean = + serverManager.integrationRepository(serverId).getThreadCredentialsSynced() + override suspend fun getPreferredDatasetFromServer(serverId: Int): ThreadDatasetResponse? = getDatasetsFromServer(serverId)?.firstOrNull { it.preferred } @@ -252,6 +265,12 @@ class ThreadManagerImpl @Inject constructor( } } + override suspend fun isPreferredDatasetByDevice(context: Context, tlv: String): Boolean { + val tlv = ThreadDatasetTlvResponse(tlv).tlvAsByteArray + val threadNetworkCredentials = ThreadNetworkCredentials.fromActiveOperationalDataset(tlv) + return isPreferredCredentials(context, threadNetworkCredentials) + } + private suspend fun isPreferredDatasetByDevice(context: Context, datasetId: String, serverId: Int): Boolean { val tlv = serverManager.webSocketRepository(serverId).getThreadDatasetTlv(datasetId)?.tlvAsByteArray return if (tlv != null) { @@ -292,7 +311,7 @@ class ThreadManagerImpl @Inject constructor( ThreadNetwork.getNetworkClient(context) .isPreferredCredentials(credentials) .addOnSuccessListener { cont.resume(it == IsPreferredCredentialsResult.PREFERRED_CREDENTIALS_MATCHED) } - .addOnFailureListener { cont.resumeWithException(it) } + .addOnFailureListener { cont.resumeWithException(ThreadManager.ThreadNetworkException(it.message)) } } override suspend fun sendThreadDatasetExportResult(result: ActivityResult, serverId: Int): String? { @@ -302,7 +321,10 @@ class ThreadManagerImpl @Inject constructor( val added = serverManager.webSocketRepository( serverId, ).addThreadDataset(threadNetworkCredentials.activeOperationalDataset) - if (added) return threadNetworkCredentials.networkName + if (added) { + serverManager.integrationRepository(serverId).setThreadCredentialsSynced(true) + return threadNetworkCredentials.networkName + } } catch (e: Exception) { Timber.e(e, "Error while executing server new Thread credentials request") } 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 3af998d53ff..3c8283301c4 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 @@ -23,6 +23,9 @@ interface ThreadManager { object NoneHaveCredentials : SyncResult() } + /** Exception related to Thread Network operations */ + class ThreadNetworkException(message: String?) : Exception() + /** * Indicates if the app on this device supports Thread credential management. */ @@ -52,6 +55,13 @@ interface ThreadManager { scope: CoroutineScope, ): SyncResult + /** + * Check if there was ever a Thread dataset synchronization to or from the specified server. + * @see syncPreferredDataset for syncing details. + * @return `true` if the server has ever synced, `false` if the server has never synced. + */ + suspend fun hasSyncedPreferredDataset(serverId: Int): Boolean + /** * Get the preferred Thread dataset from the server. */ @@ -79,6 +89,14 @@ interface ThreadManager { */ suspend fun getPreferredDatasetFromDevice(context: Context): IntentSender? + /** + * Compares the supplied dataset to what is preferred by the device. + * @return `true` if preferred by the device, `false` otherwise + * @throws IllegalArgumentException if the tlv is malformed + * @throws ThreadNetworkException if it is not possible to get the preferred dataset + */ + suspend fun isPreferredDatasetByDevice(context: Context, tlv: String): Boolean + /** * Process the result from [syncPreferredDataset] or [getPreferredDatasetFromDevice]'s intent * and add the Thread dataset, if any, to the server. diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt index 099d88fa058..4cc9e9774ca 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt @@ -938,7 +938,7 @@ class WebViewActivity : ), ) - "matter/commission" -> presenter.startCommissioningMatterDevice(this@WebViewActivity) + "matter/commission" -> startMatterCommissioning(json) "thread/import_credentials" -> { presenter.exportThreadCredentials(this@WebViewActivity) @@ -1009,6 +1009,13 @@ class WebViewActivity : } } + private fun startMatterCommissioning(json: JsonObject) { + val payload = json["payload"]?.jsonObjectOrNull() + val tlv = payload?.getStringOrNull("active_operational_dataset") + val borderAgentId = payload?.getStringOrNull("border_agent_id") + presenter.startCommissioningMatterDevice(this@WebViewActivity, tlv, borderAgentId) + } + private fun addEntityTo(json: JsonObject) { val payload = json["payload"]?.jsonObjectOrNull() val entityId = payload?.getStringOrNull("entity_id") diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenter.kt b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenter.kt index 783b2a8177d..3d20ede86de 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenter.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenter.kt @@ -73,7 +73,7 @@ interface WebViewPresenter { suspend fun parseWebViewColor(webViewColor: String): Int fun appCanCommissionMatterDevice(): Boolean - fun startCommissioningMatterDevice(context: Context) + fun startCommissioningMatterDevice(context: Context, tlv: String?, borderAgentId: String?) /** @return `true` if the app can send this device's preferred Thread credential to the server */ fun appCanExportThreadCredentials(): Boolean diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt index 92e221a2766..d8e64b7684c 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt @@ -509,18 +509,83 @@ class WebViewPresenterImpl @Inject constructor( override fun appCanCommissionMatterDevice(): Boolean = matterUseCase.appSupportsCommissioning() - override fun startCommissioningMatterDevice(context: Context) { + override fun startCommissioningMatterDevice(context: Context, tlv: String?, borderAgentId: String?) { if (mutableMatterThreadStep.value != MatterThreadStep.REQUESTED) { mutableMatterThreadStep.tryEmit(MatterThreadStep.REQUESTED) - // The app used to sync Thread credentials here until commit 26a472a, but it was - // (temporarily?) removed due to slowing down the Matter commissioning flow for the user - // and limited usefulness of the result (because of API limitations) + mainScope.launch { + if (tlv != null && borderAgentId != null) { + Timber.d("Matter commissioning payload has preferred Thread dataset, comparing") + compareThreadDatasetForCommissioning(context, tlv) + } else { + Timber.d("Matter commissioning payload doesn't have Thread, starting commissioning") + startMatterCommissioningFlow(context) + } + } + } // else already waiting for a result, don't send another request + } + /** + * Compare Thread dataset supplied for Matter commissioning, and take action depending on the device state: + * - Device prefers dataset: start (external) Matter commissioning flow + * - Device doesn't prefer dataset: + * - Has never synced Thread for the server: do a full Thread dataset sync (this will usually result in the + * dataset becoming preferred, if the device has no other Thread dataset) + * - Has previously synced Thread for the server: start (external) Matter commissioning flow (the app is + * expected to be unable to change the preferred state by syncing) + */ + private suspend fun compareThreadDatasetForCommissioning(context: Context, tlv: String) { + val isPreferred = threadUseCase.isPreferredDatasetByDevice(context, tlv) + + if (isPreferred) { + Timber.d("Device prefers core preferred dataset, starting commissioning") startMatterCommissioningFlow(context) - } // else already waiting for a result, don't send another request + } else if (!threadUseCase.hasSyncedPreferredDataset(serverId)) { + // We only sync if the server has never synced, as a full sync can be slow + Timber.d("Device doesn't prefer core preferred dataset, starting first sync") + val syncExportIntent = syncThreadDataset(context) + + if (syncExportIntent != null) { + matterThreadIntentSender = syncExportIntent + mutableMatterThreadStep.tryEmit(MatterThreadStep.THREAD_EXPORT_TO_SERVER_MATTER) + } else { + startMatterCommissioningFlow(context) + } + } else { + Timber.d( + "Device doesn't prefer core preferred dataset, has previously synced, starting commissioning", + ) + startMatterCommissioningFlow(context) + } } + /** + * Try to sync the preferred Thread dataset between the server and the device. + * @return [IntentSender] if syncing requires starting an intent to complete (export to server), otherwise `null` + */ + private suspend fun syncThreadDataset(context: Context): IntentSender? { + return try { + val result = threadUseCase.syncPreferredDataset( + context = context, + serverId = serverId, + exportOnly = false, + scope = CoroutineScope(mainScope.coroutineContext + SupervisorJob()), + ) + when (result) { + is ThreadManager.SyncResult.OnlyOnDevice -> result.exportIntent + is ThreadManager.SyncResult.AllHaveCredentials -> result.exportIntent + else -> null + } + } catch (e: Exception) { + Timber.w(e, "Unable to sync preferred Thread dataset, continuing") + null + } + } + + /** + * Start the (external) Matter commissioning flow. Depending on the result, this will emit a new step with the + * intent to open the activity, or a new step for an error. + * */ private fun startMatterCommissioningFlow(context: Context) { matterUseCase.startNewCommissioningFlow( context, 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 1676bf3479f..4d6e71355e7 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 @@ -23,6 +23,8 @@ class ThreadManagerImpl @Inject constructor() : ThreadManager { scope: CoroutineScope, ): ThreadManager.SyncResult = ThreadManager.SyncResult.AppUnsupported + override suspend fun hasSyncedPreferredDataset(serverId: Int): Boolean = false + override suspend fun getPreferredDatasetFromServer(serverId: Int): ThreadDatasetResponse? = null override suspend fun importDatasetFromServer( @@ -33,7 +35,11 @@ class ThreadManagerImpl @Inject constructor() : ThreadManager { ) { } override suspend fun getPreferredDatasetFromDevice(context: Context): IntentSender? { - throw IllegalStateException("Thread is not supported with the minimal flavor") + throw ThreadManager.ThreadNetworkException("Thread is not supported with the minimal flavor") + } + + override suspend fun isPreferredDatasetByDevice(context: Context, tlv: String): Boolean { + throw ThreadManager.ThreadNetworkException("Thread is not supported with the minimal flavor") } override suspend fun sendThreadDatasetExportResult(result: ActivityResult, serverId: Int): String? = null diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/IntegrationRepository.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/IntegrationRepository.kt index 326615c35f2..1e766fef2e9 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/IntegrationRepository.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/IntegrationRepository.kt @@ -69,6 +69,12 @@ interface IntegrationRepository { suspend fun setLastUsedPipeline(pipelineId: String, supportsStt: Boolean) + /** @return Whether Thread credentials have ever been synced to or from this server */ + suspend fun getThreadCredentialsSynced(): Boolean + + /** Set whether Thread credentials have ever been synced to or from this server */ + suspend fun setThreadCredentialsSynced(synced: Boolean) + /** @return List of border agent IDs added to this device from the server */ suspend fun getThreadBorderAgentIds(): List diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/impl/IntegrationRepositoryImpl.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/impl/IntegrationRepositoryImpl.kt index afadce47467..85b32d38799 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/impl/IntegrationRepositoryImpl.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/integration/impl/IntegrationRepositoryImpl.kt @@ -99,6 +99,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_CREDENTIALS_SYNCED = "thread_credentials_synced" private const val PREF_THREAD_BORDER_AGENT_IDS = "thread_border_agent_ids" @VisibleForTesting internal const val PREF_ASK_NOTIFICATION_PERMISSION = "ask_notification_permission" @@ -441,6 +442,13 @@ class IntegrationRepositoryImpl @AssistedInject constructor( localStorage.putBoolean("${serverId}_$PREF_LAST_USED_PIPELINE_STT", supportsStt) } + override suspend fun getThreadCredentialsSynced(): Boolean = + localStorage.getBoolean("${serverId}_$PREF_THREAD_CREDENTIALS_SYNCED") + + override suspend fun setThreadCredentialsSynced(synced: Boolean) { + localStorage.putBoolean("${serverId}_$PREF_THREAD_CREDENTIALS_SYNCED", synced) + } + override suspend fun getThreadBorderAgentIds(): List = localStorage.getStringSet("${serverId}_$PREF_THREAD_BORDER_AGENT_IDS").orEmpty().toList()