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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
3 changes: 3 additions & 0 deletions base/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1064,6 +1064,24 @@ sealed class ActionData : Comparable<ActionData> {
}
}

@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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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]!!
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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",

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ enum class ActionId {
SEND_SMS,
COMPOSE_SMS,
DEVICE_CONTROLS,
HOME_ASSISTANT_SERVICE,

FORCE_STOP_APP,
CLEAR_RECENT_APP,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ fun HandleActionBottomSheets(delegate: CreateActionDelegate) {
EnableFlashlightActionBottomSheet(delegate)
ChangeFlashlightStrengthActionBottomSheet(delegate)
HttpRequestBottomSheet(delegate)
HomeAssistantBottomSheet(delegate)
SmsActionBottomSheet(delegate)
VolumeActionBottomSheet(delegate)
ModifySettingActionBottomSheet(delegate)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<List<HomeAssistantEntity>> =
networkAdapter.fetchHomeAssistantEntities(serverUrl.trim().trimEnd('/'), accessToken.trim())

override suspend fun getInputMethods(): List<ImeInfo> = inputMethodAdapter.inputMethods.first()

override fun getFlashlightLenses(): Set<CameraLens> {
Expand Down Expand Up @@ -117,6 +132,14 @@ class CreateActionUseCaseImpl @Inject constructor(
}

interface CreateActionUseCase : IsActionSupportedUseCase {
val homeAssistantConnection: Flow<HomeAssistantConnection?>
fun saveHomeAssistantConnection(connection: HomeAssistantConnection)

suspend fun fetchHomeAssistantEntities(
serverUrl: String,
accessToken: String,
): KMResult<List<HomeAssistantEntity>>

suspend fun getInputMethods(): List<ImeInfo>

fun isFlashlightEnabled(): Flow<Boolean>
Expand Down
Loading