ha-android/app/src/full/java/io/homeassistant/companion/android/thread/ThreadManagerImpl.kt

321 lines
16 KiB
Kotlin

package io.homeassistant.companion.android.thread
import android.app.Activity
import android.content.Context
import android.content.IntentSender
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.activity.result.ActivityResult
import com.google.android.gms.common.api.ApiException
import com.google.android.gms.threadnetwork.IsPreferredCredentialsResult
import com.google.android.gms.threadnetwork.ThreadBorderAgent
import com.google.android.gms.threadnetwork.ThreadNetwork
import com.google.android.gms.threadnetwork.ThreadNetworkCredentials
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 javax.inject.Inject
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
class ThreadManagerImpl @Inject constructor(
private val serverManager: ServerManager,
private val packageManager: PackageManager
) : ThreadManager {
companion object {
private const val TAG = "ThreadManagerImpl"
// ID is a placeholder used in previous app versions / for older Home Assistant versions
private const val BORDER_AGENT_ID = "0000000000000001"
}
override fun appSupportsThread(): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 && !packageManager.hasSystemFeature(PackageManager.FEATURE_AUTOMOTIVE)
override suspend fun coreSupportsThread(serverId: Int): Boolean {
if (!serverManager.isRegistered() || serverManager.getServer(serverId)?.user?.isAdmin != true) return false
val config = serverManager.webSocketRepository(serverId).getConfig()
return config != null &&
config.components.contains("thread") &&
HomeAssistantVersion.fromString(config.version)?.isAtLeast(2023, 3, 0) == true
}
private suspend fun getDatasetsFromServer(serverId: Int): List<ThreadDatasetResponse>? =
serverManager.webSocketRepository(serverId).getThreadDatasets()
override suspend fun syncPreferredDataset(
context: Context,
serverId: Int,
exportOnly: Boolean,
scope: CoroutineScope
): ThreadManager.SyncResult {
if (!appSupportsThread()) return ThreadManager.SyncResult.AppUnsupported
if (!coreSupportsThread(serverId)) return ThreadManager.SyncResult.ServerUnsupported
return if (exportOnly) { // Limited sync, only export non-app dataset
exportSyncPreferredDataset(context)
} else { // Full sync
fullSyncPreferredDataset(context, serverId, scope)
}
}
private suspend fun exportSyncPreferredDataset(
context: Context
): ThreadManager.SyncResult {
val getDeviceDataset = try {
getPreferredDatasetFromDevice(context)
} catch (e: ApiException) {
Log.e(TAG, "Thread: export cannot be started", e)
if (e.statusCode == ThreadNetworkStatusCodes.LOCAL_NETWORK_NOT_CONNECTED) {
return ThreadManager.SyncResult.NotConnected
} else {
throw e
}
}
return if (getDeviceDataset == null) {
ThreadManager.SyncResult.NoneHaveCredentials
} else {
val appIsDevicePreferred = appAddedIsPreferredCredentials(context)
Log.d(TAG, "Thread: device ${if (appIsDevicePreferred) "prefers" else "doesn't prefer" } dataset from app")
return if (appIsDevicePreferred) {
ThreadManager.SyncResult.OnlyOnServer(imported = false)
} else {
ThreadManager.SyncResult.OnlyOnDevice(exportIntent = getDeviceDataset)
}
}
}
private suspend fun fullSyncPreferredDataset(
context: Context,
serverId: Int,
scope: CoroutineScope
): ThreadManager.SyncResult {
deleteOrphanedThreadCredentials(context, serverId)
val getDeviceDataset = scope.async { getPreferredDatasetFromDevice(context) }
val getCoreDatasets = scope.async { getDatasetsFromServer(serverId) }
val deviceThreadIntent = getDeviceDataset.await()
val coreThreadDatasets = getCoreDatasets.await()
val coreThreadDataset = coreThreadDatasets?.firstOrNull { it.preferred }
return if (deviceThreadIntent == null && coreThreadDataset != null) {
try {
importDatasetFromServer(context, coreThreadDataset.datasetId, coreThreadDataset.preferredBorderAgentId, serverId)
coreThreadDataset.preferredBorderAgentId?.let {
serverManager.integrationRepository(serverId).setThreadBorderAgentIds(listOf(it))
} // else added using placeholder, will be removed when core is updated
Log.d(TAG, "Thread import to device completed")
ThreadManager.SyncResult.OnlyOnServer(imported = true)
} catch (e: Exception) {
Log.e(TAG, "Thread import to device failed", e)
ThreadManager.SyncResult.OnlyOnServer(imported = false)
}
} else if (deviceThreadIntent != null && coreThreadDataset == null) {
Log.d(TAG, "Thread export is ready")
ThreadManager.SyncResult.OnlyOnDevice(exportIntent = deviceThreadIntent)
} else if (deviceThreadIntent != null && coreThreadDataset != null) {
try {
val coreIsDevicePreferred = isPreferredDatasetByDevice(context, coreThreadDataset.datasetId, serverId)
Log.d(TAG, "Thread: device ${if (coreIsDevicePreferred) "prefers" else "doesn't prefer" } core preferred dataset")
val appIsDevicePreferred = coreIsDevicePreferred || appAddedIsPreferredCredentials(context)
Log.d(TAG, "Thread: device ${if (appIsDevicePreferred) "prefers" else "doesn't prefer" } dataset from app")
var exportFromDevice = false
var updated: Boolean? = null
if (!coreIsDevicePreferred) {
if (appIsDevicePreferred) {
// 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()
}
updated = if (coreThreadDataset.source != "Google") { // Credential from HA, update
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 && coreThreadDataset.preferredBorderAgentId != null) {
listOf(coreThreadDataset.preferredBorderAgentId!!)
} else {
emptyList()
}
)
}
Log.d(TAG, "Thread update device completed: deleted ${localIds.size} datasets, updated 1")
true
} 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())
}
Log.d(TAG, "Thread update device completed: deleted ${localIds.size} datasets")
false
}
} catch (e: Exception) {
Log.e(TAG, "Thread update device failed", e)
}
} else {
exportFromDevice = true
}
}
// Import the dataset to core if different from device
ThreadManager.SyncResult.AllHaveCredentials(
matches = coreIsDevicePreferred,
fromApp = appIsDevicePreferred,
updated = updated,
exportIntent = if (exportFromDevice) deviceThreadIntent else null
)
} catch (e: Exception) {
Log.e(TAG, "Thread device/core preferred comparison failed", e)
ThreadManager.SyncResult.AllHaveCredentials(matches = null, fromApp = null, updated = null, exportIntent = null)
}
} else {
ThreadManager.SyncResult.NoneHaveCredentials
}
}
override suspend fun getPreferredDatasetFromServer(serverId: Int): ThreadDatasetResponse? =
getDatasetsFromServer(serverId)?.firstOrNull { it.preferred }
@OptIn(ExperimentalStdlibApi::class)
override suspend fun importDatasetFromServer(
context: Context,
datasetId: String,
preferredBorderAgentId: String?,
serverId: Int
) {
val tlv = serverManager.webSocketRepository(serverId).getThreadDatasetTlv(datasetId)?.tlvAsByteArray
if (tlv != null) {
val borderAgentId = preferredBorderAgentId ?: run {
Log.w(TAG, "Adding dataset with placeholder border agent ID")
BORDER_AGENT_ID
}
val idAsBytes = borderAgentId.let { if (it.length == 16) it.toByteArray() else it.hexToByteArray() }
val threadBorderAgent = ThreadBorderAgent.newBuilder(idAsBytes).build()
val threadNetworkCredentials = ThreadNetworkCredentials.fromActiveOperationalDataset(tlv)
suspendCoroutine { cont ->
ThreadNetwork.getClient(context).addCredentials(threadBorderAgent, threadNetworkCredentials)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
}
}
override suspend fun getPreferredDatasetFromDevice(context: Context): IntentSender? = suspendCoroutine { cont ->
if (appSupportsThread()) {
ThreadNetwork.getClient(context)
.preferredCredentials
.addOnSuccessListener { cont.resume(it.intentSender) }
.addOnFailureListener { cont.resumeWithException(it) }
} else {
cont.resumeWithException(IllegalStateException("Thread is not supported on SDK <27"))
}
}
private suspend fun isPreferredDatasetByDevice(context: Context, datasetId: String, serverId: Int): Boolean {
val tlv = serverManager.webSocketRepository(serverId).getThreadDatasetTlv(datasetId)?.tlvAsByteArray
return if (tlv != null) {
val threadNetworkCredentials = ThreadNetworkCredentials.fromActiveOperationalDataset(tlv)
isPreferredCredentials(context, threadNetworkCredentials)
} else {
false
}
}
private suspend fun appAddedIsPreferredCredentials(context: Context): Boolean {
val appCredentials = suspendCoroutine { cont ->
ThreadNetwork.getClient(context)
.allCredentials
.addOnSuccessListener { cont.resume(it) }
.addOnFailureListener { cont.resume(null) }
}
return try {
appCredentials?.any {
val isPreferred = isPreferredCredentials(context, it)
if (isPreferred) {
Log.d(TAG, "Thread device prefers app added dataset: ${it.networkName} (PAN ${it.panId}, EXTPAN ${String(it.extendedPanId)})")
}
isPreferred
} ?: false
} catch (e: Exception) {
Log.e(TAG, "Thread app added credentials preferred check failed", e)
false
}
}
private suspend fun isPreferredCredentials(context: Context, credentials: ThreadNetworkCredentials): Boolean = suspendCoroutine { cont ->
ThreadNetwork.getClient(context)
.isPreferredCredentials(credentials)
.addOnSuccessListener { cont.resume(it == IsPreferredCredentialsResult.PREFERRED_CREDENTIALS_MATCHED) }
.addOnFailureListener { cont.resumeWithException(it) }
}
override suspend fun sendThreadDatasetExportResult(result: ActivityResult, serverId: Int): String? {
if (result.resultCode == Activity.RESULT_OK && coreSupportsThread(serverId)) {
val threadNetworkCredentials = ThreadNetworkCredentials.fromIntentSenderResultData(result.data!!)
try {
val added = serverManager.webSocketRepository(serverId).addThreadDataset(threadNetworkCredentials.activeOperationalDataset)
if (added) return threadNetworkCredentials.networkName
} catch (e: Exception) {
Log.e(TAG, "Error while executing server new Thread credentials request", e)
}
}
return null
}
private suspend fun deleteOrphanedThreadCredentials(context: Context, serverId: Int) {
if (serverManager.defaultServers.all { it.version?.isAtLeast(2023, 9) == true }) {
try {
deleteThreadCredential(context, BORDER_AGENT_ID)
} catch (e: Exception) {
// Expected, it may not exist
}
}
val orphanedCredentials = serverManager.integrationRepository(serverId).getOrphanedThreadBorderAgentIds()
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()
}
@OptIn(ExperimentalStdlibApi::class)
private suspend fun deleteThreadCredential(context: Context, borderAgentId: String) = suspendCoroutine { cont ->
val idAsBytes = borderAgentId.let { if (it.length == 16) it.toByteArray() else it.hexToByteArray() }
val threadBorderAgent = ThreadBorderAgent.newBuilder(idAsBytes).build()
ThreadNetwork.getClient(context)
.removeCredentials(threadBorderAgent)
.addOnSuccessListener { cont.resume(true) }
.addOnFailureListener { cont.resumeWithException(it) }
}
}