Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,18 @@ import androidx.lifecycle.lifecycleScope
import com.google.android.gms.home.matter.Matter
import com.google.android.gms.home.matter.commissioning.SharedDeviceData
import dagger.hilt.android.AndroidEntryPoint
import io.homeassistant.companion.android.common.data.servers.ServerManager
import io.homeassistant.companion.android.database.server.Server
import io.homeassistant.companion.android.matter.views.MatterCommissioningView
import io.homeassistant.companion.android.util.compose.HomeAssistantAppTheme
import io.homeassistant.companion.android.util.enableEdgeToEdgeCompat
import io.homeassistant.companion.android.webview.WebViewActivity
import javax.inject.Inject
import kotlinx.coroutines.launch
import timber.log.Timber

@AndroidEntryPoint
class MatterCommissioningActivity : AppCompatActivity() {

@Inject
lateinit var serverManager: ServerManager

private val viewModel: MatterCommissioningViewModel by viewModels()
private var deviceCode: String? = null
private var deviceName by mutableStateOf<String?>(null)
private var servers by mutableStateOf<List<Server>>(emptyList())
private var newMatterDevice = false

private val threadPermissionLauncher =
Expand All @@ -50,15 +42,14 @@ class MatterCommissioningActivity : AppCompatActivity() {
MatterCommissioningView(
step = viewModel.step,
deviceName = deviceName,
servers = servers,
servers = viewModel.servers,
onSelectServer = viewModel::checkSupport,
onConfirmCommissioning = { startCommissioning() },
onClose = { finish() },
onContinue = { continueToApp(false) },
)
}
}
servers = serverManager.defaultServers
}

