Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 }

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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? {
Expand All @@ -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")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -938,7 +938,7 @@ class WebViewActivity :
),
)

"matter/commission" -> presenter.startCommissioningMatterDevice(this@WebViewActivity)
"matter/commission" -> startMatterCommissioning(json)
"thread/import_credentials" -> {
presenter.exportThreadCredentials(this@WebViewActivity)

Expand Down Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could you split a bit this function? It has too many layer and I think having smaller functions with proper names would make it easier to follow what's going on.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I've split it into a couple of smaller but still logically grouped functions, with a description for each function. Hopefully this is a good middle ground between extremely tiny and the original.

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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<String> =
localStorage.getStringSet("${serverId}_$PREF_THREAD_BORDER_AGENT_IDS").orEmpty().toList()

Expand Down
Loading