From 25dd8692729bbe21bf9ca0b2ab5b65b2b8f53c8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Tue, 21 Feb 2023 19:27:29 +0100 Subject: [PATCH] Sync Thread datasets when starting Matter commissioning (#3349) * Sync Thread datasets when starting Matter commissioning - When starting Matter commissioning from the frontend, check if there are Thread datasets on the device or in core but not the other, and if so try to sync them * Also sync Thread datasets in shared device flow - Datasets may not be used by the system but it is useful for future devices - Make methods in ThreadManager a bit more general --- app/build.gradle.kts | 1 + app/src/full/AndroidManifest.xml | 5 +- .../matter/MatterCommissioningActivity.kt | 23 +++- .../matter/MatterCommissioningViewModel.kt | 20 ++++ .../android/thread/ThreadManagerImpl.kt | 109 ++++++++++++++++++ .../MatterFrontendCommissioningStatus.kt | 1 + .../companion/android/matter/MatterManager.kt | 4 +- .../companion/android/thread/ThreadManager.kt | 60 ++++++++++ .../companion/android/thread/ThreadModule.kt | 15 +++ .../android/webview/WebViewActivity.kt | 6 +- .../android/webview/WebViewPresenter.kt | 2 + .../android/webview/WebViewPresenterImpl.kt | 56 +++++++-- .../android/thread/ThreadManagerImpl.kt | 34 ++++++ .../data/websocket/WebSocketRepository.kt | 5 + .../websocket/impl/WebSocketRepositoryImpl.kt | 49 ++++++++ .../impl/entities/ThreadDatasetResponse.kt | 13 +++ .../impl/entities/ThreadDatasetTlvResponse.kt | 8 ++ .../companion/android/common/util/TextUtil.kt | 15 +++ 18 files changed, 406 insertions(+), 20 deletions(-) create mode 100644 app/src/full/java/io/homeassistant/companion/android/thread/ThreadManagerImpl.kt create mode 100644 app/src/main/java/io/homeassistant/companion/android/thread/ThreadManager.kt create mode 100644 app/src/main/java/io/homeassistant/companion/android/thread/ThreadModule.kt create mode 100644 app/src/minimal/java/io/homeassistant/companion/android/thread/ThreadManagerImpl.kt create mode 100644 common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/entities/ThreadDatasetResponse.kt create mode 100644 common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/entities/ThreadDatasetTlvResponse.kt create mode 100644 common/src/main/java/io/homeassistant/companion/android/common/util/TextUtil.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c26949a95..98842a401 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -168,6 +168,7 @@ dependencies { "fullImplementation"("com.google.android.gms:play-services-location:21.0.1") "fullImplementation"("com.google.android.gms:play-services-home:16.0.0") + "fullImplementation"("com.google.android.gms:play-services-threadnetwork:16.0.0-beta02") "fullImplementation"(platform("com.google.firebase:firebase-bom:31.2.2")) "fullImplementation"("com.google.firebase:firebase-messaging") "fullImplementation"("io.sentry:sentry-android:6.14.0") diff --git a/app/src/full/AndroidManifest.xml b/app/src/full/AndroidManifest.xml index 5ae7d9d3e..9a38355ba 100644 --- a/app/src/full/AndroidManifest.xml +++ b/app/src/full/AndroidManifest.xml @@ -1,5 +1,8 @@ - + + + diff --git a/app/src/full/java/io/homeassistant/companion/android/matter/MatterCommissioningActivity.kt b/app/src/full/java/io/homeassistant/companion/android/matter/MatterCommissioningActivity.kt index 9cec4578c..7d9458823 100644 --- a/app/src/full/java/io/homeassistant/companion/android/matter/MatterCommissioningActivity.kt +++ b/app/src/full/java/io/homeassistant/companion/android/matter/MatterCommissioningActivity.kt @@ -3,11 +3,14 @@ package io.homeassistant.companion.android.matter import android.os.Bundle import android.util.Log import androidx.activity.compose.setContent +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.lifecycle.lifecycleScope import com.google.accompanist.themeadapter.material.MdcTheme import com.google.android.gms.home.matter.Matter import com.google.android.gms.home.matter.commissioning.SharedDeviceData @@ -16,6 +19,7 @@ import io.homeassistant.companion.android.common.data.servers.ServerManager import io.homeassistant.companion.android.database.server.Server import io.homeassistant.companion.android.matter.views.MatterCommissioningView import io.homeassistant.companion.android.webview.WebViewActivity +import kotlinx.coroutines.launch import javax.inject.Inject @AndroidEntryPoint @@ -33,6 +37,10 @@ class MatterCommissioningActivity : AppCompatActivity() { private var deviceName by mutableStateOf(null) private var servers by mutableStateOf>(emptyList()) + private val threadPermissionLauncher = registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result -> + deviceCode?.let { viewModel.onThreadPermissionResult(result, it) } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -43,7 +51,7 @@ class MatterCommissioningActivity : AppCompatActivity() { deviceName = deviceName, servers = servers, onSelectServer = viewModel::checkSupport, - onConfirmCommissioning = { deviceCode?.let { viewModel.commissionDeviceWithCode(it) } }, + onConfirmCommissioning = { startCommissioning() }, onClose = { finish() }, onContinue = { continueToApp(false) } ) @@ -80,6 +88,19 @@ class MatterCommissioningActivity : AppCompatActivity() { } } + private fun startCommissioning() { + lifecycleScope.launch { + val threadIntent = viewModel.syncThreadIfNecessary() + if (threadIntent != null) { + threadPermissionLauncher.launch(IntentSenderRequest.Builder(threadIntent).build()) + } else { + deviceCode?.let { + viewModel.commissionDeviceWithCode(it) + } + } + } + } + private fun continueToApp(hideTransition: Boolean) { startActivity(WebViewActivity.newInstance(this, null, viewModel.serverId)) finish() diff --git a/app/src/full/java/io/homeassistant/companion/android/matter/MatterCommissioningViewModel.kt b/app/src/full/java/io/homeassistant/companion/android/matter/MatterCommissioningViewModel.kt index 704919996..9b4a73171 100644 --- a/app/src/full/java/io/homeassistant/companion/android/matter/MatterCommissioningViewModel.kt +++ b/app/src/full/java/io/homeassistant/companion/android/matter/MatterCommissioningViewModel.kt @@ -1,6 +1,8 @@ package io.homeassistant.companion.android.matter import android.app.Application +import android.content.IntentSender +import androidx.activity.result.ActivityResult import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -8,12 +10,14 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import io.homeassistant.companion.android.common.data.servers.ServerManager +import io.homeassistant.companion.android.thread.ThreadManager import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class MatterCommissioningViewModel @Inject constructor( private val matterManager: MatterManager, + private val threadManager: ThreadManager, private val serverManager: ServerManager, application: Application ) : AndroidViewModel(application) { @@ -75,6 +79,22 @@ class MatterCommissioningViewModel @Inject constructor( } } + suspend fun syncThreadIfNecessary(): IntentSender? { + step = CommissioningFlowStep.Working + return threadManager.syncPreferredDataset( + getApplication().applicationContext, + serverId, + viewModelScope + ) + } + + fun onThreadPermissionResult(result: ActivityResult, code: String) { + viewModelScope.launch { + threadManager.sendThreadDatasetExportResult(result, serverId) + commissionDeviceWithCode(code) + } + } + fun commissionDeviceWithCode(code: String) { viewModelScope.launch { step = CommissioningFlowStep.Working diff --git a/app/src/full/java/io/homeassistant/companion/android/thread/ThreadManagerImpl.kt b/app/src/full/java/io/homeassistant/companion/android/thread/ThreadManagerImpl.kt new file mode 100644 index 000000000..f6193b400 --- /dev/null +++ b/app/src/full/java/io/homeassistant/companion/android/thread/ThreadManagerImpl.kt @@ -0,0 +1,109 @@ +package io.homeassistant.companion.android.thread + +import android.app.Activity +import android.content.Context +import android.content.IntentSender +import android.os.Build +import android.util.Log +import androidx.activity.result.ActivityResult +import com.google.android.gms.threadnetwork.ThreadBorderAgent +import com.google.android.gms.threadnetwork.ThreadNetwork +import com.google.android.gms.threadnetwork.ThreadNetworkCredentials +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 kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import javax.inject.Inject +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +class ThreadManagerImpl @Inject constructor( + private val serverManager: ServerManager +) : ThreadManager { + companion object { + private const val TAG = "ThreadManagerImpl" + + // ID is a placeholder while we wait for Google to remove the requirement to provide one + private const val BORDER_AGENT_ID = "0000000000000001" + } + + override fun appSupportsThread(): Boolean = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 + + override suspend fun coreSupportsThread(serverId: Int): Boolean { + if (!serverManager.isRegistered() || serverManager.getServer(serverId) == null) return false + val config = serverManager.webSocketRepository(serverId).getConfig() + return config != null && + config.components.contains("thread") && + HomeAssistantVersion.fromString(config.version)?.isAtLeast(2023, 3, 0) == true + } + + override suspend fun syncPreferredDataset( + context: Context, + serverId: Int, + scope: CoroutineScope + ): IntentSender? { + if (!appSupportsThread() || !coreSupportsThread(serverId)) return null + + val getDeviceDataset = scope.async { getPreferredDatasetFromDevice(context) } + val getCoreDataset = scope.async { getPreferredDatasetFromServer(serverId) } + val deviceThreadIntent = getDeviceDataset.await() + val coreThreadDataset = getCoreDataset.await() + + if (deviceThreadIntent == null && coreThreadDataset != null) { + try { + importDatasetFromServer(context, coreThreadDataset.datasetId, serverId) + Log.d(TAG, "Thread import to device completed") + } catch (e: Exception) { + Log.e(TAG, "Thread import to device failed", e) + } + } else if (deviceThreadIntent != null && coreThreadDataset == null) { + Log.d(TAG, "Thread export is ready") + return deviceThreadIntent + } // else if device and core both have or don't have datasets, continue + + return null + } + + override suspend fun getPreferredDatasetFromServer(serverId: Int): ThreadDatasetResponse? { + val datasets = serverManager.webSocketRepository(serverId).getThreadDatasets() + return datasets?.firstOrNull { it.preferred } + } + + override suspend fun importDatasetFromServer(context: Context, datasetId: String, serverId: Int) { + val tlv = serverManager.webSocketRepository(serverId).getThreadDatasetTlv(datasetId)?.tlvAsByteArray + if (tlv != null) { + val threadBorderAgent = ThreadBorderAgent.newBuilder(BORDER_AGENT_ID.toByteArray()).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")) + } + } + + override suspend fun sendThreadDatasetExportResult(result: ActivityResult, serverId: Int) { + if (result.resultCode == Activity.RESULT_OK && coreSupportsThread(serverId)) { + val threadNetworkCredentials = ThreadNetworkCredentials.fromIntentSenderResultData(result.data!!) + try { + serverManager.webSocketRepository(serverId).addThreadDataset(threadNetworkCredentials.activeOperationalDataset) + } catch (e: Exception) { + Log.e(TAG, "Error while executing server new Thread credentials request", e) + } + } + } +} diff --git a/app/src/main/java/io/homeassistant/companion/android/matter/MatterFrontendCommissioningStatus.kt b/app/src/main/java/io/homeassistant/companion/android/matter/MatterFrontendCommissioningStatus.kt index 7e35edd06..8f7699e1c 100644 --- a/app/src/main/java/io/homeassistant/companion/android/matter/MatterFrontendCommissioningStatus.kt +++ b/app/src/main/java/io/homeassistant/companion/android/matter/MatterFrontendCommissioningStatus.kt @@ -3,6 +3,7 @@ package io.homeassistant.companion.android.matter enum class MatterFrontendCommissioningStatus { NOT_STARTED, REQUESTED, + THREAD_EXPORT_TO_SERVER, IN_PROGRESS, ERROR } diff --git a/app/src/main/java/io/homeassistant/companion/android/matter/MatterManager.kt b/app/src/main/java/io/homeassistant/companion/android/matter/MatterManager.kt index 208b25799..ddc12c492 100644 --- a/app/src/main/java/io/homeassistant/companion/android/matter/MatterManager.kt +++ b/app/src/main/java/io/homeassistant/companion/android/matter/MatterManager.kt @@ -29,13 +29,13 @@ interface MatterManager { /** * Send a request to the server to add a Matter device to the network and commission it - * @return `true` if the request was successful + * @return [MatterCommissionResponse], or `null` if it wasn't possible to complete the request */ suspend fun commissionDevice(code: String, serverId: Int): MatterCommissionResponse? /** * Send a request to the server to commission an "on network" Matter device - * @return `true` if the request was successful + * @return [MatterCommissionResponse], or `null` if it wasn't possible to complete the request */ suspend fun commissionOnNetworkDevice(pin: Long, serverId: Int): MatterCommissionResponse? } diff --git a/app/src/main/java/io/homeassistant/companion/android/thread/ThreadManager.kt b/app/src/main/java/io/homeassistant/companion/android/thread/ThreadManager.kt new file mode 100644 index 000000000..1b800c408 --- /dev/null +++ b/app/src/main/java/io/homeassistant/companion/android/thread/ThreadManager.kt @@ -0,0 +1,60 @@ +package io.homeassistant.companion.android.thread + +import android.content.Context +import android.content.IntentSender +import androidx.activity.result.ActivityResult +import io.homeassistant.companion.android.common.data.websocket.impl.entities.ThreadDatasetResponse +import kotlinx.coroutines.CoroutineScope + +interface ThreadManager { + + /** + * Indicates if the app on this device supports Thread credential management. + */ + fun appSupportsThread(): Boolean + + /** + * Indicates if the server supports Thread credential management. + */ + 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 or don't have preferred + * datasets, skip syncing. + * @return [IntentSender] if permission is required to import the device dataset, `null` if + * syncing completed or there is nothing to sync + */ + suspend fun syncPreferredDataset( + context: Context, + serverId: Int, + scope: CoroutineScope + ): IntentSender? + + /** + * Get the preferred Thread dataset from the server. + */ + suspend fun getPreferredDatasetFromServer(serverId: Int): ThreadDatasetResponse? + + /** + * Import a Thread dataset from the server to this device. + * @param datasetId The dataset ID as provided by the server + * @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) + + /** + * Start a flow to get the preferred Thread dataset from this device to export to the server. + * @return [IntentSender] to ask the user for permission to share the preferred dataset, or + * `null` if there are no datasets to import + * @throws Exception if it is not possible to get the preferred dataset + */ + suspend fun getPreferredDatasetFromDevice(context: Context): IntentSender? + + /** + * Process the result from [syncPreferredDataset] or [getPreferredDatasetFromDevice]'s intent + * and add the Thread dataset, if any, to the server. + */ + suspend fun sendThreadDatasetExportResult(result: ActivityResult, serverId: Int) +} diff --git a/app/src/main/java/io/homeassistant/companion/android/thread/ThreadModule.kt b/app/src/main/java/io/homeassistant/companion/android/thread/ThreadModule.kt new file mode 100644 index 000000000..3635efedf --- /dev/null +++ b/app/src/main/java/io/homeassistant/companion/android/thread/ThreadModule.kt @@ -0,0 +1,15 @@ +package io.homeassistant.companion.android.thread + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class ThreadModule { + @Binds + @Singleton + abstract fun bindThreadManager(threadManager: ThreadManagerImpl): ThreadManager +} diff --git a/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt b/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt index 572d560a2..f2dbb018f 100644 --- a/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt +++ b/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt @@ -1,7 +1,6 @@ package io.homeassistant.companion.android.webview import android.annotation.SuppressLint -import android.app.Activity import android.app.DownloadManager import android.app.PictureInPictureParams import android.content.ActivityNotFoundException @@ -164,9 +163,7 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi mFilePathCallback = null } private val commissionMatterDevice = registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result -> - // Any errors will have been shown in the UI provided by Play Services - if (result.resultCode == Activity.RESULT_OK) Log.d(TAG, "Matter commissioning returned success") - else Log.d(TAG, "Matter commissioning returned with non-OK code ${result.resultCode}") + presenter.onMatterCommissioningIntentResult(this, result) } @Inject @@ -683,6 +680,7 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi presenter.getMatterCommissioningStatusFlow().collect { Log.d(TAG, "Matter commissioning status changed to $it") when (it) { + MatterFrontendCommissioningStatus.THREAD_EXPORT_TO_SERVER, MatterFrontendCommissioningStatus.IN_PROGRESS -> { presenter.getMatterCommissioningIntent()?.let { intentSender -> commissionMatterDevice.launch(IntentSenderRequest.Builder(intentSender).build()) diff --git a/app/src/main/java/io/homeassistant/companion/android/webview/WebViewPresenter.kt b/app/src/main/java/io/homeassistant/companion/android/webview/WebViewPresenter.kt index 8f5613e3f..206e4d4cf 100644 --- a/app/src/main/java/io/homeassistant/companion/android/webview/WebViewPresenter.kt +++ b/app/src/main/java/io/homeassistant/companion/android/webview/WebViewPresenter.kt @@ -2,6 +2,7 @@ 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 @@ -47,5 +48,6 @@ interface WebViewPresenter { fun startCommissioningMatterDevice(context: Context) fun getMatterCommissioningStatusFlow(): Flow fun getMatterCommissioningIntent(): IntentSender? + fun onMatterCommissioningIntentResult(context: Context, result: ActivityResult) fun confirmMatterCommissioningError() } diff --git a/app/src/main/java/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt b/app/src/main/java/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt index bbdb2cc42..2c7a3a416 100644 --- a/app/src/main/java/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt +++ b/app/src/main/java/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt @@ -1,10 +1,12 @@ package io.homeassistant.companion.android.webview +import android.app.Activity import android.content.Context import android.content.IntentSender import android.graphics.Color import android.net.Uri import android.util.Log +import androidx.activity.result.ActivityResult import dagger.hilt.android.qualifiers.ActivityContext import io.homeassistant.companion.android.common.data.authentication.SessionState import io.homeassistant.companion.android.common.data.prefs.PrefsRepository @@ -12,6 +14,7 @@ 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.UrlHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -36,7 +39,8 @@ class WebViewPresenterImpl @Inject constructor( @ActivityContext context: Context, private val serverManager: ServerManager, private val prefsRepository: PrefsRepository, - private val matterUseCase: MatterManager + private val matterUseCase: MatterManager, + private val threadUseCase: ThreadManager ) : WebViewPresenter { companion object { @@ -289,21 +293,33 @@ class WebViewPresenterImpl @Inject constructor( if (_matterCommissioningStatus.value != MatterFrontendCommissioningStatus.REQUESTED) { _matterCommissioningStatus.tryEmit(MatterFrontendCommissioningStatus.REQUESTED) - matterUseCase.startNewCommissioningFlow( - context, - { intentSender -> - Log.d(TAG, "Matter commissioning is ready") - matterCommissioningIntentSender = intentSender - _matterCommissioningStatus.tryEmit(MatterFrontendCommissioningStatus.IN_PROGRESS) - }, - { e -> - Log.e(TAG, "Matter commissioning couldn't be prepared", e) - _matterCommissioningStatus.tryEmit(MatterFrontendCommissioningStatus.ERROR) + mainScope.launch { + val deviceThreadIntent = threadUseCase.syncPreferredDataset(context, serverId, this) + if (deviceThreadIntent != null) { + matterCommissioningIntentSender = deviceThreadIntent + _matterCommissioningStatus.tryEmit(MatterFrontendCommissioningStatus.THREAD_EXPORT_TO_SERVER) + } else { + startMatterCommissioningFlow(context) } - ) + } } // else already waiting for a result, don't send another request } + private fun startMatterCommissioningFlow(context: Context) { + matterUseCase.startNewCommissioningFlow( + context, + { intentSender -> + Log.d(TAG, "Matter commissioning is ready") + matterCommissioningIntentSender = intentSender + _matterCommissioningStatus.tryEmit(MatterFrontendCommissioningStatus.IN_PROGRESS) + }, + { e -> + Log.e(TAG, "Matter commissioning couldn't be prepared", e) + _matterCommissioningStatus.tryEmit(MatterFrontendCommissioningStatus.ERROR) + } + ) + } + override fun getMatterCommissioningStatusFlow(): Flow = _matterCommissioningStatus.asStateFlow() @@ -313,6 +329,22 @@ class WebViewPresenterImpl @Inject constructor( return intent } + override fun onMatterCommissioningIntentResult(context: Context, result: ActivityResult) { + when (_matterCommissioningStatus.value) { + MatterFrontendCommissioningStatus.THREAD_EXPORT_TO_SERVER -> { + mainScope.launch { + threadUseCase.sendThreadDatasetExportResult(result, serverId) + startMatterCommissioningFlow(context) + } + } + else -> { + // Any errors will have been shown in the UI provided by Play Services + if (result.resultCode == Activity.RESULT_OK) Log.d(TAG, "Matter commissioning returned success") + else Log.d(TAG, "Matter commissioning returned with non-OK code ${result.resultCode}") + } + } + } + override fun confirmMatterCommissioningError() { _matterCommissioningStatus.tryEmit(MatterFrontendCommissioningStatus.NOT_STARTED) } diff --git a/app/src/minimal/java/io/homeassistant/companion/android/thread/ThreadManagerImpl.kt b/app/src/minimal/java/io/homeassistant/companion/android/thread/ThreadManagerImpl.kt new file mode 100644 index 000000000..7895cda3a --- /dev/null +++ b/app/src/minimal/java/io/homeassistant/companion/android/thread/ThreadManagerImpl.kt @@ -0,0 +1,34 @@ +package io.homeassistant.companion.android.thread + +import android.content.Context +import android.content.IntentSender +import androidx.activity.result.ActivityResult +import io.homeassistant.companion.android.common.data.websocket.impl.entities.ThreadDatasetResponse +import kotlinx.coroutines.CoroutineScope +import javax.inject.Inject + +class ThreadManagerImpl @Inject constructor() : ThreadManager { + + // Thread support currently depends on Google Play Services, + // and as a result Thread is not supported with the minimal flavor + + override fun appSupportsThread(): Boolean = false + + override suspend fun coreSupportsThread(serverId: Int): Boolean = false + + override suspend fun syncPreferredDataset( + context: Context, + serverId: Int, + scope: CoroutineScope + ): IntentSender? = null + + override suspend fun getPreferredDatasetFromServer(serverId: Int): ThreadDatasetResponse? = null + + override suspend fun importDatasetFromServer(context: Context, datasetId: String, serverId: Int) { } + + override suspend fun getPreferredDatasetFromDevice(context: Context): IntentSender? { + throw IllegalStateException("Thread is not supported with the minimal flavor") + } + + override suspend fun sendThreadDatasetExportResult(result: ActivityResult, serverId: Int) { } +} diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/WebSocketRepository.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/WebSocketRepository.kt index 0d504e418..504123f99 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/WebSocketRepository.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/WebSocketRepository.kt @@ -16,6 +16,8 @@ import io.homeassistant.companion.android.common.data.websocket.impl.entities.Ge import io.homeassistant.companion.android.common.data.websocket.impl.entities.MatterCommissionResponse import io.homeassistant.companion.android.common.data.websocket.impl.entities.StateChangedEvent import io.homeassistant.companion.android.common.data.websocket.impl.entities.TemplateUpdatedEvent +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.data.websocket.impl.entities.TriggerEvent import kotlinx.coroutines.flow.Flow @@ -41,6 +43,9 @@ interface WebSocketRepository { suspend fun ackNotification(confirmId: String): Boolean suspend fun commissionMatterDevice(code: String): MatterCommissionResponse? suspend fun commissionMatterDeviceOnNetwork(pin: Long): MatterCommissionResponse? + suspend fun getThreadDatasets(): List? + suspend fun getThreadDatasetTlv(datasetId: String): ThreadDatasetTlvResponse? + suspend fun addThreadDataset(tlv: ByteArray): Boolean suspend fun getConversation(speech: String): ConversationResponse? } diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/WebSocketRepositoryImpl.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/WebSocketRepositoryImpl.kt index e033553e2..b63df3e3b 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/WebSocketRepositoryImpl.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/WebSocketRepositoryImpl.kt @@ -36,7 +36,10 @@ import io.homeassistant.companion.android.common.data.websocket.impl.entities.Ma import io.homeassistant.companion.android.common.data.websocket.impl.entities.SocketResponse import io.homeassistant.companion.android.common.data.websocket.impl.entities.StateChangedEvent import io.homeassistant.companion.android.common.data.websocket.impl.entities.TemplateUpdatedEvent +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.data.websocket.impl.entities.TriggerEvent +import io.homeassistant.companion.android.common.util.toHexString import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -394,6 +397,52 @@ class WebSocketRepositoryImpl @AssistedInject constructor( } } + /** + * Return a list of all Thread datasets known to the server. + * @return List with [ThreadDatasetResponse]s, or `null` if not an admin or no response. + */ + override suspend fun getThreadDatasets(): List? { + val response = sendMessage( + mapOf( + "type" to "thread/list_datasets" + ) + ) + return if (response?.success == true && response.result?.contains("datasets") == true) { + mapper.convertValue(response.result["datasets"]!!) + } else null + } + + /** + * Return the TLV value for a dataset. + * @return [ThreadDatasetTlvResponse] for the Thread dataset, or `null` if not found, not an + * admin or no response. + */ + override suspend fun getThreadDatasetTlv(datasetId: String): ThreadDatasetTlvResponse? { + val response = sendMessage( + mapOf( + "type" to "thread/get_dataset_tlv", + "dataset_id" to datasetId + ) + ) + + return mapResponse(response) + } + + /** + * Add a new set of Thread network credentials to the server. + * @return `true` if the server indicated success + */ + override suspend fun addThreadDataset(tlv: ByteArray): Boolean { + val response = sendMessage( + mapOf( + "type" to "thread/add_dataset_tlv", + "source" to "Google", + "tlv" to tlv.toHexString() + ) + ) + return response?.success == true + } + private suspend fun connect(): Boolean { connectedMutex.withLock { if (connection != null && connected.isCompleted) { diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/entities/ThreadDatasetResponse.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/entities/ThreadDatasetResponse.kt new file mode 100644 index 000000000..b2d45a422 --- /dev/null +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/entities/ThreadDatasetResponse.kt @@ -0,0 +1,13 @@ +package io.homeassistant.companion.android.common.data.websocket.impl.entities + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +@JsonIgnoreProperties(ignoreUnknown = true) +data class ThreadDatasetResponse( + val datasetId: String, + val extendedPanId: String, + val networkName: String, + val panId: String, + val preferred: Boolean, + val source: String +) diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/entities/ThreadDatasetTlvResponse.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/entities/ThreadDatasetTlvResponse.kt new file mode 100644 index 000000000..192008cea --- /dev/null +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/entities/ThreadDatasetTlvResponse.kt @@ -0,0 +1,8 @@ +package io.homeassistant.companion.android.common.data.websocket.impl.entities + +data class ThreadDatasetTlvResponse( + val tlv: String +) { + val tlvAsByteArray: ByteArray + get() = tlv.chunked(2).map { it.toInt(16).toByte() }.toByteArray() +} diff --git a/common/src/main/java/io/homeassistant/companion/android/common/util/TextUtil.kt b/common/src/main/java/io/homeassistant/companion/android/common/util/TextUtil.kt new file mode 100644 index 000000000..b31e3b20d --- /dev/null +++ b/common/src/main/java/io/homeassistant/companion/android/common/util/TextUtil.kt @@ -0,0 +1,15 @@ +package io.homeassistant.companion.android.common.util + +import okhttp3.internal.and + +private val HEX_ARRAY = "0123456789ABCDEF".toCharArray() + +fun ByteArray.toHexString(): String { // From https://stackoverflow.com/a/9855338/4214819 + val hexChars = CharArray(this.size * 2) + for (j in 0 until this.size) { + val v = get(j) and 0xFF + hexChars[j * 2] = HEX_ARRAY[v ushr 4] + hexChars[j * 2 + 1] = HEX_ARRAY[v and 0x0F] + } + return String(hexChars) +}