Add initial Matter commissioning support (#3132)

* Matter commissioning: MVP

 - Implement a basic framework for the app to support Matter commissioning
 - Add hooks for the `matter/commission` message that will be sent by the frontend to trigger Matter commissioning

* [WIP] HA as Matter share target

* [WIP] Updated commissioning flow

 - Add websocket Matter API support
 - Update architecture to better ensure a working minimal build
 - Remove external bus responses for Matter commissioning message

* Rename MatterRepository

* Handle shared Matter devices

 - Adds UI to handle shared Matter devices instead of copying the pairing code to the clipboard
 - Updates the on network pairing pin/code to be a String, which may be received for shared devices

* Cleanup test button

* Pairing code type and service error handling

 - Update pairing/pin code type to Long for server
 - Send Play Services error if commissioning returned an error

* Increase timeout for Matter commissioning requests

* Use normal commissioning for shared devices

 - Use the normal matter/commission command for devices shared with the app
 - Shared device UI polishing

* Fix minimal and remove old name

* Update frontend commissioning status enum
This commit is contained in:
Joris Pelgröm 2022-12-03 23:36:12 +01:00 committed by GitHub
parent 312a36e898
commit 69d5949f14
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 743 additions and 14 deletions

View file

@ -169,6 +169,7 @@ dependencies {
implementation("com.squareup.picasso:picasso:2.8")
"fullImplementation"("com.google.android.gms:play-services-location:21.0.1")
"fullImplementation"("com.google.android.gms:play-services-home:16.0.0-beta1")
"fullImplementation"(platform("com.google.firebase:firebase-bom:30.4.1"))
"fullImplementation"("com.google.firebase:firebase-messaging")
"fullImplementation"("io.sentry:sentry-android:6.9.0")

View file

@ -6,6 +6,17 @@
<meta-data android:name="io.sentry.auto-init" android:value="false" />
<meta-data android:name="io.sentry.release" android:value="${sentryRelease}" />
<activity
android:name=".matter.MatterCommissioningActivity"
android:configChanges="orientation|screenSize"
android:exported="true"
android:theme="@style/Theme.HomeAssistant.Config">
<intent-filter>
<action android:name="com.google.android.gms.home.matter.ACTION_COMMISSION_DEVICE"/>
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
</activity>
<receiver android:name=".location.HighAccuracyLocationReceiver"
android:enabled="true"
android:exported="true" />
@ -23,6 +34,10 @@
android:enabled="true"
android:exported="true"/>
<service
android:name=".matter.MatterCommissioningService"
android:exported="true" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_stat_ic_notification" />

View file

@ -0,0 +1,75 @@
package io.homeassistant.companion.android.matter
import android.os.Bundle
import android.util.Log
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import com.google.android.gms.home.matter.Matter
import com.google.android.gms.home.matter.commissioning.SharedDeviceData
import com.google.android.material.composethemeadapter.MdcTheme
import dagger.hilt.android.AndroidEntryPoint
import io.homeassistant.companion.android.matter.views.MatterCommissioningView
import io.homeassistant.companion.android.webview.WebViewActivity
@AndroidEntryPoint
class MatterCommissioningActivity : AppCompatActivity() {
companion object {
private const val TAG = "MatterCommissioningActi"
}
private val viewModel: MatterCommissioningViewModel by viewModels()
private var deviceCode: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MdcTheme {
MatterCommissioningView(
step = viewModel.step,
onConfirmCommissioning = { deviceCode?.let { viewModel.commissionDeviceWithCode(it) } },
onClose = { finish() },
onContinue = { continueToApp(false) }
)
}
}
}
override fun onResume() {
super.onResume()
if (intent?.action == Matter.ACTION_COMMISSION_DEVICE) {
try {
val data = SharedDeviceData.fromIntent(intent)
Log.d(
TAG,
"Matter commissioning data:\n" +
"device name: ${data.deviceName}\n" +
"room name: ${data.roomName}\n" +
"device type: ${data.deviceType}\n" +
"product id: ${data.productId}\n" +
"vendor id: ${data.vendorId}\n" +
"window expires: ${data.commissioningWindowExpirationMillis}"
)
deviceCode = data.manualPairingCode
viewModel.checkSupport()
} catch (e: SharedDeviceData.InvalidSharedDeviceDataException) {
Log.e(TAG, "Received incomplete Matter commissioning data, launching webview")
continueToApp(true)
}
} else {
Log.d(TAG, "No Matter commissioning data, launching webview")
continueToApp(true)
}
}
private fun continueToApp(hideTransition: Boolean) {
startActivity(WebViewActivity.newInstance(this))
finish()
if (hideTransition) { // Disable activity start/stop animation
overridePendingTransition(0, 0)
}
}
}