override fun onResume() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import io.homeassistant.companion.android.common.data.servers.ServerManager
import io.homeassistant.companion.android.database.server.Server
import io.homeassistant.companion.android.thread.ThreadManager
import javax.inject.Inject
import kotlinx.coroutines.launch
Expand Down Expand Up @@ -41,6 +42,9 @@ class MatterCommissioningViewModel @Inject constructor(
var serverId by mutableIntStateOf(0)
private set

var servers by mutableStateOf<List<Server>>(emptyList())
private set

fun checkSetup(isNewDevice: Boolean) {
viewModelScope.launch {
if (!isNewDevice && step != CommissioningFlowStep.NotStarted) {
Expand All @@ -53,8 +57,8 @@ class MatterCommissioningViewModel @Inject constructor(
step = CommissioningFlowStep.NotRegistered
return@launch
}

if (serverManager.defaultServers.size > 1) {
servers = serverManager.servers()
if (servers.size > 1) {
step = CommissioningFlowStep.SelectServer
} else {
serverManager.getServer()?.id?.let {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ class FirebaseCloudMessagingService : FirebaseMessagingService() {
Timber.d("Not trying to update registration since we aren't authenticated.")
return@launch
}
serverManager.defaultServers.forEach {
serverManager.servers().forEach {
launch {
try {
serverManager.integrationRepository(it.id).updateRegistration(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,7 @@ class LocationSensorManager :
lastHighAccuracyMode = highAccuracyModeEnabled
lastHighAccuracyUpdateInterval = updateIntervalHighAccuracySeconds

serverManager(latestContext).defaultServers.forEach {
serverManager(latestContext).servers().forEach {
getSendLocationAsSetting(it.id) // Sets up the setting, value isn't used right now
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package io.homeassistant.companion.android.settings.wear

import io.homeassistant.companion.android.common.data.authentication.AuthorizationException
import io.homeassistant.companion.android.common.data.authentication.impl.AuthenticationService
import io.homeassistant.companion.android.common.data.authentication.impl.AuthenticationService.Companion.SEGMENT_AUTH_TOKEN
import io.homeassistant.companion.android.common.data.integration.Entity
import io.homeassistant.companion.android.common.data.integration.IntegrationException
import io.homeassistant.companion.android.common.data.integration.impl.IntegrationService
import io.homeassistant.companion.android.common.data.integration.impl.entities.RenderTemplateIntegrationRequest
import io.homeassistant.companion.android.common.data.integration.impl.entities.Template
import io.homeassistant.companion.android.common.data.servers.tryOnUrls
import io.homeassistant.companion.android.common.util.FailFast
import javax.inject.Inject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import timber.log.Timber

/**
* A lightweight server representation used exclusively for Wear OS onboarding operations.
*
* This class exists to avoid persisting a server in the app's database when onboarding a Wear
* device to a Home Assistant instance that may not be registered on the phone. By keeping this
* as a transient data structure, the server management logic remains simpler and doesn't need
* to handle temporary or Wear-only servers.
*
* The tradeoff is some code duplication that already exists in
* the integration layer, but this is acceptable to maintain clear separation of concerns.
*/
data class WearServer(
val serverId: Int,
val externalUrl: String,
val cloudUrl: String?,
val webhookId: String,
val cloudhookUrl: String?,
val accessToken: String?,
) {
/**
* Returns available base URLs for API calls, prioritizing cloud URL over external URL.
*/
fun getBaseUrls(): List<HttpUrl> = buildList {
cloudUrl?.toHttpUrlOrNull()?.let(::add)
externalUrl.toHttpUrlOrNull()?.let(::add)
}

/**
* Returns available webhook URLs, prioritizing cloudhook URL over the external webhook path.
*/
fun getWebhookUrls(): List<HttpUrl> = buildList {
cloudhookUrl?.toHttpUrlOrNull()?.let(::add)
externalUrl.toHttpUrlOrNull()?.newBuilder()
?.addPathSegments("api/webhook/$webhookId")?.build()?.let(::add)
}
}

/**
* Handles Home Assistant server operations specifically for Wear OS device onboarding.
*
* This repository is communicating directly with the Home Assistant server using [WearServer]
* credentials without requiring a persisted server in the app's database.
*/
class SettingsWearRepository @Inject constructor(
private val authenticationService: AuthenticationService,
private val integrationService: IntegrationService,
) {

/**
* Exchanges a refresh token for a new access token and returns an updated [WearServer].
*
* @param server The server configuration containing the URLs to try.
* @param refreshToken The OAuth refresh token to exchange.
* @return A copy of [server] with the new access token populated.
* @throws AuthorizationException If the token response is empty.
* @throws IntegrationException If the refresh request fails.
*/
suspend fun registerRefreshToken(server: WearServer, refreshToken: String): WearServer {
return tryOnUrls(server.getBaseUrls(), "refresh_token") {
val response = authenticationService.refreshToken(
it.newBuilder().addPathSegments(SEGMENT_AUTH_TOKEN).build(),
AuthenticationService.GRANT_TYPE_REFRESH,
refreshToken,
AuthenticationService.CLIENT_ID,
)
if (response.isSuccessful) {
val refreshedToken = response.body() ?: throw AuthorizationException()
server.copy(accessToken = refreshedToken.accessToken)
} else {
throw IntegrationException(
"Error calling refresh token",
response.code(),
response.errorBody(),
)
}
}
}

/**
* Renders a Home Assistant template string on the server.
*
* @param wearServer The server configuration containing the webhook URLs.
* @param template The Jinja2 template string to render.
* @return The rendered template result as a string, or `null` if the result is null.
*/
suspend fun renderTemplate(wearServer: WearServer, template: String): String? {
val templateResult = tryOnUrls(
wearServer.getWebhookUrls(),
"render_template",
) { url ->
integrationService.getTemplate(
url,
RenderTemplateIntegrationRequest(
mapOf("template" to Template(template, emptyMap())),
),
)["template"]
}
// We check if the result is a JsonPrimitive instead of a simple global toString to avoid rendering " around the string
return if (templateResult is JsonPrimitive) templateResult.contentOrNull else templateResult.toString()
}

/**
* Fetches all entities from the Home Assistant server.
*
* Requires [WearServer.accessToken] to be set. Call [registerRefreshToken] first
* to obtain an access token.
*
* @param wearServer The server configuration with a valid access token.
* @return A list of all entities, or an empty list if the request fails or no access token is set.
*/
suspend fun getEntities(wearServer: WearServer): List<Entity> {
if (wearServer.accessToken == null) {
FailFast.fail { "Missing access token, you should invoke registerRefreshToken first" }
return emptyList()
}

return try {
tryOnUrls(wearServer.getBaseUrls(), "get_entities") { url ->
integrationService.getStates(
url.newBuilder().addPathSegments("api/states").build(),
"Bearer ${wearServer.accessToken}",
)
}.map {
Entity(
it.entityId,
it.state,
it.attributes,
it.lastChanged,
it.lastUpdated,
)
}
} catch (e: IntegrationException) {
Timber.e(e, "Fail to get entities")
emptyList()
}
}
}
Loading