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 e522a259ce2..6699aa5d1de 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 @@ -97,6 +97,7 @@ class MatterCommissioningViewModel @Inject constructor( val result = threadManager.syncPreferredDataset( getApplication().applicationContext, serverId, + false, viewModelScope ) when (result) { 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 index 88b3ca55dac..0de92e53343 100644 --- a/app/src/full/java/io/homeassistant/companion/android/thread/ThreadManagerImpl.kt +++ b/app/src/full/java/io/homeassistant/companion/android/thread/ThreadManagerImpl.kt @@ -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) } 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 deleted file mode 100644 index 8f7699e1c93..00000000000 --- a/app/src/main/java/io/homeassistant/companion/android/matter/MatterFrontendCommissioningStatus.kt +++ /dev/null @@ -1,9 +0,0 @@ -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/settings/developer/DeveloperSettingsPresenterImpl.kt b/app/src/main/java/io/homeassistant/companion/android/settings/developer/DeveloperSettingsPresenterImpl.kt index 45ee7558af9..b0b69d4c2e6 100644 --- a/app/src/main/java/io/homeassistant/companion/android/settings/developer/DeveloperSettingsPresenterImpl.kt +++ b/app/src/main/java/io/homeassistant/companion/android/settings/developer/DeveloperSettingsPresenterImpl.kt @@ -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 -> { 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 index 7976e890c45..23d4496dfd3 100644 --- a/app/src/main/java/io/homeassistant/companion/android/thread/ThreadManager.kt +++ b/app/src/main/java/io/homeassistant/companion/android/thread/ThreadManager.kt @@ -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 diff --git a/app/src/main/java/io/homeassistant/companion/android/webview/MatterThreadStep.kt b/app/src/main/java/io/homeassistant/companion/android/webview/MatterThreadStep.kt new file mode 100644 index 00000000000..808a66effcf --- /dev/null +++ b/app/src/main/java/io/homeassistant/companion/android/webview/MatterThreadStep.kt @@ -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 +} 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 c7c5a2ef9ce..26b4438f0e3 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 @@ -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) 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 313ce866bfa..21baaedce40 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 @@ -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 - 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 + fun getMatterThreadIntent(): IntentSender? + fun onMatterThreadIntentResult(context: Context, result: ActivityResult) + fun finishMatterThreadFlow() } 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 4a490299087..fe31f539e20 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 @@ -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 = - _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.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) } } 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 index 875ea499bfe..e10d7508ff2 100644 --- a/app/src/minimal/java/io/homeassistant/companion/android/thread/ThreadManagerImpl.kt +++ b/app/src/minimal/java/io/homeassistant/companion/android/thread/ThreadManagerImpl.kt @@ -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 diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index 95179839823..5cb57d00847 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -1145,6 +1145,10 @@ The Home Assistant server does not support Thread Updated network from Home Assistant on this device Manually update device and server Thread credentials and verify results + Imported credential + You don\'t have any credentials to import. + You are not connected to a local network. Connect to Wi-Fi or ethernet to import Thread credentials. + Thread is currently unavailable Vibrate when clicked Requires unlocked device No results yet