View file

@ -0,0 +1,63 @@
package io.homeassistant.companion.android.matter
import android.app.Service
import android.content.Intent
import android.os.IBinder
import android.util.Log
import com.google.android.gms.home.matter.commissioning.CommissioningCompleteMetadata
import com.google.android.gms.home.matter.commissioning.CommissioningRequestMetadata
import com.google.android.gms.home.matter.commissioning.CommissioningService
import com.google.android.gms.home.matter.commissioning.CommissioningService.CommissioningError
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
class MatterCommissioningService : Service(), CommissioningService.Callback {
companion object {
private const val TAG = "MatterCommissioningServ"
}
@Inject
lateinit var matterManager: MatterManager
private val serviceScope = CoroutineScope(Dispatchers.Main + Job())
private lateinit var commissioningServiceDelegate: CommissioningService
override fun onCreate() {
super.onCreate()
commissioningServiceDelegate = CommissioningService.Builder(this).setCallback(this).build()
}
override fun onBind(intent: Intent?): IBinder {
return commissioningServiceDelegate.asBinder()
}
override fun onDestroy() {
super.onDestroy()
serviceScope.cancel()
}
override fun onCommissioningRequested(metadata: CommissioningRequestMetadata) {
Log.d(TAG, "Received request to commission Matter device")
serviceScope.launch {
val success = matterManager.commissionOnNetworkDevice(metadata.passcode)
Log.d(TAG, "Server commissioning was ${if (success) "successful" else "not successful"}")
if (success) {
commissioningServiceDelegate.sendCommissioningComplete(
CommissioningCompleteMetadata.Builder().build()
)
} else {
commissioningServiceDelegate.sendCommissioningError(CommissioningError.OTHER)
}
}
}
}

View file

@ -0,0 +1,63 @@
package io.homeassistant.companion.android.matter
import android.app.Application
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import io.homeassistant.companion.android.common.data.integration.IntegrationRepository
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class MatterCommissioningViewModel @Inject constructor(
private val matterManager: MatterManager,
private val integrationRepository: IntegrationRepository,
application: Application
) : AndroidViewModel(application) {
enum class CommissioningFlowStep {
NOT_STARTED,
NOT_REGISTERED,
CHECKING_CORE,
NOT_SUPPORTED,
CONFIRMATION,
WORKING,
SUCCESS,
FAILURE
}
var step by mutableStateOf(CommissioningFlowStep.NOT_STARTED)
private set
fun checkSupport() {
viewModelScope.launch {
if (step != CommissioningFlowStep.NOT_STARTED) return@launch
if (!integrationRepository.isRegistered()) {
step = CommissioningFlowStep.NOT_REGISTERED
return@launch
}
step = CommissioningFlowStep.CHECKING_CORE
val coreSupport = matterManager.coreSupportsCommissioning()
step =
if (coreSupport) CommissioningFlowStep.CONFIRMATION
else CommissioningFlowStep.NOT_SUPPORTED
}
}
fun commissionDeviceWithCode(code: String) {
viewModelScope.launch {
step = CommissioningFlowStep.WORKING
val result = matterManager.commissionDevice(code)
step =
if (result) CommissioningFlowStep.SUCCESS
else CommissioningFlowStep.FAILURE
}
}
}

View file

