mirror of
https://github.com/home-assistant/android
synced 2024-10-15 12:32:54 +00:00
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:
parent
312a36e898
commit
69d5949f14
|
@ -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")
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 = { }
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package io.homeassistant.companion.android.matter
|
||||
|
||||
enum class MatterFrontendCommissioningStatus {
|
||||
NOT_STARTED,
|
||||
REQUESTED,
|
||||
IN_PROGRESS,
|
||||
ERROR
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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?
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
15
app/src/main/res/drawable/ic_matter.xml
Normal file
15
app/src/main/res/drawable/ic_matter.xml
Normal 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>
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in a new issue