Support for Thread "import credentials" from frontend (#4128)

* Support for Thread "import credentials" frontend action

 - Add support for the frontend action to import Thread credentials, or better said export Thread credentials from the device. This is essentially a very basic sync.

* Rename/reorder reused Matter code

 - Change names and order of reused Matter code for Thread importing to prevent potential confusion

* Update minimal implementation
This commit is contained in:
Joris Pelgröm 2024-01-12 15:20:57 +01:00 committed by GitHub
parent 8dd76734f8
commit a5efe6a9ab
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 193 additions and 48 deletions

View file

@ -97,6 +97,7 @@ class MatterCommissioningViewModel @Inject constructor(
val result = threadManager.syncPreferredDataset(
getApplication<Application>().applicationContext,
serverId,
false,
viewModelScope
)
when (result) {

View file

@ -7,10 +7,12 @@ 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
@ -49,11 +51,52 @@ class ThreadManagerImpl @Inject constructor(
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) }

View file

@ -1,9 +0,0 @@
package io.homeassistant.companion.android.matter
enum class MatterFrontendCommissioningStatus {
NOT_STARTED,
REQUESTED,
THREAD_EXPORT_TO_SERVER,
IN_PROGRESS,
ERROR
}

View file

@ -63,7 +63,7 @@ class DeveloperSettingsPresenterImpl @Inject constructor(
override fun runThreadDebug(context: Context, serverId: Int) {
mainScope.launch {
try {
when (val syncResult = threadManager.syncPreferredDataset(context, serverId, CoroutineScope(coroutineContext + SupervisorJob()))) {
when (val syncResult = threadManager.syncPreferredDataset(context, serverId, false, CoroutineScope(coroutineContext + SupervisorJob()))) {
is ThreadManager.SyncResult.ServerUnsupported ->
view.onThreadDebugResult(context.getString(commonR.string.thread_debug_result_unsupported_server), false)
is ThreadManager.SyncResult.OnlyOnServer -> {

View file

@ -11,6 +11,7 @@ interface ThreadManager {
sealed class SyncResult {
object AppUnsupported : SyncResult()
object ServerUnsupported : SyncResult()
object NotConnected : SyncResult()
class OnlyOnServer(val imported: Boolean) : SyncResult()
class OnlyOnDevice(val exportIntent: IntentSender?) : SyncResult()
class AllHaveCredentials(val matches: Boolean?, val fromApp: Boolean?, val updated: Boolean?, val exportIntent: IntentSender?) : SyncResult()
@ -28,15 +29,21 @@ interface ThreadManager {
suspend fun coreSupportsThread(serverId: Int): Boolean
/**
* Try to sync the preferred Thread dataset with the device and server. If one has a preferred
* dataset while the other one doesn't, it will sync. If both have preferred datasets, it will
* send updated data to the server if needed. If neither has a preferred dataset, skip syncing.
* Try to sync the preferred Thread dataset.
* @param exportOnly Controls the synchronization direction.
* - If set to `true`, only get the device preferred dataset and sync to the server if it
* wasn't added by the app.
* - If set to `false`, try to get the device and server in sync. This will clean up old/stale
* app datasets. If one has a preferred dataset while the other one doesn't, it will sync to
* the other. If both have preferred datasets, it will send updated data to the server if
* needed. If neither has a preferred dataset, skip syncing.
* @return [SyncResult] with details of the sync operation, which may include an [IntentSender]
* if permission is required to import the device dataset
*/
suspend fun syncPreferredDataset(
context: Context,
serverId: Int,
exportOnly: Boolean,
scope: CoroutineScope
): SyncResult

View file

@ -0,0 +1,14 @@
package io.homeassistant.companion.android.webview
enum class MatterThreadStep {
NOT_STARTED,
REQUESTED,
THREAD_EXPORT_TO_SERVER_MATTER,
THREAD_EXPORT_TO_SERVER_ONLY,
MATTER_IN_PROGRESS,
THREAD_SENT,
THREAD_NONE,
ERROR_MATTER,
ERROR_THREAD_LOCAL_NETWORK,
ERROR_THREAD_OTHER
}

View file

@ -87,7 +87,6 @@ import io.homeassistant.companion.android.database.authentication.Authentication
import io.homeassistant.companion.android.databinding.ActivityWebviewBinding
import io.homeassistant.companion.android.databinding.DialogAuthenticationBinding
import io.homeassistant.companion.android.launch.LaunchActivity
import io.homeassistant.companion.android.matter.MatterFrontendCommissioningStatus
import io.homeassistant.companion.android.nfc.WriteNfcTag
import io.homeassistant.companion.android.sensors.SensorReceiver
import io.homeassistant.companion.android.sensors.SensorWorker
@ -171,7 +170,7 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi
mFilePathCallback = null
}
private val commissionMatterDevice = registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result ->
presenter.onMatterCommissioningIntentResult(this, result)
presenter.onMatterThreadIntentResult(this, result)
}
@Inject
@ -631,18 +630,45 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
presenter.getMatterCommissioningStatusFlow().collect {
Log.d(TAG, "Matter commissioning status changed to $it")
presenter.getMatterThreadStepFlow().collect {
Log.d(TAG, "Matter/Thread step changed to $it")
when (it) {
MatterFrontendCommissioningStatus.THREAD_EXPORT_TO_SERVER,
MatterFrontendCommissioningStatus.IN_PROGRESS -> {
presenter.getMatterCommissioningIntent()?.let { intentSender ->
MatterThreadStep.THREAD_EXPORT_TO_SERVER_MATTER,
MatterThreadStep.THREAD_EXPORT_TO_SERVER_ONLY,
MatterThreadStep.MATTER_IN_PROGRESS -> {
presenter.getMatterThreadIntent()?.let { intentSender ->
commissionMatterDevice.launch(IntentSenderRequest.Builder(intentSender).build())
}
}
MatterFrontendCommissioningStatus.ERROR -> {
MatterThreadStep.THREAD_NONE -> {
alertDialog?.cancel()
AlertDialog.Builder(this@WebViewActivity)
.setMessage(commonR.string.thread_export_none)
.setPositiveButton(commonR.string.ok, null)
.show()
presenter.finishMatterThreadFlow()
}
MatterThreadStep.THREAD_SENT -> {
Toast.makeText(this@WebViewActivity, commonR.string.thread_export_success, Toast.LENGTH_SHORT).show()
alertDialog?.cancel()
presenter.finishMatterThreadFlow()
}
MatterThreadStep.ERROR_MATTER -> {
Toast.makeText(this@WebViewActivity, commonR.string.matter_commissioning_unavailable, Toast.LENGTH_SHORT).show()
presenter.confirmMatterCommissioningError()
presenter.finishMatterThreadFlow()
}
MatterThreadStep.ERROR_THREAD_LOCAL_NETWORK -> {
alertDialog?.cancel()
AlertDialog.Builder(this@WebViewActivity)
.setMessage(commonR.string.thread_export_not_connected)
.setPositiveButton(commonR.string.ok, null)
.show()
presenter.finishMatterThreadFlow()
}
MatterThreadStep.ERROR_THREAD_OTHER -> {
Toast.makeText(this@WebViewActivity, commonR.string.thread_export_unavailable, Toast.LENGTH_SHORT).show()
alertDialog?.cancel()
presenter.finishMatterThreadFlow()
}
else -> { } // Do nothing
}
@ -702,6 +728,7 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi
val pm: PackageManager = context.packageManager
val hasNfc = pm.hasSystemFeature(PackageManager.FEATURE_NFC)
val canCommissionMatter = presenter.appCanCommissionMatterDevice()
val canExportThread = presenter.appCanExportThreadCredentials()
webView.externalBus(
id = JSONObject(message).get("id"),
type = "result",
@ -712,6 +739,7 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi
"canWriteTag" to hasNfc,
"hasExoPlayer" to true,
"canCommissionMatter" to canCommissionMatter,
"canImportThreadCredentials" to canExportThread,
"hasAssist" to true
)
)
@ -755,6 +783,14 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi
)
)
"matter/commission" -> presenter.startCommissioningMatterDevice(this@WebViewActivity)
"thread/import_credentials" -> {
presenter.exportThreadCredentials(this@WebViewActivity)
alertDialog = AlertDialog.Builder(this@WebViewActivity)
.setMessage(commonR.string.thread_debug_active)
.create()
alertDialog?.show()
}
"exoplayer/play_hls" -> exoPlayHls(json)
"exoplayer/stop" -> exoStopHls()
"exoplayer/resize" -> exoResizeHls(json)

View file

@ -3,7 +3,6 @@ package io.homeassistant.companion.android.webview
import android.content.Context
import android.content.IntentSender
import androidx.activity.result.ActivityResult
import io.homeassistant.companion.android.matter.MatterFrontendCommissioningStatus
import kotlinx.coroutines.flow.Flow
interface WebViewPresenter {
@ -54,8 +53,12 @@ interface WebViewPresenter {
fun appCanCommissionMatterDevice(): Boolean
fun startCommissioningMatterDevice(context: Context)
fun getMatterCommissioningStatusFlow(): Flow<MatterFrontendCommissioningStatus>
fun getMatterCommissioningIntent(): IntentSender?
fun onMatterCommissioningIntentResult(context: Context, result: ActivityResult)
fun confirmMatterCommissioningError()
/** @return `true` if the app can send this device's preferred Thread credential to the server */
fun appCanExportThreadCredentials(): Boolean
fun exportThreadCredentials(context: Context)
fun getMatterThreadStepFlow(): Flow<MatterThreadStep>
fun getMatterThreadIntent(): IntentSender?
fun onMatterThreadIntentResult(context: Context, result: ActivityResult)
fun finishMatterThreadFlow()
}

View file

@ -12,7 +12,6 @@ import io.homeassistant.companion.android.common.data.authentication.SessionStat
import io.homeassistant.companion.android.common.data.prefs.PrefsRepository
import io.homeassistant.companion.android.common.data.servers.ServerManager
import io.homeassistant.companion.android.common.util.DisabledLocationHandler
import io.homeassistant.companion.android.matter.MatterFrontendCommissioningStatus
import io.homeassistant.companion.android.matter.MatterManager
import io.homeassistant.companion.android.thread.ThreadManager
import io.homeassistant.companion.android.util.UrlUtil
@ -58,9 +57,9 @@ class WebViewPresenterImpl @Inject constructor(
private var url: URL? = null
private var urlForServer: Int? = null
private val _matterCommissioningStatus = MutableStateFlow(MatterFrontendCommissioningStatus.NOT_STARTED)
private val _matterThreadStep = MutableStateFlow(MatterThreadStep.NOT_STARTED)
private var matterCommissioningIntentSender: IntentSender? = null
private var matterThreadIntentSender: IntentSender? = null
init {
updateActiveServer()
@ -343,12 +342,12 @@ class WebViewPresenterImpl @Inject constructor(
override fun appCanCommissionMatterDevice(): Boolean = matterUseCase.appSupportsCommissioning()
override fun startCommissioningMatterDevice(context: Context) {
if (_matterCommissioningStatus.value != MatterFrontendCommissioningStatus.REQUESTED) {
_matterCommissioningStatus.tryEmit(MatterFrontendCommissioningStatus.REQUESTED)
if (_matterThreadStep.value != MatterThreadStep.REQUESTED) {
_matterThreadStep.tryEmit(MatterThreadStep.REQUESTED)
mainScope.launch {
val deviceThreadIntent = try {
when (val result = threadUseCase.syncPreferredDataset(context, serverId, CoroutineScope(coroutineContext + SupervisorJob()))) {
when (val result = threadUseCase.syncPreferredDataset(context, serverId, false, CoroutineScope(coroutineContext + SupervisorJob()))) {
is ThreadManager.SyncResult.OnlyOnDevice -> result.exportIntent
is ThreadManager.SyncResult.AllHaveCredentials -> result.exportIntent
else -> null
@ -358,8 +357,8 @@ class WebViewPresenterImpl @Inject constructor(
null
}
if (deviceThreadIntent != null) {
matterCommissioningIntentSender = deviceThreadIntent
_matterCommissioningStatus.tryEmit(MatterFrontendCommissioningStatus.THREAD_EXPORT_TO_SERVER)
matterThreadIntentSender = deviceThreadIntent
_matterThreadStep.tryEmit(MatterThreadStep.THREAD_EXPORT_TO_SERVER_MATTER)
} else {
startMatterCommissioningFlow(context)
}
@ -372,33 +371,79 @@ class WebViewPresenterImpl @Inject constructor(
context,
{ intentSender ->
Log.d(TAG, "Matter commissioning is ready")
matterCommissioningIntentSender = intentSender
_matterCommissioningStatus.tryEmit(MatterFrontendCommissioningStatus.IN_PROGRESS)
matterThreadIntentSender = intentSender
_matterThreadStep.tryEmit(MatterThreadStep.MATTER_IN_PROGRESS)
},
{ e ->
Log.e(TAG, "Matter commissioning couldn't be prepared", e)
_matterCommissioningStatus.tryEmit(MatterFrontendCommissioningStatus.ERROR)
_matterThreadStep.tryEmit(MatterThreadStep.ERROR_MATTER)
}
)
}
override fun getMatterCommissioningStatusFlow(): Flow<MatterFrontendCommissioningStatus> =
_matterCommissioningStatus.asStateFlow()
override fun appCanExportThreadCredentials(): Boolean = threadUseCase.appSupportsThread()
override fun getMatterCommissioningIntent(): IntentSender? {
val intent = matterCommissioningIntentSender
matterCommissioningIntentSender = null
override fun exportThreadCredentials(context: Context) {
if (_matterThreadStep.value != MatterThreadStep.REQUESTED) {
_matterThreadStep.tryEmit(MatterThreadStep.REQUESTED)
mainScope.launch {
try {
val result = threadUseCase.syncPreferredDataset(context, serverId, true, CoroutineScope(coroutineContext + SupervisorJob()))
Log.d(TAG, "Export preferred Thread dataset returned $result")
when (result) {
is ThreadManager.SyncResult.OnlyOnDevice -> {
matterThreadIntentSender = result.exportIntent
_matterThreadStep.tryEmit(MatterThreadStep.THREAD_EXPORT_TO_SERVER_ONLY)
}
is ThreadManager.SyncResult.NoneHaveCredentials,
is ThreadManager.SyncResult.OnlyOnServer -> {
_matterThreadStep.tryEmit(MatterThreadStep.THREAD_NONE)
}
is ThreadManager.SyncResult.NotConnected -> {
_matterThreadStep.tryEmit(MatterThreadStep.ERROR_THREAD_LOCAL_NETWORK)
}
else -> {
_matterThreadStep.tryEmit(MatterThreadStep.ERROR_THREAD_OTHER)
}
}
} catch (e: Exception) {
Log.w(TAG, "Unable to export preferred Thread dataset", e)
_matterThreadStep.tryEmit(MatterThreadStep.ERROR_THREAD_OTHER)
}
}
} // else already waiting for a result, don't send another request
}
override fun getMatterThreadStepFlow(): Flow<MatterThreadStep> =
_matterThreadStep.asStateFlow()
override fun getMatterThreadIntent(): IntentSender? {
val intent = matterThreadIntentSender
matterThreadIntentSender = null
return intent
}
override fun onMatterCommissioningIntentResult(context: Context, result: ActivityResult) {
when (_matterCommissioningStatus.value) {
MatterFrontendCommissioningStatus.THREAD_EXPORT_TO_SERVER -> {
override fun onMatterThreadIntentResult(context: Context, result: ActivityResult) {
when (_matterThreadStep.value) {
MatterThreadStep.THREAD_EXPORT_TO_SERVER_MATTER -> {
mainScope.launch {
threadUseCase.sendThreadDatasetExportResult(result, serverId)
startMatterCommissioningFlow(context)
}
}
MatterThreadStep.THREAD_EXPORT_TO_SERVER_ONLY -> {
mainScope.launch {
val sent = threadUseCase.sendThreadDatasetExportResult(result, serverId)
Log.d(TAG, "Thread ${if (!sent.isNullOrBlank()) "sent credential for $sent" else "did not send credential"}")
if (sent.isNullOrBlank()) {
_matterThreadStep.tryEmit(MatterThreadStep.THREAD_NONE)
} else {
_matterThreadStep.tryEmit(MatterThreadStep.THREAD_SENT)
}
}
}
else -> {
// Any errors will have been shown in the UI provided by Play Services
if (result.resultCode == Activity.RESULT_OK) {
@ -410,7 +455,7 @@ class WebViewPresenterImpl @Inject constructor(
}
}
override fun confirmMatterCommissioningError() {
_matterCommissioningStatus.tryEmit(MatterFrontendCommissioningStatus.NOT_STARTED)
override fun finishMatterThreadFlow() {
_matterThreadStep.tryEmit(MatterThreadStep.NOT_STARTED)
}
}

View file

@ -19,6 +19,7 @@ class ThreadManagerImpl @Inject constructor() : ThreadManager {
override suspend fun syncPreferredDataset(
context: Context,
serverId: Int,
exportOnly: Boolean,
scope: CoroutineScope
): ThreadManager.SyncResult = ThreadManager.SyncResult.AppUnsupported

View file

@ -1145,6 +1145,10 @@
<string name="thread_debug_result_unsupported_server">The Home Assistant server does not support Thread</string>
<string name="thread_debug_result_updated">Updated network from Home Assistant on this device</string>
<string name="thread_debug_summary">Manually update device and server Thread credentials and verify results</string>
<string name="thread_export_success">Imported credential</string>
<string name="thread_export_none">You don\'t have any credentials to import.</string>
<string name="thread_export_not_connected">You are not connected to a local network. Connect to Wi-Fi or ethernet to import Thread credentials.</string>
<string name="thread_export_unavailable">Thread is currently unavailable</string>
<string name="tile_vibrate">Vibrate when clicked</string>
<string name="tile_auth_required">Requires unlocked device</string>
<string name="no_results">No results yet</string>