@ -0,0 +1,65 @@
package io.homeassistant.companion.android.matter
import android.content.ComponentName
import android.content.Context
import android.content.IntentSender
import android.os.Build
import android.util.Log
import com.google.android.gms.home.matter.Matter
import com.google.android.gms.home.matter.commissioning.CommissioningRequest
import io.homeassistant.companion.android.common.data.websocket.WebSocketRepository
import javax.inject.Inject
class MatterManagerImpl @Inject constructor(
private val websocketRepository: WebSocketRepository
) : MatterManager {
companion object {
private const val TAG = "MatterManagerImpl"
}
override fun appSupportsCommissioning(): Boolean =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1
override suspend fun coreSupportsCommissioning(): Boolean {
val config = websocketRepository.getConfig()
return config != null && config.components.contains("matter")
}
override fun startNewCommissioningFlow(
context: Context,
onSuccess: (IntentSender) -> Unit,
onFailure: (Exception) -> Unit
) {
if (appSupportsCommissioning()) {
Matter.getCommissioningClient(context)
.commissionDevice(
CommissioningRequest.builder()
.setCommissioningService(ComponentName(context, MatterCommissioningService::class.java))
.build()
)
.addOnSuccessListener { onSuccess(it) }
.addOnFailureListener { onFailure(it) }
} else {
onFailure(IllegalStateException("Matter commissioning is not supported on SDK <27"))
}
}
override suspend fun commissionDevice(code: String): Boolean {
return try {
websocketRepository.commissionMatterDevice(code)
} catch (e: Exception) {
Log.e(TAG, "Error while executing server commissioning request", e)
false
}
}
override suspend fun commissionOnNetworkDevice(pin: Long): Boolean {
return try {
websocketRepository.commissionMatterDeviceOnNetwork(pin)
} catch (e: Exception) {
Log.e(TAG, "Error while executing server commissioning request", e)
false
}
}
}

View file

@ -0,0 +1,192 @@
package io.homeassistant.companion.android.matter.views
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Button
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.MaterialTheme
import androidx.compose.material.ProvideTextStyle
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import com.google.android.material.composethemeadapter.MdcTheme
import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.matter.MatterCommissioningViewModel.CommissioningFlowStep
import kotlin.math.min
import io.homeassistant.companion.android.common.R as commonR
@Composable
fun MatterCommissioningView(
step: CommissioningFlowStep,
onConfirmCommissioning: () -> Unit,
onClose: () -> Unit,
onContinue: () -> Unit
) {
if (step == CommissioningFlowStep.NOT_STARTED) return
val screenWidth = LocalConfiguration.current.screenWidthDp
val notLoadingSteps = listOf(
CommissioningFlowStep.NOT_REGISTERED,
CommissioningFlowStep.NOT_SUPPORTED,
CommissioningFlowStep.CONFIRMATION,
CommissioningFlowStep.SUCCESS,
CommissioningFlowStep.FAILURE
)
Box(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
) {
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
.width(min(screenWidth, 600).dp)
.align(Alignment.Center)
) {
MatterCommissioningViewHeader()
ProvideTextStyle(MaterialTheme.typography.body1) {
Box(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.CenterHorizontally)
) {
if (step !in notLoadingSteps) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator()
if (step == CommissioningFlowStep.WORKING) {
Text(
text = stringResource(commonR.string.matter_shared_status_working),
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth(0.67f)
.padding(top = 16.dp)
)
}
}
} else {
Text(
text = stringResource(
when (step) {
CommissioningFlowStep.NOT_REGISTERED -> commonR.string.matter_shared_status_not_registered
CommissioningFlowStep.NOT_SUPPORTED -> commonR.string.matter_shared_status_not_supported
CommissioningFlowStep.CONFIRMATION -> commonR.string.matter_shared_status_confirmation
CommissioningFlowStep.SUCCESS -> commonR.string.matter_shared_status_success
CommissioningFlowStep.FAILURE -> commonR.string.matter_shared_status_failure
else -> 0 // not used because everything above is in notLoadingSteps
}
),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
}
}
if (step in notLoadingSteps) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 32.dp, bottom = 16.dp)
) {
if (step == CommissioningFlowStep.CONFIRMATION || step == CommissioningFlowStep.FAILURE) {
TextButton(onClick = { onClose() }) {
Text(stringResource(commonR.string.cancel))
}
}
Spacer(modifier = Modifier.weight(1f))
when (step) {
CommissioningFlowStep.NOT_REGISTERED,
CommissioningFlowStep.NOT_SUPPORTED -> {
Button(onClick = { onClose() }) {
Text(stringResource(commonR.string.close))
}
}
CommissioningFlowStep.CONFIRMATION -> {
Button(onClick = { onConfirmCommissioning() }) {
Text(stringResource(commonR.string.add_device))
}
}
CommissioningFlowStep.SUCCESS -> {
Button(onClick = { onContinue() }) {
Text(stringResource(commonR.string.continue_connect))
}
}
CommissioningFlowStep.FAILURE -> {
Button(onClick = { onConfirmCommissioning() }) {
Text(stringResource(commonR.string.retry))
}
}
else -> { /* No button */ }
}
}
}
}
}
}
@Composable
fun MatterCommissioningViewHeader() {
Column(modifier = Modifier.fillMaxWidth()) {
Spacer(modifier = Modifier.height(32.dp))
Image(
imageVector = ImageVector.vectorResource(R.drawable.ic_matter),
contentDescription = null,
colorFilter = ColorFilter.tint(colorResource(commonR.color.colorAccent)),
modifier = Modifier
.size(48.dp)
.align(Alignment.CenterHorizontally)
)
Text(
text = stringResource(commonR.string.matter_shared_title),
style = MaterialTheme.typography.h5,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(vertical = 16.dp)
.align(Alignment.CenterHorizontally)
)
}
}
@Preview
@Composable
fun PreviewMatterCommissioningView(
@PreviewParameter(MatterCommissioningViewPreviewStates::class) step: CommissioningFlowStep
) {
MdcTheme {
MatterCommissioningView(
step = step,
onConfirmCommissioning = { },
onClose = { },
onContinue = { }
)
}
}

