diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a586617bf..8a8d0c9bff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## Unreleased + +## Added + +- Home Assistant service actions with a searchable entity picker, shared encrypted connection settings, and connection verification. + +## Fixed + +- Android TV remote navigation in Home Assistant forms: focus fields before editing and reach Done after selecting a service. + ## [4.6.1](https://github.com/sds100/KeyMapper/releases/tag/v4.6.1) #### 10 October 2026 diff --git a/base/build.gradle.kts b/base/build.gradle.kts index bd8e2dbfe9..147feccb27 100644 --- a/base/build.gradle.kts +++ b/base/build.gradle.kts @@ -150,6 +150,8 @@ dependencies { // Tests + testImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.test.manifest) testImplementation(libs.junit) testImplementation(libs.hamcrest.all) testImplementation(libs.androidx.junit.ktx) // androidx.test.ext:junit-ktx @@ -164,6 +166,7 @@ dependencies { testDebugImplementation(libs.androidx.fragment.testing) // Dependencies for Android instrumented tests + androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.androidx.test.ext.junit) // androidx.test.ext:junit androidTestImplementation(libs.junit) // Repeated, fine androidTestImplementation(libs.androidx.navigation.testing) diff --git a/base/src/androidTest/java/io/github/sds100/keymapper/base/actions/HomeAssistantCredentialCipherTest.kt b/base/src/androidTest/java/io/github/sds100/keymapper/base/actions/HomeAssistantCredentialCipherTest.kt new file mode 100644 index 0000000000..7a329ed843 --- /dev/null +++ b/base/src/androidTest/java/io/github/sds100/keymapper/base/actions/HomeAssistantCredentialCipherTest.kt @@ -0,0 +1,24 @@ +package io.github.sds100.keymapper.base.actions + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import java.security.KeyStore +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class HomeAssistantCredentialCipherTest { + @Test + fun connectionUsesNonExportableDeviceKeyAndFreshNonce() { + val cipher = HomeAssistantCredentialCipher() + val first = cipher.encrypt("test-token") + val second = cipher.encrypt("test-token") + assertNotEquals(first, second) + assertEquals("test-token", HomeAssistantCredentialCipher().decrypt(first)) + + val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + assertNull(store.getKey("keymapper_home_assistant_connection_v1", null).encoded) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt index 62138836d5..065a614de9 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt @@ -1064,6 +1064,24 @@ sealed class ActionData : Comparable { } } + @Serializable + data class HomeAssistantService( + val description: String, + val serverUrl: String, + val accessToken: String, + val domain: String, + val service: String, + val entityId: String, + val serviceData: String, + ) : ActionData() { + override val id: ActionId = ActionId.HOME_ASSISTANT_SERVICE + + override fun toString(): String { + // Do not leak sensitive server URL or access token info to logs. + return "HomeAssistantService(description=$description, entity=$entityId)" + } + } + @Serializable data class ShellCommand( val description: String, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt index 8acc6af0df..819cd5ec61 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt @@ -862,6 +862,43 @@ object ActionDataEntityMapper { ) } + ActionId.HOME_ASSISTANT_SERVICE -> { + val description = + entity.extras.getData(ActionEntity.EXTRA_HA_DESCRIPTION).valueOrNull() + ?: return null + + val serverUrl = + entity.extras.getData(ActionEntity.EXTRA_HA_SERVER_URL).valueOrNull() ?: "" + + val accessToken = + entity.extras.getData(ActionEntity.EXTRA_HA_ACCESS_TOKEN).valueOrNull() ?: "" + + val domain = + entity.extras.getData(ActionEntity.EXTRA_HA_DOMAIN).valueOrNull() + ?: return null + + val service = + entity.extras.getData(ActionEntity.EXTRA_HA_SERVICE).valueOrNull() + ?: return null + + val entityId = + entity.extras.getData(ActionEntity.EXTRA_HA_ENTITY_ID).valueOrNull() + ?: return null + + val serviceData = + entity.extras.getData(ActionEntity.EXTRA_HA_SERVICE_DATA).valueOrNull() ?: "" + + ActionData.HomeAssistantService( + description = description, + serverUrl = serverUrl, + accessToken = accessToken, + domain = domain, + service = service, + entityId = entityId, + serviceData = serviceData, + ) + } + ActionId.INTERACT_UI_ELEMENT -> { val packageName = entity.extras.getData(ActionEntity.EXTRA_ACCESSIBILITY_PACKAGE_NAME) @@ -1202,6 +1239,8 @@ object ActionDataEntityMapper { is ActionData.HttpRequest -> SYSTEM_ACTION_ID_MAP[data.id]!! + is ActionData.HomeAssistantService -> SYSTEM_ACTION_ID_MAP[data.id]!! + is ActionData.ControlMediaForApp.Rewind -> SYSTEM_ACTION_ID_MAP[data.id]!! is ActionData.ControlMediaForApp.Stop -> SYSTEM_ACTION_ID_MAP[data.id]!! @@ -1464,6 +1503,20 @@ object ActionDataEntityMapper { ), ) + is ActionData.HomeAssistantService -> buildList { + add(EntityExtra(ActionEntity.EXTRA_HA_DESCRIPTION, data.description)) + if (data.serverUrl.isNotBlank()) { + add(EntityExtra(ActionEntity.EXTRA_HA_SERVER_URL, data.serverUrl)) + } + if (data.accessToken.isNotBlank()) { + add(EntityExtra(ActionEntity.EXTRA_HA_ACCESS_TOKEN, data.accessToken)) + } + add(EntityExtra(ActionEntity.EXTRA_HA_DOMAIN, data.domain)) + add(EntityExtra(ActionEntity.EXTRA_HA_SERVICE, data.service)) + add(EntityExtra(ActionEntity.EXTRA_HA_ENTITY_ID, data.entityId)) + add(EntityExtra(ActionEntity.EXTRA_HA_SERVICE_DATA, data.serviceData)) + } + is ActionData.InteractUiElement -> buildList { add( EntityExtra( @@ -1785,6 +1838,7 @@ object ActionDataEntityMapper { ActionId.END_PHONE_CALL to "end_phone_call", ActionId.DEVICE_CONTROLS to "device_controls", ActionId.HTTP_REQUEST to "http_request", + ActionId.HOME_ASSISTANT_SERVICE to "home_assistant_service", ActionId.FORCE_STOP_APP to "force_stop_app", ActionId.CLEAR_RECENT_APP to "clear_recent_app", diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt index ffc99a0286..21978bd0fe 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt @@ -161,6 +161,7 @@ enum class ActionId { SEND_SMS, COMPOSE_SMS, DEVICE_CONTROLS, + HOME_ASSISTANT_SERVICE, FORCE_STOP_APP, CLEAR_RECENT_APP, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt index 06a257c296..186a70d8fd 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt @@ -733,6 +733,8 @@ class ActionUiHelper( is ActionData.HttpRequest -> action.description + is ActionData.HomeAssistantService -> action.description + is ActionData.ShellCommand -> when (action.executionMode) { ShellExecutionMode.ROOT -> getString( R.string.action_shell_command_description_with_root, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt index 347e2b35a2..13290b36fc 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt @@ -137,6 +137,7 @@ object ActionUtils { ActionId.URL -> ActionCategory.APPS ActionId.HTTP_REQUEST -> ActionCategory.APPS ActionId.SHELL_COMMAND -> ActionCategory.APPS + ActionId.HOME_ASSISTANT_SERVICE -> ActionCategory.CONNECTIVITY ActionId.TOGGLE_WIFI -> ActionCategory.CONNECTIVITY ActionId.ENABLE_WIFI -> ActionCategory.CONNECTIVITY ActionId.DISABLE_WIFI -> ActionCategory.CONNECTIVITY @@ -528,6 +529,8 @@ object ActionUtils { ActionId.HTTP_REQUEST -> R.string.action_http_request + ActionId.HOME_ASSISTANT_SERVICE -> R.string.action_home_assistant_service + ActionId.SHELL_COMMAND -> R.string.action_shell_command ActionId.INTERACT_UI_ELEMENT -> R.string.action_interact_ui_element_title @@ -995,6 +998,12 @@ object ActionUtils { return listOf(Permission.ACCESS_LOCAL_NETWORK) } + ActionId.HOME_ASSISTANT_SERVICE -> if (Build.VERSION.SDK_INT >= + Build.VERSION_CODES.CINNAMON_BUN + ) { + return listOf(Permission.ACCESS_LOCAL_NETWORK) + } + else -> return emptyList() } @@ -1133,6 +1142,7 @@ object ActionUtils { ActionId.END_PHONE_CALL -> Icons.Outlined.CallEnd ActionId.DEVICE_CONTROLS -> KeyMapperIcons.HomeIotDevice ActionId.HTTP_REQUEST -> Icons.Outlined.Http + ActionId.HOME_ASSISTANT_SERVICE -> Icons.Outlined.Home ActionId.SHELL_COMMAND -> Icons.Rounded.Terminal ActionId.INTERACT_UI_ELEMENT -> KeyMapperIcons.JumpToElement ActionId.FORCE_STOP_APP -> Icons.Outlined.Dangerous @@ -1190,6 +1200,7 @@ fun ActionData.isEditable(): Boolean = when (this) { is ActionData.SendSms, is ActionData.ComposeSms, is ActionData.HttpRequest, + is ActionData.HomeAssistantService, is ActionData.ShellCommand, is ActionData.CreateNotification, is ActionData.Toast, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionScreen.kt index be9fb86a90..71f5967470 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionScreen.kt @@ -55,6 +55,7 @@ fun HandleActionBottomSheets(delegate: CreateActionDelegate) { EnableFlashlightActionBottomSheet(delegate) ChangeFlashlightStrengthActionBottomSheet(delegate) HttpRequestBottomSheet(delegate) + HomeAssistantBottomSheet(delegate) SmsActionBottomSheet(delegate) VolumeActionBottomSheet(delegate) ModifySettingActionBottomSheet(delegate) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt index d757e3c508..8c5000f92e 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt @@ -68,6 +68,7 @@ class CreateActionDelegate( ) var httpRequestBottomSheetState: ActionData.HttpRequest? by mutableStateOf(null) + var homeAssistantBottomSheetState: ActionData.HomeAssistantService? by mutableStateOf(null) var smsActionBottomSheetState: SmsActionBottomSheetState? by mutableStateOf(null) var volumeActionState: VolumeActionBottomSheetState? by mutableStateOf(null) var talkBackGestureDialogState: TalkBackGestureDialogState? by mutableStateOf(null) @@ -131,6 +132,14 @@ class CreateActionDelegate( } } + val homeAssistantConnection get() = useCase.homeAssistantConnection + + fun saveHomeAssistantConnection(connection: HomeAssistantConnection) = + useCase.saveHomeAssistantConnection(connection) + + suspend fun fetchHomeAssistantEntities(serverUrl: String, accessToken: String) = + useCase.fetchHomeAssistantEntities(serverUrl, accessToken) + fun onDoneConfigEnableFlashlightClick() { enableFlashlightActionState?.also { state -> val flashInfo = state.lensData[state.selectedLens] ?: return @@ -1359,6 +1368,24 @@ class CreateActionDelegate( return null } + ActionId.HOME_ASSISTANT_SERVICE -> { + if (oldData == null) { + homeAssistantBottomSheetState = ActionData.HomeAssistantService( + description = "", + serverUrl = "http://", + accessToken = "", + domain = "", + service = "turn_on", + entityId = "", + serviceData = "", + ) + } else { + homeAssistantBottomSheetState = + oldData as? ActionData.HomeAssistantService + } + return null + } + ActionId.SHELL_COMMAND -> { val oldAction = oldData as? ActionData.ShellCommand diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionUseCase.kt index cb9a9dce1b..4e765c78fb 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionUseCase.kt @@ -9,6 +9,8 @@ import io.github.sds100.keymapper.system.camera.CameraFlashInfo import io.github.sds100.keymapper.system.camera.CameraLens import io.github.sds100.keymapper.system.inputmethod.ImeInfo import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter +import io.github.sds100.keymapper.system.network.HomeAssistantEntity +import io.github.sds100.keymapper.system.network.NetworkAdapter import io.github.sds100.keymapper.system.notifications.NotificationAdapter import io.github.sds100.keymapper.system.notifications.NotificationModel import io.github.sds100.keymapper.system.permissions.Permission @@ -30,12 +32,25 @@ class CreateActionUseCaseImpl @Inject constructor( private val phoneAdapter: PhoneAdapter, private val settingsAdapter: SettingsAdapter, private val notificationAdapter: NotificationAdapter, + private val networkAdapter: NetworkAdapter, + private val homeAssistantConfig: HomeAssistantConfigRepository, ) : CreateActionUseCase, IsActionSupportedUseCase by IsActionSupportedUseCaseImpl( systemFeatureAdapter, cameraAdapter, permissionAdapter, ) { + override val homeAssistantConnection = homeAssistantConfig.connection + + override fun saveHomeAssistantConnection(connection: HomeAssistantConnection) = + homeAssistantConfig.save(connection) + + override suspend fun fetchHomeAssistantEntities( + serverUrl: String, + accessToken: String, + ): KMResult> = + networkAdapter.fetchHomeAssistantEntities(serverUrl.trim().trimEnd('/'), accessToken.trim()) + override suspend fun getInputMethods(): List = inputMethodAdapter.inputMethods.first() override fun getFlashlightLenses(): Set { @@ -117,6 +132,14 @@ class CreateActionUseCaseImpl @Inject constructor( } interface CreateActionUseCase : IsActionSupportedUseCase { + val homeAssistantConnection: Flow + fun saveHomeAssistantConnection(connection: HomeAssistantConnection) + + suspend fun fetchHomeAssistantEntities( + serverUrl: String, + accessToken: String, + ): KMResult> + suspend fun getInputMethods(): List fun isFlashlightEnabled(): Flow diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/HomeAssistantBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/HomeAssistantBottomSheet.kt new file mode 100644 index 0000000000..14906adf2a --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/HomeAssistantBottomSheet.kt @@ -0,0 +1,675 @@ +package io.github.sds100.keymapper.base.actions + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.settings.HomeAssistantConnectionDialog +import io.github.sds100.keymapper.base.utils.ui.compose.KMBottomSheet +import io.github.sds100.keymapper.base.utils.ui.compose.RemoteOutlinedTextField +import io.github.sds100.keymapper.common.utils.KMResult +import io.github.sds100.keymapper.common.utils.Success +import io.github.sds100.keymapper.system.network.HomeAssistantEntity +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.update +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import timber.log.Timber + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeAssistantBottomSheet(delegate: CreateActionDelegate) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val savedConnection by delegate.homeAssistantConnection.collectAsStateWithLifecycle( + initialValue = null, + ) + var connectionSaveFailed by remember { mutableStateOf(false) } + var showConnectionSettings by remember { mutableStateOf(false) } + + if (showConnectionSettings) { + HomeAssistantConnectionDialog( + connection = savedConnection, + onVerify = { delegate.fetchHomeAssistantEntities(it.serverUrl, it.accessToken) }, + onSave = { + delegate.saveHomeAssistantConnection(it) + showConnectionSettings = false + }, + onDismiss = { showConnectionSettings = false }, + ) + } + + if (delegate.homeAssistantBottomSheetState != null) { + HomeAssistantBottomSheetContent( + sheetState = sheetState, + onDismissRequest = { + delegate.homeAssistantBottomSheetState = null + }, + state = delegate.homeAssistantBottomSheetState!!.let { action -> + savedConnection?.let { + action.copy(serverUrl = it.serverUrl, accessToken = it.accessToken) + } ?: action + }, + connectionConfigured = savedConnection != null, + connectionSaveFailed = connectionSaveFailed, + onConfigureConnection = { showConnectionSettings = true }, + fetchEntities = delegate::fetchHomeAssistantEntities, + onDescriptionChanged = { + delegate.homeAssistantBottomSheetState = + delegate.homeAssistantBottomSheetState?.copy(description = it) + }, + onServerUrlChanged = { + delegate.homeAssistantBottomSheetState = + delegate.homeAssistantBottomSheetState?.copy( + serverUrl = it, + entityId = "", + domain = "", + ) + }, + onAccessTokenChanged = { + delegate.homeAssistantBottomSheetState = + delegate.homeAssistantBottomSheetState?.copy( + accessToken = it, + entityId = "", + domain = "", + ) + }, + onEntityIdChanged = { + delegate.homeAssistantBottomSheetState = + delegate.homeAssistantBottomSheetState?.copy( + entityId = it, + domain = it.substringBefore("."), + ) + }, + onServiceChanged = { + delegate.homeAssistantBottomSheetState = + delegate.homeAssistantBottomSheetState?.copy(service = it) + }, + onServiceDataChanged = { + delegate.homeAssistantBottomSheetState = + delegate.homeAssistantBottomSheetState?.copy(serviceData = it) + }, + onDoneClick = { + val result = delegate.homeAssistantBottomSheetState + ?: return@HomeAssistantBottomSheetContent + if (savedConnection == null) { + val saved = runCatching { + delegate.saveHomeAssistantConnection( + HomeAssistantConnection(result.serverUrl, result.accessToken), + ) + } + if (saved.isFailure) { + connectionSaveFailed = true + return@HomeAssistantBottomSheetContent + } + } + delegate.homeAssistantBottomSheetState = null + delegate.actionResult.update { + result.copy( + serverUrl = "", + accessToken = "", + service = result.service.trim(), + ) + } + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun HomeAssistantBottomSheetContent( + sheetState: SheetState, + onDismissRequest: () -> Unit, + state: ActionData.HomeAssistantService, + connectionConfigured: Boolean = false, + connectionSaveFailed: Boolean = false, + onConfigureConnection: () -> Unit = {}, + fetchEntities: (suspend (String, String) -> KMResult>)? = null, + onDescriptionChanged: (String) -> Unit = {}, + onServerUrlChanged: (String) -> Unit = {}, + onAccessTokenChanged: (String) -> Unit = {}, + onEntityIdChanged: (String) -> Unit = {}, + onServiceChanged: (String) -> Unit = {}, + onServiceDataChanged: (String) -> Unit = {}, + onDoneClick: () -> Unit = {}, +) { + val descriptionFocus = remember { FocusRequester() } + val urlFocus = remember { FocusRequester() } + val tokenFocus = remember { FocusRequester() } + val serviceFocus = remember { FocusRequester() } + val customServiceFocus = remember { FocusRequester() } + val serviceDataFocus = remember { FocusRequester() } + val doneFocus = remember { FocusRequester() } + var restoreServiceFocus by remember { mutableStateOf(false) } + + var entities by remember { mutableStateOf?>(null) } + var isLoading by remember { mutableStateOf(false) } + var fetchError by remember { mutableStateOf(false) } + var refresh by remember { mutableStateOf(0) } + var search by rememberSaveable { mutableStateOf("") } + + LaunchedEffect(state.serverUrl, state.accessToken, refresh) { + entities = null + fetchError = false + isLoading = false + val fetch = fetchEntities ?: return@LaunchedEffect + if (state.serverUrl.trim().toHttpUrlOrNull() == null || state.accessToken.isBlank()) { + Timber.tag( + "HomeAssistant", + ).d("Entity fetch skipped: valid server URL and token required") + return@LaunchedEffect + } + isLoading = true + try { + Timber.tag("HomeAssistant").d("Entity picker: waiting for connection input to settle") + delay(600) + when (val result = fetch(state.serverUrl, state.accessToken)) { + is Success -> { + entities = result.value.sortedBy { it.friendlyName.lowercase() } + Timber.tag( + "HomeAssistant", + ).d("Entity picker: displaying %d entities", result.value.size) + } + else -> { + fetchError = true + Timber.tag( + "HomeAssistant", + ).e("Entity picker: fetch failed (%s)", result.javaClass.simpleName) + } + } + isLoading = false + } catch (e: CancellationException) { + Timber.tag( + "HomeAssistant", + ).d("Entity fetch cancelled: connection input changed or picker closed") + throw e + } + } + + var serviceDropdownExpanded by rememberSaveable { mutableStateOf(false) } + + val descriptionEmptyErrorString = + stringResource(R.string.action_ha_description_empty_error) + val serverUrlEmptyErrorString = + stringResource(R.string.action_ha_server_url_empty_error) + val malformedUrlErrorString = + stringResource(R.string.action_ha_malformed_url_error) + val tokenEmptyErrorString = + stringResource(R.string.action_ha_access_token_empty_error) + val entityEmptyErrorString = + stringResource(R.string.action_ha_entity_empty_error) + + var descriptionError: String? by rememberSaveable { mutableStateOf(null) } + var serverUrlError: String? by rememberSaveable { mutableStateOf(null) } + var tokenError: String? by rememberSaveable { mutableStateOf(null) } + var entityError: String? by rememberSaveable { mutableStateOf(null) } + var serviceError by rememberSaveable { mutableStateOf(false) } + + val services = listOf( + "turn_on" to stringResource(R.string.action_ha_turn_on), + "turn_off" to stringResource(R.string.action_ha_turn_off), + "toggle" to stringResource(R.string.action_ha_toggle), + ).let { presets -> + if (presets.any { it.first == state.service }) { + presets + } else { + presets + (state.service to state.service) + } + } + + KMBottomSheet( + title = stringResource(R.string.action_home_assistant_service), + negButtonText = stringResource(R.string.neg_cancel), + posButtonText = stringResource(R.string.pos_done), + onPosButtonClick = { + descriptionError = null + serverUrlError = null + tokenError = null + entityError = null + serviceError = state.service.isBlank() + + if (state.description.isBlank()) { + descriptionError = descriptionEmptyErrorString + } + + if (state.serverUrl.isBlank()) { + serverUrlError = serverUrlEmptyErrorString + } else if (state.serverUrl.trim().toHttpUrlOrNull() == null) { + serverUrlError = malformedUrlErrorString + } + + if (state.accessToken.isBlank()) { + tokenError = tokenEmptyErrorString + } + + if (state.entityId.isBlank()) { + entityError = entityEmptyErrorString + } + + if (descriptionError == null && + serverUrlError == null && + tokenError == null && + entityError == null && + state.service.isNotBlank() + ) { + onDoneClick() + } + }, + sheetState = sheetState, + onDismissRequest = onDismissRequest, + posButtonModifier = Modifier.focusRequester(doneFocus).focusProperties { + up = serviceDataFocus + previous = serviceDataFocus + }, + ) { + if (connectionSaveFailed) { + Text( + stringResource(R.string.home_assistant_save_error), + color = MaterialTheme.colorScheme.error, + ) + } + // The sheet has its own window and focus owner. Read this inside its + // content; the caller's focus manager controls the screen behind it. + val focusManager = LocalFocusManager.current + val nextFieldActions = KeyboardActions( + onNext = { focusManager.moveFocus(FocusDirection.Next) }, + ) + + RemoteOutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .focusRequester(descriptionFocus) + .focusProperties { + if (!connectionConfigured) { + next = urlFocus + down = urlFocus + } + } + .remoteFieldNavigation(focusManager), + keyboardActions = nextFieldActions, + value = state.description, + label = { Text(stringResource(R.string.action_ha_description_label)) }, + onValueChange = { + descriptionError = null + onDescriptionChanged(it) + }, + maxLines = 1, + singleLine = true, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next, + capitalization = KeyboardCapitalization.Sentences, + autoCorrectEnabled = true, + ), + isError = descriptionError != null, + supportingText = { + if (descriptionError != null) { + Text( + text = descriptionError!!, + color = MaterialTheme.colorScheme.error, + ) + } + }, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + if (connectionConfigured) { + Text(stringResource(R.string.home_assistant_using_saved_connection)) + TextButton(onClick = onConfigureConnection) { + Text(stringResource(R.string.home_assistant_edit_connection)) + } + } else { + RemoteOutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .focusRequester(urlFocus) + .focusProperties { + previous = descriptionFocus + up = descriptionFocus + next = tokenFocus + down = tokenFocus + } + .remoteFieldNavigation(focusManager), + keyboardActions = nextFieldActions, + value = state.serverUrl, + label = { Text(stringResource(R.string.action_ha_server_url_label)) }, + onValueChange = { + serverUrlError = null + onServerUrlChanged(it) + }, + maxLines = 1, + singleLine = true, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next, + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Uri, + ), + isError = serverUrlError != null, + supportingText = { + if (serverUrlError != null) { + Text( + text = serverUrlError!!, + color = MaterialTheme.colorScheme.error, + ) + } else { + Text(stringResource(R.string.action_ha_server_url_supporting_text)) + } + }, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + RemoteOutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .focusRequester(tokenFocus) + .focusProperties { + previous = urlFocus + up = urlFocus + } + .remoteFieldNavigation(focusManager), + keyboardActions = nextFieldActions, + value = state.accessToken, + label = { Text(stringResource(R.string.action_ha_access_token_label)) }, + onValueChange = { + tokenError = null + onAccessTokenChanged(it) + }, + maxLines = 1, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next, + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + ), + isError = tokenError != null, + supportingText = { + if (tokenError != null) { + Text( + text = tokenError!!, + color = MaterialTheme.colorScheme.error, + ) + } + }, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text(stringResource(R.string.home_assistant_save_connection_hint)) + } + + TextButton( + enabled = !isLoading && + state.serverUrl.trim().toHttpUrlOrNull() != null && + state.accessToken.isNotBlank(), + onClick = { refresh++ }, + ) { + Text(stringResource(R.string.action_ha_fetch_entities)) + } + if (isLoading) { + Text(stringResource(R.string.action_ha_fetching_entities)) + } + if (fetchError) { + Text( + stringResource(R.string.action_ha_fetch_error), + color = MaterialTheme.colorScheme.error, + ) + } + Text( + text = stringResource(R.string.action_ha_selected_entity, state.entityId), + ) + if (entityError != null) { + Text(entityError!!, color = MaterialTheme.colorScheme.error) + } + entities?.let { available -> + RemoteOutlinedTextField( + modifier = Modifier.fillMaxWidth().remoteFieldNavigation(focusManager), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + keyboardActions = nextFieldActions, + value = search, + onValueChange = { search = it }, + label = { Text(stringResource(R.string.action_ha_search_entities)) }, + singleLine = true, + ) + val filtered = available.filter { + it.friendlyName.contains(search, ignoreCase = true) || + it.entityId.contains(search, ignoreCase = true) + } + if (filtered.isEmpty()) { + Text(stringResource(R.string.action_ha_no_entities)) + } else { + LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 240.dp)) { + items(filtered, key = { it.entityId }) { entity -> + ListItem( + modifier = Modifier.clickable { + entityError = null + onEntityIdChanged(entity.entityId) + if (state.description.isBlank()) { + onDescriptionChanged(entity.friendlyName) + } + }, + headlineContent = { Text(entity.friendlyName) }, + supportingContent = { Text("${entity.entityId} · ${entity.state}") }, + trailingContent = { + if (entity.entityId == state.entityId) { + Text(stringResource(R.string.action_ha_selected)) + } + }, + ) + } + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + LaunchedEffect(serviceDropdownExpanded, restoreServiceFocus) { + if (!serviceDropdownExpanded && restoreServiceFocus) { + serviceFocus.requestFocus() + restoreServiceFocus = false + } + } + Box { + OutlinedButton( + modifier = Modifier + .fillMaxWidth() + .focusRequester(serviceFocus) + .focusProperties { + next = customServiceFocus + down = customServiceFocus + } + .remoteFieldNavigation(focusManager), + onClick = { serviceDropdownExpanded = true }, + ) { + Text( + stringResource( + R.string.home_assistant_service_selection, + services.first { it.first == state.service }.second, + ), + ) + } + DropdownMenu( + expanded = serviceDropdownExpanded, + onDismissRequest = { + serviceDropdownExpanded = false + restoreServiceFocus = true + }, + ) { + services.forEach { (service, title) -> + DropdownMenuItem( + text = { Text(title) }, + onClick = { + serviceError = false + onServiceChanged(service) + serviceDropdownExpanded = false + restoreServiceFocus = true + }, + ) + } + } + } + + RemoteOutlinedTextField( + modifier = Modifier.fillMaxWidth() + .focusRequester(customServiceFocus) + .focusProperties { + previous = serviceFocus + up = serviceFocus + next = serviceDataFocus + down = serviceDataFocus + } + .remoteFieldNavigation(focusManager), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + keyboardActions = nextFieldActions, + value = state.service, + onValueChange = { + serviceError = false + onServiceChanged(it) + }, + isError = serviceError, + label = { Text(stringResource(R.string.action_ha_service_label)) }, + supportingText = { Text(stringResource(R.string.action_ha_service_help)) }, + singleLine = true, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + RemoteOutlinedTextField( + modifier = Modifier.fillMaxWidth() + .focusRequester(serviceDataFocus) + .focusProperties { + previous = customServiceFocus + up = customServiceFocus + next = doneFocus + down = doneFocus + } + .remoteFieldNavigation(focusManager), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { doneFocus.requestFocus() }), + value = state.serviceData, + label = { Text(stringResource(R.string.action_ha_service_data_label)) }, + onValueChange = { + onServiceDataChanged(it) + }, + ) + } +} + +// Text fields consume D-pad arrows for cursor movement. Handle vertical navigation +// first so a TV remote can leave the editor and reach fields below the visible area. +internal fun Modifier.remoteFieldNavigation(focusManager: FocusManager): Modifier = + onPreviewKeyEvent { event -> + val direction = when (event.key) { + Key.DirectionDown -> FocusDirection.Next + Key.DirectionUp -> FocusDirection.Previous + else -> return@onPreviewKeyEvent false + } + if (event.type == KeyEventType.KeyDown) { + focusManager.moveFocus(direction) + } else { + false + } + } + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showSystemUi = true) +@Composable +private fun PreviewEmpty() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, + initialValue = SheetValue.Expanded, + ) + HomeAssistantBottomSheetContent( + sheetState = sheetState, + onDismissRequest = {}, + state = ActionData.HomeAssistantService( + description = "", + serverUrl = "http://", + accessToken = "", + domain = "", + service = "turn_on", + entityId = "", + serviceData = "", + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showSystemUi = true) +@Composable +private fun PreviewFilled() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, + initialValue = SheetValue.Expanded, + ) + HomeAssistantBottomSheetContent( + sheetState = sheetState, + onDismissRequest = {}, + state = ActionData.HomeAssistantService( + description = "Toggle living room light", + serverUrl = "http://192.168.1.100:8123", + accessToken = "eyJ0eXAiOiJKV1Q...", + domain = "light", + service = "toggle", + entityId = "light.living_room", + serviceData = "", + ), + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/HomeAssistantConfigRepository.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/HomeAssistantConfigRepository.kt new file mode 100644 index 0000000000..249c8afb9c --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/HomeAssistantConfigRepository.kt @@ -0,0 +1,55 @@ +package io.github.sds100.keymapper.base.actions + +import io.github.sds100.keymapper.data.Keys +import io.github.sds100.keymapper.data.repositories.PreferenceRepository +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import timber.log.Timber + +@Serializable +data class HomeAssistantConnection(val serverUrl: String, val accessToken: String) { + override fun toString(): String = "HomeAssistantConnection(redacted)" +} + +class HomeAssistantConfigRepository @Inject constructor( + private val preferences: PreferenceRepository, + private val cipher: HomeAssistantCredentialCipher, +) { + val connection = preferences.get(Keys.homeAssistantConnection).map { value -> + value?.let { + runCatching { Json.decodeFromString(cipher.decrypt(it)) } + .onFailure { + Timber.tag( + "HomeAssistant", + ).e("Saved connection could not be decrypted; configure it again") + }.getOrNull() + } + }.flowOn(Dispatchers.IO) + + fun save(connection: HomeAssistantConnection) { + preferences.set( + Keys.homeAssistantConnection, + cipher.encrypt( + Json.encodeToString( + connection.copy( + serverUrl = connection.serverUrl.trim().trimEnd('/'), + accessToken = connection.accessToken.trim(), + ), + ), + ), + ) + } + + suspend fun resolve(action: ActionData.HomeAssistantService): HomeAssistantConnection? { + // Old actions retain their embedded connection until edited and saved again. + if (action.serverUrl.isNotBlank() && action.accessToken.isNotBlank()) { + return HomeAssistantConnection(action.serverUrl, action.accessToken) + } + return connection.first() + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/HomeAssistantCredentialCipher.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/HomeAssistantCredentialCipher.kt new file mode 100644 index 0000000000..65083abbbf --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/HomeAssistantCredentialCipher.kt @@ -0,0 +1,65 @@ +package io.github.sds100.keymapper.base.actions + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import java.security.KeyStore +import java.util.Base64 +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import javax.inject.Inject + +class HomeAssistantCredentialCipher internal constructor( + private val keyProvider: () -> SecretKey, +) { + @Inject + constructor() : this({ loadKey() }) + + fun encrypt(value: String): String { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, keyProvider()) + cipher.updateAAD(ALIAS.toByteArray(Charsets.UTF_8)) + val encrypted = cipher.doFinal(value.toByteArray(Charsets.UTF_8)) + val encoder = Base64.getEncoder() + return "v1.${encoder.encodeToString(cipher.iv)}.${encoder.encodeToString(encrypted)}" + } + + fun decrypt(value: String): String { + val parts = value.split('.') + require(parts.size == 3 && parts[0] == "v1") + val decoder = Base64.getDecoder() + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init( + Cipher.DECRYPT_MODE, + keyProvider(), + GCMParameterSpec(128, decoder.decode(parts[1])), + ) + cipher.updateAAD(ALIAS.toByteArray(Charsets.UTF_8)) + return String(cipher.doFinal(decoder.decode(parts[2])), Charsets.UTF_8) + } + + private companion object { + const val ALIAS = "keymapper_home_assistant_connection_v1" + const val TRANSFORMATION = "AES/GCM/NoPadding" + val KEY_LOCK = Any() + + private fun loadKey(): SecretKey = synchronized(KEY_LOCK) { + val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + (store.getKey(ALIAS, null) as? SecretKey) ?: KeyGenerator + .getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") + .apply { + init( + KeyGenParameterSpec.Builder( + ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setKeySize(256) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .build(), + ) + }.generateKey() + } + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt index 64a5fbacc3..09c7bedba5 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt @@ -113,6 +113,7 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( private val mediaAdapter: MediaAdapter, private val airplaneModeAdapter: AirplaneModeAdapter, private val networkAdapter: NetworkAdapter, + private val homeAssistantConfig: HomeAssistantConfigRepository, private val bluetoothAdapter: BluetoothAdapter, private val nfcAdapter: NfcAdapter, private val openUrlAdapter: OpenUrlAdapter, @@ -1084,6 +1085,22 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( ) } + is ActionData.HomeAssistantService -> { + val connection = homeAssistantConfig.resolve(action) + result = if (connection == null) { + KMError.MalformedUrl + } else { + networkAdapter.callHomeAssistantService( + serverUrl = connection.serverUrl, + accessToken = connection.accessToken, + domain = action.domain, + service = action.service, + entityId = action.entityId, + serviceData = action.serviceData, + ) + } + } + is ActionData.ShellCommand -> { result = executeShellCommandUseCase.execute( command = action.command, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/settings/ConfigSettingsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/settings/ConfigSettingsUseCase.kt index d4305fd7a7..dbf064ef42 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/settings/ConfigSettingsUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/settings/ConfigSettingsUseCase.kt @@ -1,6 +1,7 @@ package io.github.sds100.keymapper.base.settings import androidx.datastore.preferences.core.Preferences +import io.github.sds100.keymapper.base.actions.HomeAssistantConnection import io.github.sds100.keymapper.base.actions.sound.SoundFileInfo import io.github.sds100.keymapper.base.actions.sound.SoundsManager import io.github.sds100.keymapper.base.system.inputmethod.KeyMapperImeHelper @@ -17,6 +18,8 @@ import io.github.sds100.keymapper.system.apps.PackageManagerAdapter import io.github.sds100.keymapper.system.devices.DevicesAdapter import io.github.sds100.keymapper.system.inputmethod.ImeInfo import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter +import io.github.sds100.keymapper.system.network.HomeAssistantEntity +import io.github.sds100.keymapper.system.network.NetworkAdapter import io.github.sds100.keymapper.system.notifications.NotificationAdapter import io.github.sds100.keymapper.system.permissions.Permission import io.github.sds100.keymapper.system.permissions.PermissionAdapter @@ -42,8 +45,16 @@ class ConfigSettingsUseCaseImpl @Inject constructor( private val devicesAdapter: DevicesAdapter, private val buildConfigProvider: BuildConfigProvider, private val notificationAdapter: NotificationAdapter, + private val networkAdapter: NetworkAdapter, ) : ConfigSettingsUseCase { + override suspend fun verifyHomeAssistantConnection( + connection: HomeAssistantConnection, + ): KMResult> = networkAdapter.fetchHomeAssistantEntities( + connection.serverUrl.trim().trimEnd('/'), + connection.accessToken.trim(), + ) + private val imeHelper by lazy { KeyMapperImeHelper( switchImeInterface, @@ -197,6 +208,10 @@ class ConfigSettingsUseCaseImpl @Inject constructor( } interface ConfigSettingsUseCase { + suspend fun verifyHomeAssistantConnection( + connection: HomeAssistantConnection, + ): KMResult> + fun getPreference(key: Preferences.Key): Flow fun setPreference(key: Preferences.Key, value: T?) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/settings/HomeAssistantConnectionDialog.kt b/base/src/main/java/io/github/sds100/keymapper/base/settings/HomeAssistantConnectionDialog.kt new file mode 100644 index 0000000000..b1f1173b65 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/settings/HomeAssistantConnectionDialog.kt @@ -0,0 +1,188 @@ +package io.github.sds100.keymapper.base.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.actions.HomeAssistantConnection +import io.github.sds100.keymapper.base.actions.remoteFieldNavigation +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.RemoteOutlinedTextField +import io.github.sds100.keymapper.common.utils.KMError +import io.github.sds100.keymapper.common.utils.KMResult +import io.github.sds100.keymapper.common.utils.Success +import io.github.sds100.keymapper.system.network.HomeAssistantEntity +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull + +@Composable +internal fun HomeAssistantConnectionDialog( + connection: HomeAssistantConnection?, + onSave: (HomeAssistantConnection) -> Unit, + onDismiss: () -> Unit, + onVerify: (suspend (HomeAssistantConnection) -> KMResult>)? = null, +) { + val scope = rememberCoroutineScope() + var verificationJob by remember { mutableStateOf(null) } + var verifying by remember { mutableStateOf(false) } + var verificationResult by remember { + mutableStateOf>?>(null) + } + + fun resetVerification() { + verificationJob?.cancel() + verifying = false + verificationResult = null + } + + var saveFailed by remember { mutableStateOf(false) } + var url by remember { mutableStateOf(connection?.serverUrl ?: "") } + var token by remember { mutableStateOf(connection?.accessToken ?: "") } + Dialog(onDismissRequest = onDismiss) { + // Resolve focus inside this dialog's window, not the underlying screen. + val focusManager = LocalFocusManager.current + val next = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }) + Surface { + Column( + Modifier.verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(stringResource(R.string.home_assistant_settings_title)) + Text(stringResource(R.string.home_assistant_settings_summary)) + RemoteOutlinedTextField( + modifier = Modifier.fillMaxWidth().remoteFieldNavigation(focusManager), + value = url, + onValueChange = { + resetVerification() + url = it + }, + label = { Text(stringResource(R.string.action_ha_server_url_label)) }, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + autoCorrectEnabled = false, + imeAction = ImeAction.Next, + ), + keyboardActions = next, + ) + RemoteOutlinedTextField( + modifier = Modifier.fillMaxWidth().remoteFieldNavigation(focusManager), + value = token, + onValueChange = { + resetVerification() + token = it + }, + label = { Text(stringResource(R.string.action_ha_access_token_label)) }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + autoCorrectEnabled = false, + imeAction = ImeAction.Next, + ), + keyboardActions = next, + ) + TextButton( + enabled = !verifying && + onVerify != null && + url.trim().toHttpUrlOrNull() != null && + token.isNotBlank(), + onClick = { + val verify = onVerify ?: return@TextButton + val draft = HomeAssistantConnection(url.trim().trimEnd('/'), token.trim()) + verifying = true + verificationResult = null + verificationJob = scope.launch { + val result = try { + verify(draft) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + KMError.UnknownIOError + } + ensureActive() + verificationResult = result + verifying = false + } + }, + ) { + Text( + stringResource( + if (verifying) { + R.string.home_assistant_verifying + } else { + R.string.home_assistant_verify + }, + ), + ) + } + when (val result = verificationResult) { + is Success -> Text( + stringResource(R.string.home_assistant_verify_success, result.value.size), + ) + is KMError -> Text( + stringResource(R.string.home_assistant_verify_failure), + color = MaterialTheme.colorScheme.error, + ) + null -> Unit + } + if (saveFailed) { + Text(stringResource(R.string.home_assistant_save_error)) + } + TextButton( + enabled = + !verifying && url.trim().toHttpUrlOrNull() != null && token.isNotBlank(), + onClick = { + saveFailed = + runCatching { onSave(HomeAssistantConnection(url, token)) }.isFailure + }, + ) { + Text(stringResource(R.string.pos_done)) + } + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.neg_cancel)) + } + } + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun PreviewHomeAssistantConnectionDialog() { + KeyMapperTheme { + HomeAssistantConnectionDialog( + connection = HomeAssistantConnection("http://homeassistant.local:8123", ""), + onSave = {}, + onDismiss = {}, + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsScreen.kt index d2466a9c01..72c191b00a 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.material.icons.outlined.Android import androidx.compose.material.icons.outlined.BugReport import androidx.compose.material.icons.outlined.FindInPage import androidx.compose.material.icons.outlined.Gamepad +import androidx.compose.material.icons.outlined.Home import androidx.compose.material.icons.outlined.Keyboard import androidx.compose.material.icons.outlined.OfflineBolt import androidx.compose.material.icons.rounded.Code @@ -82,6 +83,21 @@ private val isAutoSwitchImeSupported = Build.VERSION.SDK_INT >= Build.VERSION_CO @Composable fun SettingsScreen(modifier: Modifier = Modifier, viewModel: SettingsViewModel) { val state by viewModel.mainScreenState.collectAsStateWithLifecycle() + val homeAssistantConnection by viewModel.homeAssistantConnection.collectAsStateWithLifecycle( + initialValue = null, + ) + var showHomeAssistantSettings by remember { mutableStateOf(false) } + if (showHomeAssistantSettings) { + HomeAssistantConnectionDialog( + connection = homeAssistantConnection, + onVerify = viewModel::verifyHomeAssistantConnection, + onSave = { + viewModel.saveHomeAssistantConnection(it) + showHomeAssistantSettings = false + }, + onDismiss = { showHomeAssistantSettings = false }, + ) + } val snackbarHostState = SnackbarHostState() var showAutomaticBackupDialog by remember { mutableStateOf(false) } val context = LocalContext.current @@ -146,6 +162,7 @@ fun SettingsScreen(modifier: Modifier = Modifier, viewModel: SettingsViewModel) ) { Content( state = state, + onHomeAssistantClick = { showHomeAssistantSettings = true }, onThemeSelected = viewModel::onThemeSelected, onPauseResumeNotificationClick = viewModel::onPauseResumeNotificationClick, onDefaultOptionsClick = viewModel::onDefaultOptionsClick, @@ -230,6 +247,7 @@ private fun SettingsScreen( private fun Content( modifier: Modifier = Modifier, state: MainSettingsState, + onHomeAssistantClick: () -> Unit = {}, onThemeSelected: (Theme) -> Unit = { }, onPauseResumeNotificationClick: () -> Unit = { }, onDefaultOptionsClick: () -> Unit = { }, @@ -254,6 +272,13 @@ private fun Content( ) { Spacer(modifier = Modifier.height(8.dp)) + OptionPageButton( + title = stringResource(R.string.home_assistant_settings_title), + text = stringResource(R.string.home_assistant_settings_summary), + icon = Icons.Outlined.Home, + onClick = onHomeAssistantClick, + ) + OptionsHeaderRow( modifier = Modifier.fillMaxWidth(), icon = KeyMapperIcons.WandStars, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsViewModel.kt index 568da791b8..d406e3bcde 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsViewModel.kt @@ -6,6 +6,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.actions.HomeAssistantConfigRepository +import io.github.sds100.keymapper.base.actions.HomeAssistantConnection import io.github.sds100.keymapper.base.logging.ShareLogcatUseCase import io.github.sds100.keymapper.base.system.notifications.NotificationController import io.github.sds100.keymapper.base.utils.getFullMessage @@ -34,6 +36,7 @@ import kotlinx.coroutines.launch @HiltViewModel class SettingsViewModel @Inject constructor( private val useCase: ConfigSettingsUseCase, + private val homeAssistantConfig: HomeAssistantConfigRepository, private val resourceProvider: ResourceProvider, private val shareLogcatUseCase: ShareLogcatUseCase, dialogProvider: DialogProvider, @@ -44,6 +47,14 @@ class SettingsViewModel @Inject constructor( NavigationProvider by navigationProvider, DefaultOptionsSettingsCallback { + val homeAssistantConnection = homeAssistantConfig.connection + + suspend fun verifyHomeAssistantConnection(connection: HomeAssistantConnection) = + useCase.verifyHomeAssistantConnection(connection) + + fun saveHomeAssistantConnection(connection: HomeAssistantConnection) = + homeAssistantConfig.save(connection) + val defaultLongPressDelay: Flow = useCase.defaultLongPressDelay val defaultDoublePressDelay: Flow = useCase.defaultDoublePressDelay val defaultRepeatDelay: Flow = useCase.defaultRepeatDelay diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/KMBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/KMBottomSheet.kt index e58ec6b5f2..3ac3815f36 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/KMBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/KMBottomSheet.kt @@ -44,6 +44,7 @@ fun KMBottomSheet( posButtonEnabled: Boolean = true, negButtonText: String? = null, onNegButtonClick: (() -> Unit)? = null, + posButtonModifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit, ) { val scope = rememberCoroutineScope() @@ -111,7 +112,7 @@ fun KMBottomSheet( } Button( - modifier = Modifier.weight(1f), + modifier = Modifier.weight(1f).then(posButtonModifier), enabled = posButtonEnabled, onClick = onPosButtonClick, ) { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/RemoteOutlinedTextField.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/RemoteOutlinedTextField.kt new file mode 100644 index 0000000000..991d5cbc25 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/RemoteOutlinedTextField.kt @@ -0,0 +1,115 @@ +package io.github.sds100.keymapper.base.utils.ui.compose + +import android.content.res.Configuration +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.OutlinedTextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.input.VisualTransformation + +/** On TV, focusing a field selects it; pressing OK explicitly starts editing. */ +@Composable +internal fun RemoteOutlinedTextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + label: @Composable (() -> Unit)? = null, + supportingText: @Composable (() -> Unit)? = null, + singleLine: Boolean = false, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + visualTransformation: VisualTransformation = VisualTransformation.None, + isError: Boolean = false, +) { + val isTv = LocalConfiguration.current.uiMode and Configuration.UI_MODE_TYPE_MASK == + Configuration.UI_MODE_TYPE_TELEVISION + val keyboard = LocalSoftwareKeyboardController.current + var editing by remember { mutableStateOf(false) } + var activating by remember { mutableStateOf(false) } + var sawKeyboard by remember { mutableStateOf(false) } + val imeVisible = WindowInsets.ime.getBottom(LocalDensity.current) > 0 + + LaunchedEffect(editing) { + if (isTv && editing) { + // Wait for the editable field's input connection to be installed. + withFrameNanos { } + keyboard?.show() + } + } + LaunchedEffect(imeVisible, editing) { + if (!editing) { + sawKeyboard = false + } else if (imeVisible) { + sawKeyboard = true + } else if (sawKeyboard) { + editing = false + } + } + BackHandler(enabled = isTv && editing) { + editing = false + keyboard?.hide() + } + + OutlinedTextField( + modifier = modifier + .onFocusChanged { + if (!it.isFocused && editing) { + editing = false + keyboard?.hide() + } + } + .onPreviewKeyEvent { event -> + val activate = event.key == Key.DirectionCenter || + event.key == Key.Enter || + event.key == Key.NumPadEnter + if (!isTv || !activate) return@onPreviewKeyEvent false + if (!editing && event.type == KeyEventType.KeyDown) { + activating = true + true + } else if (activating && event.type == KeyEventType.KeyUp) { + activating = false + editing = true + true + } else { + false + } + }, + value = value, + onValueChange = onValueChange, + label = label, + supportingText = supportingText, + singleLine = singleLine, + maxLines = maxLines, + readOnly = isTv && !editing, + keyboardOptions = if (isTv) { + keyboardOptions.copy( + showKeyboardOnFocus = false, + ) + } else { + keyboardOptions + }, + keyboardActions = keyboardActions, + visualTransformation = visualTransformation, + isError = isError, + ) +} diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 92ce7b43e9..bdfd5be0d8 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1315,6 +1315,27 @@ Authorization header (optional) You must prepend \'Bearer\' if necessary + Home Assistant service + Description + Can not be empty! + Server URL + Can not be empty! + e.g. http://192.168.1.100:8123 + Long-Lived Access Token + Can not be empty! + Entity + Can not be empty! + Service + Service data JSON (optional) + Fetch entities + Fetching entities… + Failed to fetch entities. Check URL and token. + Test + Connection successful! + Connection failed! + Malformed URL. Did you forget the http://? + No entities found + Shell command Shell command action Script @@ -2376,4 +2397,22 @@ Select a group Choose which group this intent controls + Search entities + Selected entity: %1$s + Selected + Enter a service supported by this entity, e.g. toggle, press, or open_cover. Sensors only report state. + Turn on + Turn off + Toggle + Home Assistant + Configure the server URL and access token once for your Home Assistant shortcuts. + Using the Home Assistant connection from app settings. + Edit connection + This connection will be saved in app settings for future shortcuts. + Could not securely save the connection. Please try again. + Verify connection + Verifying… + Connection verified. Entities available: %1$d + Verification failed. Check the URL, access token, and network connection. + Service: %1$s diff --git a/base/src/test/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapperTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapperTest.kt index a5f7d4fb55..b3b5685a97 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapperTest.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapperTest.kt @@ -14,6 +14,47 @@ import org.junit.Test class ActionDataEntityMapperTest { + @Test + fun `shared home assistant action does not store credentials in mapping data`() { + val action = ActionData.HomeAssistantService( + description = "Light", + serverUrl = "", + accessToken = "", + domain = "light", + service = "toggle", + entityId = "light.living_room", + serviceData = "", + ) + val entity = ActionDataEntityMapper.toEntity(action) + assertThat( + entity.extras.getData(ActionEntity.EXTRA_HA_ACCESS_TOKEN).valueOrNull(), + `is`(nullValue()), + ) + assertThat( + entity.extras.getData(ActionEntity.EXTRA_HA_SERVER_URL).valueOrNull(), + `is`(nullValue()), + ) + assertThat(ActionDataEntityMapper.fromEntity(entity), `is`(action)) + } + + @Test + fun `home assistant action preserves its target and service data`() { + val action = ActionData.HomeAssistantService( + description = "Living room light", + serverUrl = "http://homeassistant.local:8123", + accessToken = "test-token", + domain = "light", + service = "turn_on", + entityId = "light.living_room", + serviceData = "{\"brightness\":128}", + ) + + assertThat( + ActionDataEntityMapper.fromEntity(ActionDataEntityMapper.toEntity(action)), + `is`(action), + ) + } + @Test fun `set variable action round trips through the entity`() { val action = ActionData.SetVariable(name = "counter", value = -12) diff --git a/base/src/test/java/io/github/sds100/keymapper/base/actions/ActionListItemTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/actions/ActionListItemTest.kt new file mode 100644 index 0000000000..3eac9af2e1 --- /dev/null +++ b/base/src/test/java/io/github/sds100/keymapper/base/actions/ActionListItemTest.kt @@ -0,0 +1,60 @@ +package io.github.sds100.keymapper.base.actions + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Home +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.pressKey +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], qualifiers = "w600dp-h600dp") +class ActionListItemTest { + @get:Rule + val compose = createComposeRule() + + @Test + fun remoteCanReachTestBetweenDuplicateAndSettings() { + var tested = 0 + var expanded = 0 + compose.setContent { + KeyMapperTheme { + ActionListItem( + model = ActionListItemModel( + id = "light", + title = "Living room light", + icon = ComposeIconInfo.Vector(Icons.Outlined.Home), + ), + index = 0, + isExpanded = true, + isDragging = false, + isReorderingEnabled = false, + onTestClick = { tested++ }, + onExpandedChange = { expanded++ }, + ) + } + } + compose.onNodeWithContentDescription("Edit").assertIsDisplayed() + .performSemanticsAction(SemanticsActions.RequestFocus) { it() } + .performKeyInput { pressKey(Key.DirectionLeft) } + compose.onNodeWithContentDescription("Test").assertIsFocused() + .performKeyInput { pressKey(Key.DirectionCenter) } + compose.runOnIdle { + assertEquals(1, tested) + assertEquals(0, expanded) + } + } +} diff --git a/base/src/test/java/io/github/sds100/keymapper/base/actions/HomeAssistantConfigRepositoryTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/actions/HomeAssistantConfigRepositoryTest.kt new file mode 100644 index 0000000000..6459fd6dd3 --- /dev/null +++ b/base/src/test/java/io/github/sds100/keymapper/base/actions/HomeAssistantConfigRepositoryTest.kt @@ -0,0 +1,99 @@ +package io.github.sds100.keymapper.base.actions + +import io.github.sds100.keymapper.base.repositories.FakePreferenceRepository +import io.github.sds100.keymapper.data.Keys +import javax.crypto.AEADBadTagException +import javax.crypto.KeyGenerator +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Test + +class HomeAssistantConfigRepositoryTest { + private val key = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + private val cipher = HomeAssistantCredentialCipher { key } + private val preferences = FakePreferenceRepository() + private val repository = HomeAssistantConfigRepository(preferences, cipher) + + private val action = ActionData.HomeAssistantService( + description = "Light", + serverUrl = "", + accessToken = "", + domain = "light", + service = "toggle", + entityId = "light.living_room", + serviceData = "", + ) + + @Test + fun `encryption failure never falls back to plaintext storage`() = runTest { + val failingCipher = HomeAssistantCredentialCipher { error("Key unavailable") } + val failingRepository = HomeAssistantConfigRepository(preferences, failingCipher) + assertThrows(IllegalStateException::class.java) { + failingRepository.save(HomeAssistantConnection("https://example.test", "secret-token")) + } + assertNull(preferences.get(Keys.homeAssistantConnection).first()) + } + + @Test + fun `connection is encrypted at rest and survives repository recreation`() = runTest { + repository.save(HomeAssistantConnection(" https://example.test/ ", " secret-token ")) + val stored = preferences.get(Keys.homeAssistantConnection).first()!! + assertFalse(stored.contains("secret-token")) + assertFalse(stored.contains("example.test")) + assertEquals( + HomeAssistantConnection("https://example.test", "secret-token"), + HomeAssistantConfigRepository(preferences, cipher).connection.first(), + ) + } + + @Test + fun `shortcuts resolve the latest app connection`() = runTest { + repository.save(HomeAssistantConnection("https://first.test", "first-token")) + assertEquals("first-token", repository.resolve(action)?.accessToken) + repository.save(HomeAssistantConnection("https://second.test", "second-token")) + assertEquals("second-token", repository.resolve(action)?.accessToken) + } + + @Test + fun `legacy shortcut retains its connection`() = runTest { + repository.save(HomeAssistantConnection("https://shared.test", "shared-token")) + assertEquals( + HomeAssistantConnection("https://legacy.test", "legacy-token"), + repository.resolve( + action.copy(serverUrl = "https://legacy.test", accessToken = "legacy-token"), + ), + ) + } + + @Test + fun `restoring ciphertext without the device key requires configuration again`() = runTest { + repository.save(HomeAssistantConnection("https://example.test", "secret-token")) + val otherKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + val restored = + HomeAssistantConfigRepository(preferences, HomeAssistantCredentialCipher { otherKey }) + assertNull(restored.connection.first()) + } + + @Test + fun `encryption uses a fresh nonce and rejects tampering`() { + val first = cipher.encrypt("secret-token") + val second = cipher.encrypt("secret-token") + assertNotEquals(first, second) + assertEquals("secret-token", cipher.decrypt(first)) + val parts = first.split('.').toMutableList() + parts[2] = (if (parts[2][0] == 'A') "B" else "A") + parts[2].substring(1) + assertThrows(AEADBadTagException::class.java) { cipher.decrypt(parts.joinToString(".")) } + } + + @Test + fun `connection never prints credentials`() { + val connection = HomeAssistantConnection("https://example.test", "secret-token") + assertFalse(connection.toString().contains("secret-token")) + assertFalse(connection.toString().contains("example.test")) + } +} diff --git a/base/src/test/java/io/github/sds100/keymapper/base/actions/HomeAssistantConnectionDialogTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/actions/HomeAssistantConnectionDialogTest.kt new file mode 100644 index 0000000000..731485d98b --- /dev/null +++ b/base/src/test/java/io/github/sds100/keymapper/base/actions/HomeAssistantConnectionDialogTest.kt @@ -0,0 +1,135 @@ +package io.github.sds100.keymapper.base.actions + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.OutlinedTextField +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertIsNotFocused +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performImeAction +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.performTextReplacement +import androidx.compose.ui.test.pressKey +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.settings.HomeAssistantConnectionDialog +import io.github.sds100.keymapper.common.utils.KMError +import io.github.sds100.keymapper.common.utils.KMResult +import io.github.sds100.keymapper.common.utils.Success +import io.github.sds100.keymapper.system.network.HomeAssistantEntity +import kotlinx.coroutines.CompletableDeferred +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class HomeAssistantConnectionDialogTest { + private var verifiedConnection: HomeAssistantConnection? = null + private var saves = 0 + private var verificationResponse: KMResult> = Success(emptyList()) + private var verificationGate: CompletableDeferred>>? = null + + @get:Rule + val compose = createComposeRule() + + @Before + fun showConnectionSettings() { + compose.setContent { + KeyMapperTheme { + Column { + OutlinedTextField( + modifier = Modifier.testTag("background"), + value = "Settings screen", + onValueChange = {}, + ) + HomeAssistantConnectionDialog( + connection = HomeAssistantConnection("https://example.test", "test-token"), + onSave = { saves++ }, + onDismiss = {}, + onVerify = { + verifiedConnection = it + verificationGate?.await() ?: verificationResponse + }, + ) + } + } + } + compose.onNodeWithText("Server URL") + .performSemanticsAction(SemanticsActions.RequestFocus) { it() } + } + + @Test + fun remoteMovesBetweenConnectionFieldsWithoutFocusingBackground() { + compose.onNodeWithText("Server URL").performKeyInput { pressKey(Key.DirectionDown) } + compose.onNodeWithText("Long-Lived Access Token").assertIsFocused() + compose.onNodeWithText("Long-Lived Access Token").performKeyInput { + pressKey(Key.DirectionUp) + } + compose.onNodeWithText("Server URL").assertIsFocused() + compose.onNodeWithTag("background").assertIsNotFocused() + } + + @Test + fun keyboardNextMovesToToken() { + compose.onNodeWithText("Server URL").performImeAction() + compose.onNodeWithText("Long-Lived Access Token").assertIsFocused() + compose.onNodeWithTag("background").assertIsNotFocused() + } + + @Test + fun invalidConnectionCannotBeSaved() { + compose.onNodeWithText("Server URL").performTextReplacement("") + compose.onNodeWithText("Done").assertIsNotEnabled() + compose.onNodeWithText("Verify connection").assertIsNotEnabled() + } + + @Test + fun verifiesUnsavedConnectionWithoutSavingIt() { + compose.onNodeWithText("Server URL").performTextReplacement(" https://other.test/ ") + compose.onNodeWithText("Verify connection").performClick() + compose.onNodeWithText("Connection verified. Entities available: 0").assertExists() + compose.runOnIdle { + assertEquals( + HomeAssistantConnection("https://other.test", "test-token"), + verifiedConnection, + ) + assertEquals(0, saves) + } + } + + @Test + fun failedVerificationIsShownAndClearedWhenInputChanges() { + verificationResponse = KMError.UnknownIOError + compose.onNodeWithText("Verify connection").performClick() + val failure = "Verification failed. Check the URL, access token, and network connection." + compose.onNodeWithText(failure).assertExists() + compose.onNodeWithText("Server URL").performTextReplacement("https://other.test") + compose.onNodeWithText(failure).assertDoesNotExist() + } + + @Test + fun editingDuringVerificationCancelsItAndDiscardsOldResult() { + val gate = CompletableDeferred>>() + verificationGate = gate + compose.onNodeWithText("Verify connection").performClick() + compose.onNodeWithText("Verifying…").assertIsNotEnabled() + compose.onNodeWithText("Done").assertIsNotEnabled() + compose.onNodeWithText("Server URL").performTextReplacement("https://other.test") + gate.complete(Success(emptyList())) + compose.onNodeWithText("Verify connection").assertIsEnabled() + compose.onNodeWithText("Connection verified. Entities available: 0").assertDoesNotExist() + } +} diff --git a/base/src/test/java/io/github/sds100/keymapper/base/actions/HomeAssistantFocusTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/actions/HomeAssistantFocusTest.kt new file mode 100644 index 0000000000..63c6f6a364 --- /dev/null +++ b/base/src/test/java/io/github/sds100/keymapper/base/actions/HomeAssistantFocusTest.kt @@ -0,0 +1,106 @@ +package io.github.sds100.keymapper.base.actions + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.assertIsNotFocused +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performImeAction +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.pressKey +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +@OptIn(ExperimentalMaterial3Api::class) +class HomeAssistantFocusTest { + private var connectionConfigured by mutableStateOf(false) + + @get:Rule + val compose = createComposeRule() + + @Before + fun showSheetOverFocusableScreen() { + compose.setContent { + KeyMapperTheme { + Column { + OutlinedTextField( + modifier = Modifier.testTag("background"), + value = "Background screen", + onValueChange = {}, + ) + HomeAssistantBottomSheetContent( + connectionConfigured = connectionConfigured, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + onDismissRequest = {}, + state = ActionData.HomeAssistantService( + description = "Living room", + serverUrl = "http://homeassistant.local:8123", + accessToken = "test-token", + domain = "light", + service = "toggle", + entityId = "light.living_room", + serviceData = "", + ), + ) + } + } + } + compose.onNodeWithText("Description") + .performSemanticsAction(SemanticsActions.RequestFocus) { it() } + compose.onNodeWithText("Description").assertIsFocused() + } + + @Test + fun configuredConnectionHidesCredentialsAndKeepsNavigationInsideSheet() { + compose.runOnIdle { connectionConfigured = true } + compose.onNodeWithText("Server URL").assertDoesNotExist() + compose.onNodeWithText("Long-Lived Access Token").assertDoesNotExist() + compose.onNodeWithText("Description").performKeyInput { pressKey(Key.DirectionDown) } + compose.onNodeWithText("Edit connection").assertIsFocused() + compose.onNodeWithTag("background").assertIsNotFocused() + } + + @Test + fun downMovesThroughFieldsInsideSheet() { + compose.onNodeWithText("Description").performKeyInput { pressKey(Key.DirectionDown) } + compose.onNodeWithText("Server URL").assertIsFocused() + compose.onNodeWithText("Server URL").performKeyInput { pressKey(Key.DirectionDown) } + compose.onNodeWithText("Long-Lived Access Token").assertIsFocused() + compose.onNodeWithTag("background").assertIsNotFocused() + } + + @Test + fun upReturnsToDescriptionInsideSheet() { + compose.onNodeWithText("Server URL") + .performSemanticsAction(SemanticsActions.RequestFocus) { it() } + compose.onNodeWithText("Server URL").performKeyInput { pressKey(Key.DirectionUp) } + compose.onNodeWithText("Description").assertIsFocused() + compose.onNodeWithTag("background").assertIsNotFocused() + } + + @Test + fun keyboardNextMovesInsideSheet() { + compose.onNodeWithText("Description").performImeAction() + compose.onNodeWithText("Server URL").assertIsFocused() + compose.onNodeWithTag("background").assertIsNotFocused() + } +} diff --git a/base/src/test/java/io/github/sds100/keymapper/base/actions/HomeAssistantTvFocusTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/actions/HomeAssistantTvFocusTest.kt new file mode 100644 index 0000000000..3296448228 --- /dev/null +++ b/base/src/test/java/io/github/sds100/keymapper/base/actions/HomeAssistantTvFocusTest.kt @@ -0,0 +1,95 @@ +package io.github.sds100.keymapper.base.actions + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.pressKey +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], qualifiers = "w960dp-h540dp-television") +@OptIn(ExperimentalMaterial3Api::class) +class HomeAssistantTvFocusTest { + @get:Rule + val compose = createComposeRule() + + private var saved = 0 + private var action by mutableStateOf( + ActionData.HomeAssistantService( + description = "Light", + serverUrl = "https://example.test", + accessToken = "test-token", + domain = "light", + service = "toggle", + entityId = "light.living_room", + serviceData = "", + ), + ) + + @Before + fun showAction() { + compose.setContent { + KeyMapperTheme { + HomeAssistantBottomSheetContent( + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + state = action, + connectionConfigured = true, + onServiceChanged = { action = action.copy(service = it) }, + onDoneClick = { saved++ }, + onDismissRequest = {}, + ) + } + } + } + + @Test + fun focusingDoesNotEditUntilOkAndLeavingStopsEditing() { + compose.onNodeWithText("Description") + .performSemanticsAction(SemanticsActions.RequestFocus) { it() } + .assert(hasSetTextAction().not()) + .performKeyInput { pressKey(Key.DirectionCenter) } + .assert(hasSetTextAction()) + .performKeyInput { pressKey(Key.DirectionDown) } + compose.onNodeWithText("Edit connection").assertIsFocused() + compose.onNodeWithText("Description").assert(hasSetTextAction().not()) + } + + @Test + fun selectingServiceRestoresFocusAndRemoteCanReachDone() { + compose.onNodeWithText("Service: Toggle") + .performSemanticsAction(SemanticsActions.RequestFocus) { it() } + .performKeyInput { pressKey(Key.DirectionCenter) } + compose.onNodeWithText("Turn on") + .performSemanticsAction(SemanticsActions.RequestFocus) { it() } + .performKeyInput { pressKey(Key.DirectionCenter) } + compose.onNodeWithText("Service: Turn on").assertIsFocused() + .performKeyInput { pressKey(Key.DirectionDown) } + compose.onNodeWithText("Service").assertIsFocused() + .assert(hasSetTextAction().not()) + .performKeyInput { pressKey(Key.DirectionDown) } + compose.onNodeWithText("Service data JSON (optional)").assertIsFocused() + .assert(hasSetTextAction().not()) + .performKeyInput { pressKey(Key.DirectionDown) } + compose.onNodeWithText("Done").assertIsFocused() + .performKeyInput { pressKey(Key.DirectionCenter) } + compose.runOnIdle { assertEquals(1, saved) } + } +} diff --git a/base/src/test/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCaseTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCaseTest.kt index db7a58c3b3..140ab6c0e4 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCaseTest.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCaseTest.kt @@ -80,6 +80,7 @@ class PerformActionsUseCaseTest { mediaAdapter = mock(), airplaneModeAdapter = mock(), networkAdapter = mock(), + homeAssistantConfig = mock(), bluetoothAdapter = mock(), nfcAdapter = mock(), openUrlAdapter = mock(), diff --git a/data/src/main/java/io/github/sds100/keymapper/data/Keys.kt b/data/src/main/java/io/github/sds100/keymapper/data/Keys.kt index 0a5055381f..58c70a2826 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/Keys.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/Keys.kt @@ -7,6 +7,8 @@ import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey object Keys { + val homeAssistantConnection = stringPreferencesKey("home_assistant_connection") + val darkTheme = stringPreferencesKey("pref_dark_theme_mode") /** diff --git a/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt b/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt index 1c0f5e0972..3b3558b8d3 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt @@ -92,6 +92,13 @@ data class ActionEntity( const val EXTRA_HTTP_BODY = "extra_http_body" const val EXTRA_HTTP_DESCRIPTION = "extra_http_description" const val EXTRA_HTTP_AUTHORIZATION_HEADER = "extra_http_authorization_header" + const val EXTRA_HA_DESCRIPTION = "extra_ha_description" + const val EXTRA_HA_SERVER_URL = "extra_ha_server_url" + const val EXTRA_HA_ACCESS_TOKEN = "extra_ha_access_token" + const val EXTRA_HA_DOMAIN = "extra_ha_domain" + const val EXTRA_HA_SERVICE = "extra_ha_service" + const val EXTRA_HA_ENTITY_ID = "extra_ha_entity_id" + const val EXTRA_HA_SERVICE_DATA = "extra_ha_service_data" const val EXTRA_SMS_MESSAGE = "extra_sms_message" const val EXTRA_SHELL_COMMAND_USE_ROOT = "extra_shell_command_use_root" const val EXTRA_SHELL_COMMAND_DESCRIPTION = "extra_shell_command_description" diff --git a/evdev/build.gradle.kts b/evdev/build.gradle.kts index 20a27bb7af..6aed43f86a 100644 --- a/evdev/build.gradle.kts +++ b/evdev/build.gradle.kts @@ -57,7 +57,7 @@ cargo { module = "src/main/rust/evdev_manager" libname = "evdev_manager" - targets = if (isDebug) { + targets = providers.gradleProperty("keymapperRustTargets").orNull?.split(",") ?: if (isDebug) { // Only building for one target saves some time when developing listOf("arm64") } else { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ad19c4bc33..d192f205e1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,6 +26,7 @@ androidx-recyclerview = "1.4.0" androidx-room = "2.7.1" # room_version for dependencies androidx-room-gradle-plugin = "2.7.2" # For plugin androidx-test-core = "1.6.1" +androidx-test-runner = "1.6.2" androidx-viewpager2 = "1.1.0" dagger-hilt-android = "2.56.2" @@ -122,6 +123,7 @@ androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "androidx-room" } androidx-room-testing-legacy = { group = "android.arch.persistence.room", name = "testing", version.ref = "room-testing-legacy" } androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidx-test-core" } +androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidx-test-runner" } androidx-test-core-ktx = { group = "androidx.test", name = "core-ktx", version.ref = "androidx-test-core" } androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-junit" } androidx-viewpager2 = { group = "androidx.viewpager2", name = "viewpager2", version.ref = "androidx-viewpager2" } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/network/AndroidNetworkAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/network/AndroidNetworkAdapter.kt index f500d1d980..aabbbbafc0 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/network/AndroidNetworkAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/network/AndroidNetworkAdapter.kt @@ -14,6 +14,7 @@ import android.net.NetworkRequest import android.net.wifi.WifiInfo import android.net.wifi.WifiManager import android.os.Build +import android.os.SystemClock import android.provider.Settings import android.telephony.SubscriptionManager import android.telephony.TelephonyManager @@ -27,6 +28,7 @@ import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.Success import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager import io.github.sds100.keymapper.system.root.SuAdapter +import java.util.concurrent.atomic.AtomicInteger import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.Dispatchers @@ -35,12 +37,19 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext +import okhttp3.Call +import okhttp3.EventListener +import okhttp3.Handshake import okhttp3.Headers +import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okio.IOException import okio.use +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject import timber.log.Timber @Singleton @@ -55,6 +64,56 @@ class AndroidNetworkAdapter @Inject constructor( private val connectivityManager: ConnectivityManager by lazy { ctx.getSystemService()!! } private val httpClient: OkHttpClient by lazy { OkHttpClient() } + private val homeAssistantRequestIds = AtomicInteger() + + private val homeAssistantHttpClient: OkHttpClient by lazy { + httpClient.newBuilder().eventListenerFactory { + object : EventListener() { + override fun dnsStart(call: Call, domainName: String) { + Timber.tag( + "HomeAssistant", + ).d("Fetch #%s: resolving DNS", call.request().tag(Int::class.javaObjectType)) + } + + override fun connectStart( + call: Call, + inetSocketAddress: java.net.InetSocketAddress, + proxy: java.net.Proxy, + ) { + Timber.tag( + "HomeAssistant", + ).d("Fetch #%s: connecting", call.request().tag(Int::class.javaObjectType)) + } + + override fun secureConnectStart(call: Call) { + Timber.tag( + "HomeAssistant", + ).d( + "Fetch #%s: starting TLS handshake", + call.request().tag(Int::class.javaObjectType), + ) + } + + override fun secureConnectEnd(call: Call, handshake: Handshake?) { + Timber.tag( + "HomeAssistant", + ).d( + "Fetch #%s: TLS handshake complete", + call.request().tag(Int::class.javaObjectType), + ) + } + + override fun responseHeadersStart(call: Call) { + Timber.tag( + "HomeAssistant", + ).d( + "Fetch #%s: waiting for response headers", + call.request().tag(Int::class.javaObjectType), + ) + } + } + }.build() + } private val broadcastReceiver = object : BroadcastReceiver() { @Suppress("DEPRECATION") @@ -307,6 +366,154 @@ class AndroidNetworkAdapter @Inject constructor( } } + override suspend fun fetchHomeAssistantEntities( + serverUrl: String, + accessToken: String, + ): KMResult> { + val requestId = homeAssistantRequestIds.incrementAndGet() + val startedAt = SystemClock.elapsedRealtime() + Timber.tag("HomeAssistant").d("Fetch #%d: GET /api/states started", requestId) + try { + val request = Request.Builder() + .url("${serverUrl.trim().trimEnd('/')}/api/states") + .get() + .tag(Int::class.javaObjectType, requestId) + .addHeader("Authorization", "Bearer ${accessToken.trim()}") + .addHeader("Content-Type", "application/json") + .build() + + val responseBody = withContext(Dispatchers.IO) { + homeAssistantHttpClient.newCall(request).execute().use { response -> + Timber.tag("HomeAssistant").d( + "Fetch #%d: HTTP %d after %d ms", + requestId, + response.code, + SystemClock.elapsedRealtime() - startedAt, + ) + if (!response.isSuccessful) { + Timber.tag("HomeAssistant").e( + "Fetch #%d failed: HTTP %d%s", + requestId, + response.code, + if (response.code == 401 || + response.code == 403 + ) { + " (authentication rejected)" + } else { + "" + }, + ) + return@withContext null + } + response.body?.string().also { + if (it.isNullOrBlank()) { + Timber.tag( + "HomeAssistant", + ).e("Fetch #%d: empty response body", requestId) + } + } + } + } ?: return KMError.UnknownIOError + + val jsonArray = JSONArray(responseBody) + val entities = mutableListOf() + + for (i in 0 until jsonArray.length()) { + val obj = jsonArray.getJSONObject(i) + val entityId = obj.getString("entity_id") + val domain = entityId.substringBefore(".") + val attributes = obj.optJSONObject("attributes") + val friendlyName = attributes?.optString("friendly_name", entityId) ?: entityId + val state = obj.optString("state", "unknown") + + entities.add( + HomeAssistantEntity( + entityId = entityId, + friendlyName = friendlyName, + domain = domain, + state = state, + ), + ) + } + + Timber.tag("HomeAssistant").d( + "Fetch #%d completed: %d entities in %d ms", + requestId, + entities.size, + SystemClock.elapsedRealtime() - startedAt, + ) + return Success(entities) + } catch (e: IOException) { + // Exception messages can include URLs or response data; log only their types. + Timber.tag("HomeAssistant").e( + "Fetch #%d failed after %d ms: %s (cause: %s)", + requestId, + SystemClock.elapsedRealtime() - startedAt, + e.javaClass.simpleName, + e.cause?.javaClass?.simpleName ?: "none", + ) + return KMError.UnknownIOError + } catch (e: IllegalArgumentException) { + Timber.tag( + "HomeAssistant", + ).e("Fetch #%d failed: malformed server URL or request", requestId) + return KMError.MalformedUrl + } catch (e: JSONException) { + Timber.tag("HomeAssistant").e( + "Fetch #%d failed: response is not a valid Home Assistant entity list (%s)", + requestId, + e.javaClass.simpleName, + ) + return KMError.UnknownIOError + } + } + + override suspend fun callHomeAssistantService( + serverUrl: String, + accessToken: String, + domain: String, + service: String, + entityId: String, + serviceData: String, + ): KMResult<*> { + try { + val jsonBody = if (serviceData.isBlank()) { + JSONObject().apply { + put("entity_id", entityId) + }.toString() + } else { + val dataObj = JSONObject(serviceData) + dataObj.put("entity_id", entityId) + dataObj.toString() + } + + val request = Request.Builder() + .url("${serverUrl.trim().trimEnd('/')}/api/services/$domain/$service") + .post(jsonBody.toRequestBody("application/json".toMediaType())) + .addHeader("Authorization", "Bearer ${accessToken.trim()}") + .build() + + withContext(Dispatchers.IO) { httpClient.newCall(request).execute() } + .use { response -> + Timber.d(response.toString()) + + if (!response.isSuccessful) { + return KMError.UnknownIOError + } + + return Success(Unit) + } + } catch (e: IOException) { + Timber.e(e) + return KMError.UnknownIOError + } catch (e: IllegalArgumentException) { + return KMError.MalformedUrl + } catch (e: JSONException) { + Timber.e(e) + return KMError.UnknownIOError + } + } + override fun connectWifiNetwork() { val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { Intent(Settings.Panel.ACTION_WIFI) diff --git a/system/src/main/java/io/github/sds100/keymapper/system/network/HomeAssistantEntity.kt b/system/src/main/java/io/github/sds100/keymapper/system/network/HomeAssistantEntity.kt new file mode 100644 index 0000000000..a782774028 --- /dev/null +++ b/system/src/main/java/io/github/sds100/keymapper/system/network/HomeAssistantEntity.kt @@ -0,0 +1,8 @@ +package io.github.sds100.keymapper.system.network + +data class HomeAssistantEntity( + val entityId: String, + val friendlyName: String, + val domain: String, + val state: String, +) diff --git a/system/src/main/java/io/github/sds100/keymapper/system/network/NetworkAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/network/NetworkAdapter.kt index a68d3807a2..06f71a8daa 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/network/NetworkAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/network/NetworkAdapter.kt @@ -38,4 +38,18 @@ interface NetworkAdapter { body: String, authorizationHeader: String, ): KMResult<*> + + suspend fun fetchHomeAssistantEntities( + serverUrl: String, + accessToken: String, + ): KMResult> + + suspend fun callHomeAssistantService( + serverUrl: String, + accessToken: String, + domain: String, + service: String, + entityId: String, + serviceData: String, + ): KMResult<*> }