Improve settings when using Home Assistant Cloud (#2965)

* Save cloud URL separately and add pref

 - Save the cloud / remote UI url that is received during registration separately, and add a preference to use the cloud url instead of the external url
 - Save the cloud / remote UI url and cloudhook url received during webhook config updates, to be able to support using cloud after the app was set up
 - Clean up cloudhook url when logging out

* Add UI for "Use Home Assistant Cloud" url

* Cleanup

* Fix function order
This commit is contained in:
Joris Pelgröm 2022-10-19 20:53:01 +02:00 committed by GitHub
parent 3e72f2719d
commit acd8d2d660
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 431 additions and 15 deletions

View file

@ -44,6 +44,7 @@ import io.homeassistant.companion.android.settings.sensor.SensorSettingsFragment
import io.homeassistant.companion.android.settings.sensor.SensorUpdateFrequencyFragment
import io.homeassistant.companion.android.settings.shortcuts.ManageShortcutsSettingsFragment
import io.homeassistant.companion.android.settings.ssid.SsidFragment
import io.homeassistant.companion.android.settings.url.ExternalUrlFragment
import io.homeassistant.companion.android.settings.wear.SettingsWearActivity
import io.homeassistant.companion.android.settings.wear.SettingsWearDetection
import io.homeassistant.companion.android.settings.websocket.WebsocketSettingFragment
@ -152,8 +153,14 @@ class SettingsFragment constructor(
onChangeUrlValidator
}
findPreference<EditTextPreference>("connection_external")?.onPreferenceChangeListener =
onChangeUrlValidator
findPreference<Preference>("connection_external")?.setOnPreferenceClickListener {
parentFragmentManager
.beginTransaction()
.replace(R.id.content, ExternalUrlFragment::class.java, null)
.addToBackStack(getString(commonR.string.input_url))
.commit()
return@setOnPreferenceClickListener true
}
findPreference<Preference>("connection_internal_ssids")?.let {
it.setOnPreferenceClickListener {
@ -420,6 +427,14 @@ class SettingsFragment constructor(
}
}
override fun updateExternalUrl(url: String, useCloud: Boolean) {
findPreference<Preference>("connection_external")?.let {
it.summary =
if (useCloud) getString(commonR.string.input_cloud)
else url
}
}
override fun updateSsids(ssids: Set<String>) {
findPreference<Preference>("connection_internal_ssids")?.let {
it.summary =
@ -628,6 +643,7 @@ class SettingsFragment constructor(
super.onResume()
activity?.title = getString(commonR.string.companion_app)
presenter.updateExternalUrlStatus()
presenter.updateInternalUrlStatus()
}
}

View file

@ -9,6 +9,7 @@ interface SettingsPresenter {
fun getPreferenceDataStore(): PreferenceDataStore
fun onCreate()
fun onFinish()
fun updateExternalUrlStatus()
fun updateInternalUrlStatus()
fun isLockEnabled(): Boolean
fun sessionTimeOut(): Int

View file

@ -77,8 +77,7 @@ class SettingsPresenterImpl @Inject constructor(
override fun getString(key: String, defValue: String?): String? {
return runBlocking {
when (key) {
"connection_internal" -> (urlUseCase.getUrl(true) ?: "").toString()
"connection_external" -> (urlUseCase.getUrl(false) ?: "").toString()
"connection_internal" -> (urlUseCase.getUrl(isInternal = true, force = true) ?: "").toString()
"registration_name" -> integrationUseCase.getRegistration().deviceName
"session_timeout" -> integrationUseCase.getSessionTimeOut().toString()
"themes" -> themesManager.getCurrentTheme()
@ -92,7 +91,6 @@ class SettingsPresenterImpl @Inject constructor(
mainScope.launch {
when (key) {
"connection_internal" -> urlUseCase.saveUrl(value ?: "", true)
"connection_external" -> urlUseCase.saveUrl(value ?: "", false)
"session_timeout" -> {
try {
integrationUseCase.sessionTimeOut(value.toString().toInt())
@ -142,6 +140,7 @@ class SettingsPresenterImpl @Inject constructor(
override fun onCreate() {
mainScope.launch {
handleInternalUrlStatus(urlUseCase.getHomeWifiSsids())
updateExternalUrlStatus()
}
}
@ -149,6 +148,15 @@ class SettingsPresenterImpl @Inject constructor(
mainScope.cancel()
}
override fun updateExternalUrlStatus() {
mainScope.launch {
settingsView.updateExternalUrl(
urlUseCase.getUrl(false)?.toString() ?: "",
urlUseCase.shouldUseCloud() && urlUseCase.canUseCloud()
)
}
}
override fun updateInternalUrlStatus() {
mainScope.launch {
handleInternalUrlStatus(urlUseCase.getHomeWifiSsids())

View file

@ -6,6 +6,8 @@ interface SettingsView {
fun enableInternalConnection()
fun updateExternalUrl(url: String, useCloud: Boolean)
fun updateSsids(ssids: Set<String>)
fun onLangSettingsChanged()

View file

@ -0,0 +1,49 @@
package io.homeassistant.companion.android.settings.url
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.ui.platform.ComposeView
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import com.google.android.material.composethemeadapter.MdcTheme
import dagger.hilt.android.AndroidEntryPoint
import io.homeassistant.companion.android.settings.url.views.ExternalUrlView
import io.homeassistant.companion.android.common.R as commonR
@AndroidEntryPoint
class ExternalUrlFragment : Fragment() {
val viewModel by viewModels<ExternalUrlViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(false)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return ComposeView(requireContext()).apply {
setContent {
MdcTheme {
ExternalUrlView(
canUseCloud = viewModel.canUseCloud,
useCloud = viewModel.useCloud,
externalUrl = viewModel.externalUrl,
onUseCloudToggle = { viewModel.toggleCloud(it) },
onExternalUrlSaved = { viewModel.updateExternalUrl(it) }
)
}
}
}
}
override fun onResume() {
super.onResume()
activity?.title = getString(commonR.string.input_url)
}
}

View file

@ -0,0 +1,55 @@
package io.homeassistant.companion.android.settings.url
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.url.UrlRepository
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class ExternalUrlViewModel @Inject constructor(
private val urlRepository: UrlRepository,
application: Application
) : AndroidViewModel(application) {
var canUseCloud by mutableStateOf(false)
private set
var useCloud by mutableStateOf(false)
private set
var externalUrl by mutableStateOf("")
private set
init {
viewModelScope.launch {
canUseCloud = urlRepository.canUseCloud()
useCloud = urlRepository.shouldUseCloud()
externalUrl = urlRepository.getUrl(isInternal = false, force = true).toString()
}
}
fun toggleCloud(use: Boolean) {
viewModelScope.launch {
useCloud = if (use && canUseCloud) {
urlRepository.setUseCloud(true)
true
} else {
urlRepository.setUseCloud(false)
false
}
}
}
fun updateExternalUrl(url: String) {
viewModelScope.launch {
urlRepository.saveUrl(url, false)
externalUrl = urlRepository.getUrl(isInternal = false, force = true)?.toString() ?: ""
}
}
}

View file

@ -0,0 +1,127 @@
package io.homeassistant.companion.android.settings.url.views
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Error
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import io.homeassistant.companion.android.common.R as commonR
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun ExternalUrlInputView(
url: String?,
focusRequester: FocusRequester,
onSaveUrl: (String) -> Unit
) {
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
var urlInput by remember(url) { mutableStateOf(url) }
var urlError by remember { mutableStateOf(false) }
Column(
modifier = Modifier.padding(horizontal = 16.dp)
) {
OutlinedTextField(
value = urlInput ?: "",
singleLine = true,
onValueChange = {
urlInput = it
urlError = false
},
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(
onDone = {
urlError = !performUrlUpdate(urlInput?.trim(), url, onSaveUrl)
if (!urlError) {
keyboardController?.hide()
focusManager.clearFocus()
}
}
),
placeholder = { Text(stringResource(commonR.string.input_url)) },
isError = urlError,
trailingIcon = if (urlError) {
{
Icon(
imageVector = Icons.Default.Error,
contentDescription = stringResource(commonR.string.url_invalid)
)
}
} else null,
modifier = Modifier
.focusRequester(focusRequester)
.fillMaxWidth()
.padding(bottom = 8.dp)
)
if (urlError) {
Text(
text = stringResource(commonR.string.url_parse_error),
color = MaterialTheme.colors.error,
style = MaterialTheme.typography.caption,
modifier = Modifier.padding(start = 16.dp)
)
}
if (urlInput != url && urlInput?.trim()?.toHttpUrlOrNull()?.toString() != url) {
TextButton(
modifier = Modifier.align(Alignment.End),
onClick = {
urlError = !performUrlUpdate(urlInput?.trim(), url, onSaveUrl)
if (!urlError) {
keyboardController?.hide()
focusManager.clearFocus()
}
}
) {
Text(stringResource(commonR.string.update))
}
}
}
}
/**
* Try saving the url with the value of the input.
* @return boolean indicating if the url was saved successfully
*/
private fun performUrlUpdate(
input: String?,
current: String?,
onSaveUrl: (String) -> Unit
): Boolean {
return if (input != current && input?.toHttpUrlOrNull()?.toString() != current) {
val urlValue = input?.toHttpUrlOrNull()
val isValid = urlValue != null
if (isValid) {
onSaveUrl(urlValue.toString())
}
isValid
} else {
true
}
}

View file

@ -0,0 +1,120 @@
package io.homeassistant.companion.android.settings.url.views
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Switch
import androidx.compose.material.SwitchDefaults
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.homeassistant.companion.android.common.R as commonR
@Composable
fun ExternalUrlView(
canUseCloud: Boolean,
useCloud: Boolean,
externalUrl: String?,
onUseCloudToggle: (Boolean) -> Unit,
onExternalUrlSaved: (String) -> Unit
) {
val focusRequester = remember { FocusRequester() }
Column(
modifier = Modifier.padding(vertical = 16.dp)
) {
if (canUseCloud) {
ExternalUrlCloudView(
useCloud = useCloud,
onUseCloudToggle = onUseCloudToggle
)
Spacer(modifier = Modifier.height(24.dp))
}
if (!canUseCloud || !useCloud) {
ExternalUrlInputView(
url = externalUrl,
focusRequester = focusRequester,
onSaveUrl = onExternalUrlSaved
)
}
}
LaunchedEffect(Unit) {
if (!canUseCloud) focusRequester.requestFocus()
}
}
@Composable
fun ExternalUrlCloudView(
useCloud: Boolean,
onUseCloudToggle: (Boolean) -> Unit
) {
Row(
modifier = Modifier
.clickable { onUseCloudToggle(!useCloud) }
.heightIn(min = 56.dp)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(commonR.string.input_cloud),
modifier = Modifier
.weight(1f)
.padding(end = 16.dp)
)
Switch(
checked = useCloud,
onCheckedChange = null,
colors = SwitchDefaults.colors(uncheckedThumbColor = colorResource(commonR.color.colorSwitchUncheckedThumb))
)
}
}
@Preview
@Composable
fun PreviewExternalUrlViewCloudOn() {
ExternalUrlView(
canUseCloud = true,
useCloud = true,
externalUrl = "https://home.example.com:8123/",
onUseCloudToggle = {},
onExternalUrlSaved = {}
)
}
@Preview
@Composable
fun PreviewExternalUrlViewCloudOff() {
ExternalUrlView(
canUseCloud = true,
useCloud = false,
externalUrl = "https://home.example.com:8123/",
onUseCloudToggle = {},
onExternalUrlSaved = {}
)
}
@Preview
@Composable
fun PreviewExternalUrlViewCloudNone() {
ExternalUrlView(
canUseCloud = false,
useCloud = false,
externalUrl = "https://home.example.com:8123/",
onUseCloudToggle = {},
onExternalUrlSaved = {}
)
}

View file

@ -129,6 +129,7 @@ class WebViewPresenterImpl @Inject constructor(
mainScope.launch {
urlUseCase.saveUrl("", true)
urlUseCase.saveUrl("", false)
urlUseCase.updateCloudUrls(null, null)
}
}

View file

@ -4,11 +4,10 @@
xmlns:app="http://schemas.android.com/apk/res-auto">
<PreferenceCategory
android:title="@string/pref_connection_title">
<EditTextPreference
<Preference
android:key="connection_external"
android:icon="@drawable/ic_globe"
android:title="@string/pref_connection_url"
app:useSimpleSummaryProvider="true"/>
android:title="@string/pref_connection_url"/>
<Preference
android:key="connection_internal_ssids"
android:icon="@drawable/ic_wifi"

View file

@ -77,6 +77,7 @@ class AuthenticationRepositoryImpl @Inject constructor(
saveSession(null)
urlRepository.saveUrl("", true)
urlRepository.saveUrl("", false)
urlRepository.updateCloudUrls(null, null)
urlRepository.saveHomeWifiSsids(emptySet())
}

View file

@ -540,6 +540,7 @@ class IntegrationRepositoryImpl @Inject constructor(
PREF_CHECK_SENSOR_REGISTRATION_NEXT,
System.currentTimeMillis() + TimeUnit.HOURS.toMillis(4)
)
urlRepository.updateCloudUrls(response.cloudhookUrl, response.remoteUiUrl)
return response
}
}

View file

@ -15,10 +15,18 @@ interface UrlRepository {
suspend fun saveRegistrationUrls(cloudHookUrl: String?, remoteUiUrl: String?, webhookId: String)
suspend fun getUrl(isInternal: Boolean? = null): URL?
suspend fun updateCloudUrls(cloudhookUrl: String?, remoteUiUrl: String?)
suspend fun getUrl(isInternal: Boolean? = null, force: Boolean = false): URL?
suspend fun saveUrl(url: String, isInternal: Boolean? = null)
suspend fun canUseCloud(): Boolean
suspend fun shouldUseCloud(): Boolean
suspend fun setUseCloud(use: Boolean)
suspend fun getHomeWifiSsids(): Set<String>
suspend fun saveHomeWifiSsids(ssid: Set<String>)

View file

@ -20,9 +20,11 @@ class UrlRepositoryImpl @Inject constructor(
companion object {
private const val PREF_CLOUDHOOK_URL = "cloudhook_url"
private const val PREF_CLOUD_UI_URL = "remote_ui_url"
private const val PREF_REMOTE_URL = "remote_url"
private const val PREF_WEBHOOK_ID = "webhook_id"
private const val PREF_LOCAL_URL = "local_url"
private const val PREF_USE_CLOUD = "use_cloud"
private const val PREF_WIFI_SSIDS = "wifi_ssids"
private const val PREF_PRIORITIZE_INTERNAL = "prioritize_internal"
private const val TAG = "UrlRepository"
@ -76,18 +78,29 @@ class UrlRepositoryImpl @Inject constructor(
) {
localStorage.putString(PREF_CLOUDHOOK_URL, cloudHookUrl)
localStorage.putString(PREF_WEBHOOK_ID, webhookId)
remoteUiUrl?.let {
localStorage.putString(PREF_REMOTE_URL, it)
}
localStorage.putString(PREF_CLOUD_UI_URL, remoteUiUrl)
localStorage.putBoolean(PREF_USE_CLOUD, remoteUiUrl != null)
}
override suspend fun getUrl(isInternal: Boolean?): URL? {
override suspend fun updateCloudUrls(
cloudhookUrl: String?,
remoteUiUrl: String?
) {
localStorage.putString(PREF_CLOUDHOOK_URL, cloudhookUrl)
localStorage.putString(PREF_CLOUD_UI_URL, remoteUiUrl)
}
override suspend fun getUrl(isInternal: Boolean?, force: Boolean): URL? {
val internal = localStorage.getString(PREF_LOCAL_URL)?.toHttpUrlOrNull()?.toUrl()
val external = localStorage.getString(PREF_REMOTE_URL)?.toHttpUrlOrNull()?.toUrl()
val cloud = localStorage.getString(PREF_CLOUD_UI_URL)?.toHttpUrlOrNull()?.toUrl()
return if (isInternal ?: isInternal() && internal != null) {
return if (isInternal ?: isInternal() && (internal != null || force)) {
Log.d(TAG, "Using internal URL")
internal
} else if (!force && shouldUseCloud() && cloud != null) {
Log.d(TAG, "Using cloud / remote UI URL")
cloud
} else {
Log.d(TAG, "Using external URL")
external
@ -110,6 +123,18 @@ class UrlRepositoryImpl @Inject constructor(
localStorage.putString(if (isInternal ?: isInternal()) PREF_LOCAL_URL else PREF_REMOTE_URL, trimUrl)
}
override suspend fun canUseCloud(): Boolean {
return !localStorage.getString(PREF_CLOUD_UI_URL).isNullOrBlank()
}
override suspend fun shouldUseCloud(): Boolean {
return localStorage.getBoolean(PREF_USE_CLOUD)
}
override suspend fun setUseCloud(use: Boolean) {
localStorage.putBoolean(PREF_USE_CLOUD, use)
}
override suspend fun getHomeWifiSsids(): Set<String> {
return localStorage.getStringSet(PREF_WIFI_SSIDS) ?: emptySet()
}

View file

@ -12,5 +12,7 @@ data class GetConfigResponse(
val timeZone: String,
val components: List<String>,
val version: String,
var cloudhookUrl: String?, // only when using webhook
var remoteUiUrl: String?, // only when using webhook
val entities: Map<String, Map<String, Any>>? // only on core >= 2022.6 when using webhook
)

View file

@ -242,6 +242,7 @@
<string name="icon">Icon</string>
<string name="input_booleans">Input Booleans</string>
<string name="input_buttons">Input Buttons</string>
<string name="input_cloud">Use Home Assistant Cloud</string>
<string name="input_url_hint">https://example.duckdns.org:8123</string>
<string name="input_url">Home Assistant URL</string>
<string name="install_app">Install App on Wear Device</string>
@ -758,7 +759,7 @@
<string name="update_widget">Update Widget</string>
<string name="updating_sensors">Updating Sensors</string>
<string name="url_invalid">Url Invalid</string>
<string name="url_parse_error">Unable to parse your Home Assistant URL. It should look like https://example.com</string>
<string name="url_parse_error">Unable to parse your URL. It should look like this: https://example.com.</string>
<string name="username">Username</string>
<string name="version">Version: %s</string>
<string name="view_password">View Password</string>