View file

@ -0,0 +1,17 @@
package io.homeassistant.companion.android.matter.views
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.homeassistant.companion.android.matter.MatterCommissioningViewModel
class MatterCommissioningViewPreviewStates :
PreviewParameterProvider<MatterCommissioningViewModel.CommissioningFlowStep> {
override val values = sequenceOf(
MatterCommissioningViewModel.CommissioningFlowStep.NOT_REGISTERED,
MatterCommissioningViewModel.CommissioningFlowStep.CHECKING_CORE,
MatterCommissioningViewModel.CommissioningFlowStep.NOT_SUPPORTED,
MatterCommissioningViewModel.CommissioningFlowStep.CONFIRMATION,
MatterCommissioningViewModel.CommissioningFlowStep.WORKING,
MatterCommissioningViewModel.CommissioningFlowStep.SUCCESS,
MatterCommissioningViewModel.CommissioningFlowStep.FAILURE
)
}

View file

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

View file

@ -0,0 +1,40 @@
package io.homeassistant.companion.android.matter
import android.content.Context
import android.content.IntentSender
interface MatterManager {
/**
* Indicates if the app on this device supports Matter commissioning
*/
fun appSupportsCommissioning(): Boolean
/**
* Indicates if the server supports Matter commissioning
*/
suspend fun coreSupportsCommissioning(): Boolean
/**
* Start a flow to commission a Matter device that is on the same network as this device.
* @param onSuccess Callback that receives an intent to launch the commissioning flow
* @param onFailure Callback for an exception if the commissioning flow cannot be started
*/
fun startNewCommissioningFlow(
context: Context,
onSuccess: (IntentSender) -> Unit,
onFailure: (Exception) -> Unit
)
/**
* Send a request to the server to add a Matter device to the network and commission it
* @return `true` if the request was successful
*/
suspend fun commissionDevice(code: String): Boolean
/**
* Send a request to the server to commission an "on network" Matter device
* @return `true` if the request was successful
*/
suspend fun commissionOnNetworkDevice(pin: Long): Boolean
}

View file

@ -0,0 +1,16 @@
package io.homeassistant.companion.android.matter
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 MatterModule {
@Binds
@Singleton
abstract fun bindMatterManager(matterManager: MatterManagerImpl): MatterManager
}

View file

@ -3,14 +3,12 @@ package io.homeassistant.companion.android.settings
import android.annotation.SuppressLint
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentFactory
import io.homeassistant.companion.android.common.data.integration.IntegrationRepository
import io.homeassistant.companion.android.settings.language.LanguagesProvider
import javax.inject.Inject
class SettingsFragmentFactory @Inject constructor(
private val settingsPresenter: SettingsPresenter,
private val languagesProvider: LanguagesProvider,
private val integrationRepository: IntegrationRepository
private val languagesProvider: LanguagesProvider
) : FragmentFactory() {
@SuppressLint("NewApi")
override fun instantiate(classLoader: ClassLoader, className: String): Fragment {

View file

@ -1,6 +1,7 @@
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
@ -44,6 +45,7 @@ import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.IntentSenderRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AlertDialog
@ -54,7 +56,9 @@ import androidx.core.content.res.ResourcesCompat
import androidx.core.graphics.ColorUtils
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import com.google.android.exoplayer2.DefaultLoadControl
@ -82,6 +86,7 @@ import io.homeassistant.companion.android.databinding.ActivityWebviewBinding
import io.homeassistant.companion.android.databinding.DialogAuthenticationBinding
import io.homeassistant.companion.android.databinding.ExoPlayerViewBinding
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
@ -154,6 +159,13 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi
mFilePathCallback?.onReceiveValue(result)
mFilePathCallback = null
}
private val commissionMatterDevice = registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result ->
val success = result.resultCode == Activity.RESULT_OK
// TODO send something to frontend?
if (success) Log.d(TAG, "Matter commissioning returned success")
else Log.d(TAG, "Matter commissioning returned with non-OK code ${result.resultCode}")
}
@Inject
lateinit var presenter: WebViewPresenter
@ -577,6 +589,7 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi
"config/get" -> {
val pm: PackageManager = context.packageManager
val hasNfc = pm.hasSystemFeature(PackageManager.FEATURE_NFC)
val canCommissionMatter = presenter.appCanCommissionMatterDevice()
webView.externalBus(
id = JSONObject(message).get("id"),
type = "result",
@ -585,7 +598,8 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi
mapOf(
"hasSettingsScreen" to true,
"canWriteTag" to hasNfc,
"hasExoPlayer" to true
"hasExoPlayer" to true,
"canCommissionMatter" to canCommissionMatter
)
)
) {
@ -616,6 +630,7 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi
messageId = JSONObject(message).getInt("id")
)
)
"matter/commission" -> presenter.startCommissioningMatterDevice(this@WebViewActivity)
"exoplayer/play_hls" -> exoPlayHls(json)
"exoplayer/stop" -> exoStopHls()
"exoplayer/resize" -> exoResizeHls(json)
@ -654,6 +669,25 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi
val webviewPackage = WebViewCompat.getCurrentWebViewPackage(this)
Log.d(TAG, "Current webview package ${webviewPackage?.packageName} and version ${webviewPackage?.versionName}")
}
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
presenter.getMatterCommissioningStatusFlow().collect {
Log.d(TAG, "Matter commissioning status changed to $it")
when (it) {
MatterFrontendCommissioningStatus.IN_PROGRESS -> {
presenter.getMatterCommissioningIntent()?.let { intentSender ->
commissionMatterDevice.launch(IntentSenderRequest.Builder(intentSender).build())
}
}
MatterFrontendCommissioningStatus.ERROR -> {
// TODO show error?
}
else -> { } // Do nothing
}
}
}
}
}
private fun getAndSetStatusBarNavigationBarColors() {
@ -1294,17 +1328,19 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi
id: Any,
type: String,
success: Boolean,
result: Any?,
result: Any? = null,
error: Any? = null,
callback: ValueCallback<String>?
) {
val json = JSONObject(
mapOf(
"id" to id,
"type" to type,
"success" to success,
"result" to result
)
val map = mutableMapOf(
"id" to id,
"type" to type,
"success" to success
)
if (result != null) map["result"] = result
if (error != null) map["error"] = error
val json = JSONObject(map.toMap())
val script = "externalBus($json);"
Log.d(TAG, script)

View file

@ -1,6 +1,9 @@
package io.homeassistant.companion.android.webview
import android.content.Context
import android.content.IntentSender
import io.homeassistant.companion.android.matter.MatterFrontendCommissioningStatus
import kotlinx.coroutines.flow.Flow
interface WebViewPresenter {
@ -36,4 +39,9 @@ interface WebViewPresenter {
fun getAuthorizationHeader(): String
suspend fun parseWebViewColor(webViewColor: String): Int
fun appCanCommissionMatterDevice(): Boolean
fun startCommissioningMatterDevice(context: Context)
fun getMatterCommissioningStatusFlow(): Flow<MatterFrontendCommissioningStatus>
fun getMatterCommissioningIntent(): IntentSender?
}

View file

@ -1,6 +1,7 @@
package io.homeassistant.companion.android.webview
import android.content.Context
import android.content.IntentSender
import android.graphics.Color
import android.net.Uri
import android.util.Log
@ -10,11 +11,16 @@ import io.homeassistant.companion.android.common.data.authentication.SessionStat
import io.homeassistant.companion.android.common.data.integration.IntegrationRepository
import io.homeassistant.companion.android.common.data.url.UrlRepository
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.util.UrlHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
@ -31,7 +37,8 @@ class WebViewPresenterImpl @Inject constructor(
@ActivityContext context: Context,
private val urlUseCase: UrlRepository,
private val authenticationUseCase: AuthenticationRepository,
private val integrationUseCase: IntegrationRepository
private val integrationUseCase: IntegrationRepository,
private val matterUseCase: MatterManager
) : WebViewPresenter {
companion object {
@ -44,6 +51,10 @@ class WebViewPresenterImpl @Inject constructor(
private var url: URL? = null
private val _matterCommissioningStatus = MutableStateFlow(MatterFrontendCommissioningStatus.NOT_STARTED)
private var matterCommissioningIntentSender: IntentSender? = null
override fun onViewReady(path: String?) {
mainScope.launch {
val oldUrl = url
@ -236,4 +247,34 @@ class WebViewPresenterImpl @Inject constructor(
)
} else Color.parseColor(colorString)
}
override fun appCanCommissionMatterDevice(): Boolean = matterUseCase.appSupportsCommissioning()
override fun startCommissioningMatterDevice(context: Context) {
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)
}
)
} // else already waiting for a result, don't send another request
}
override fun getMatterCommissioningStatusFlow(): Flow<MatterFrontendCommissioningStatus> =
_matterCommissioningStatus.asStateFlow()
override fun getMatterCommissioningIntent(): IntentSender? {
val intent = matterCommissioningIntentSender
matterCommissioningIntentSender = null
return intent
}
}

View file

@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="200dp"
android:height="196dp"
android:viewportWidth="200"
android:viewportHeight="196">
<path
android:pathData="M88.47,63.57C77.8,61.64 67.8,57.05 59.39,50.22L38.26,62.39C54.69,78.65 76.88,87.77 100,87.77C123.12,87.77 145.31,78.65 161.74,62.39L140.71,50.22C132.29,57.05 122.29,61.64 111.63,63.57V6.68L100.05,0L88.47,6.68V63.57Z"
android:fillColor="#000000"/>
<path
android:pathData="M76.04,129.31C87.61,149.33 90.81,173.11 84.95,195.48L63.82,183.27C65.53,172.57 64.5,161.61 60.84,151.41L11.58,179.86L0,173.21V159.83L49.26,131.39C42.26,123.12 33.28,116.76 23.16,112.88V88.55C45.47,94.64 64.47,109.29 76.04,129.31Z"
android:fillColor="#000000"/>
<path
android:pathData="M124.04,129.33C135.6,109.31 154.59,94.65 176.88,88.55L176.84,112.88C166.72,116.76 157.74,123.12 150.74,131.39L200,159.83V173.17L188.42,179.86L139.21,151.45C135.55,161.65 134.52,172.61 136.23,183.31L115.15,195.48C109.29,173.12 112.48,149.35 124.04,129.33Z"
android:fillColor="#000000"/>
</vector>

View file

@ -0,0 +1,27 @@
package io.homeassistant.companion.android.matter
import android.content.Context
import android.content.IntentSender
import javax.inject.Inject
class MatterManagerImpl @Inject constructor() : MatterManager {
// Matter support currently depends on Google Play Services,
// and as a result Matter is not supported with the minimal flavor
override fun appSupportsCommissioning(): Boolean = false
override suspend fun coreSupportsCommissioning(): Boolean = false
override fun startNewCommissioningFlow(
context: Context,
onSuccess: (IntentSender) -> Unit,
onFailure: (Exception) -> Unit
) {
onFailure(IllegalStateException("Matter commissioning is not supported with the minimal flavor"))
}
override suspend fun commissionDevice(code: String): Boolean = false
override suspend fun commissionOnNetworkDevice(pin: Long): Boolean = false
}

View file

@ -34,4 +34,6 @@ interface WebSocketRepository {
suspend fun getTemplateUpdates(template: String): Flow<TemplateUpdatedEvent>?
suspend fun getNotifications(): Flow<Map<String, Any>>?
suspend fun ackNotification(confirmId: String): Boolean
suspend fun commissionMatterDevice(code: String): Boolean
suspend fun commissionMatterDeviceOnNetwork(pin: Long): Boolean
}

View file

@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.SharedFlow
* A class that holds information about messages that are currently active (sent and no response
* received, or sent for a subscription) on the websocket connection.
* @param message Map that holds the websocket message contents
* @param timeout timeout in milliseconds for receiving a response to the message
* @param eventFlow Flow (using callbackFlow) that will emit events for a subscription, else `null`
* @param eventTimeout timeout in milliseconds for ending the subscription when the flow is no
* longer collected
@ -18,6 +19,7 @@ import kotlinx.coroutines.flow.SharedFlow
*/
data class WebSocketRequest(
val message: Map<*, *>,
val timeout: Long = 30000L,
val eventFlow: SharedFlow<Any>? = null,
val eventTimeout: Long = 0L,
val onEvent: Channel<Any>? = null,

View file

@ -317,6 +317,42 @@ class WebSocketRepositoryImpl @Inject constructor(
return response?.success == true
}
/**
* Request the server to add a Matter device to the network and commission it
* @return `true` if the request was successful
*/
override suspend fun commissionMatterDevice(code: String): Boolean {
val response = sendMessage(
WebSocketRequest(
message = mapOf(
"type" to "matter/commission",
"code" to code
),
timeout = 120000L // Matter commissioning takes at least 60 seconds + interview
)
)
return response?.success == true
}
/**
* Request the server to commission a Matter device that is already on the network
* @return `true` if the request was successful
*/
override suspend fun commissionMatterDeviceOnNetwork(pin: Long): Boolean {
val response = sendMessage(
WebSocketRequest(
message = mapOf(
"type" to "matter/commission_on_network",
"pin" to pin
),
timeout = 120000L // Matter commissioning takes at least 60 seconds + interview
)
)
return response?.success == true
}
private suspend fun connect(): Boolean {
connectedMutex.withLock {
if (connection != null && connected.isCompleted) {
@ -388,7 +424,7 @@ class WebSocketRepositoryImpl @Inject constructor(
private suspend fun sendMessage(request: WebSocketRequest): SocketResponse? {
return if (connect()) {
withTimeoutOrNull(30000) {
withTimeoutOrNull(request.timeout) {
try {
suspendCancellableCoroutine { cont ->
// Lock on the connection so that we fully send before allowing another send.

View file

@ -3,6 +3,7 @@
<string name="account">Account</string>
<string name="action_reply">Reply</string>
<string name="activity_intent_error">Unable to send activity intent, please check command format</string>
<string name="add_device">Add device</string>
<string name="add_service_data_field">Add Field</string>
<string name="add_shortcut">Add Shortcut</string>
<string name="add_ssid">Add</string>
@ -106,6 +107,7 @@
<string name="choose_entity">Choose entity</string>
<string name="choose_server">Choose server</string>
<string name="clear_favorites">Clear Favorites</string>
<string name="close">Close</string>
<string name="color_temp">Color temperature: %1$d</string>
<string name="collapse">Collapse</string>
<string name="complication_entity_invalid">Invalid entity</string>
@ -337,6 +339,13 @@
<string name="manual_setup">Enter address manually</string>
<string name="manual_title">What is your Home Assistant address?</string>
<string name="map">Map</string>
<string name="matter_shared_title">Add shared Matter device</string>
<string name="matter_shared_status_not_registered">Please connect to your Home Assistant server before sharing a Matter device.</string>
<string name="matter_shared_status_not_supported">To add a shared Matter device, please update your Home Assistant server and add Matter first.</string>
<string name="matter_shared_status_confirmation">Do you want to add this Matter device to Home Assistant?</string>
<string name="matter_shared_status_working">Adding device, this may take a minute or two.</string>
<string name="matter_shared_status_success">The device was added! 🎉</string>
<string name="matter_shared_status_failure">Unable to add the device to Home Assistant. Would you like to try again?</string>
<string name="maximum">Maximum</string>
<string name="media_player">Media player</string>
<string name="media_player_widget_desc">Control any media player and see current now playing image</string>