diff --git a/app/src/main/res/xml/changelog_master.xml b/app/src/main/res/xml/changelog_master.xml
index 24ed2aee086..6f46a3d0367 100755
--- a/app/src/main/res/xml/changelog_master.xml
+++ b/app/src/main/res/xml/changelog_master.xml
@@ -5,6 +5,7 @@
Bug fixes and dependency updates
+ Shortcut tiles now show state-aware icons (e.g., locked vs unlocked padlock)
Bug fixes and dependency updates
diff --git a/wear/src/main/kotlin/io/homeassistant/companion/android/tiles/ShortcutsTile.kt b/wear/src/main/kotlin/io/homeassistant/companion/android/tiles/ShortcutsTile.kt
index 5153d0017a1..b5218909abf 100644
--- a/wear/src/main/kotlin/io/homeassistant/companion/android/tiles/ShortcutsTile.kt
+++ b/wear/src/main/kotlin/io/homeassistant/companion/android/tiles/ShortcutsTile.kt
@@ -35,20 +35,31 @@ import com.mikepenz.iconics.utils.sizeDp
import dagger.hilt.android.AndroidEntryPoint
import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.common.R as commonR
+import io.homeassistant.companion.android.common.data.integration.Entity
+import io.homeassistant.companion.android.common.data.integration.getIcon
import io.homeassistant.companion.android.common.data.prefs.WearPrefsRepository
import io.homeassistant.companion.android.common.data.servers.ServerManager
import io.homeassistant.companion.android.data.SimplifiedEntity
import io.homeassistant.companion.android.util.getIcon
import java.nio.ByteBuffer
+import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import kotlin.math.min
import kotlin.math.roundToInt
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.guava.future
+import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
+import timber.log.Timber
// Dimensions (dp)
private const val CIRCLE_SIZE = 56f
@@ -58,6 +69,9 @@ private const val SPACING = 8f
private const val TEXT_SIZE = 8f
private const val TEXT_PADDING = 2f
+// Bounded so a cold-cache fetch cannot exceed the Wear tile render timeout (~3s).
+private const val CACHE_WARM_TIMEOUT_MS = 2000L
+
@AndroidEntryPoint
class ShortcutsTile : TileService() {
private val serviceJob = Job()
@@ -70,11 +84,11 @@ class ShortcutsTile : TileService() {
lateinit var wearPrefsRepository: WearPrefsRepository
override fun onTileRequest(requestParams: TileRequest): ListenableFuture = serviceScope.future {
- val state = requestParams.currentState
- if (state.lastClickableId.isNotEmpty()) {
+ val clicked = requestParams.currentState.lastClickableId
+ if (clicked.isNotEmpty()) {
Intent().also { intent ->
intent.action = "io.homeassistant.companion.android.TILE_ACTION"
- intent.putExtra("entity_id", state.lastClickableId)
+ intent.putExtra("entity_id", clicked)
intent.setPackage(packageName)
sendBroadcast(intent)
}
@@ -82,12 +96,35 @@ class ShortcutsTile : TileService() {
val tileId = requestParams.tileId
val entities = getEntities(tileId)
+ val entityIds = entities.map { it.entityId }
+ val isRegistered = serverManager.isRegistered()
+
+ if (isRegistered) {
+ val missing = entityIds.filterNot { entityStates.containsKey(it) }
+ if (missing.isNotEmpty()) {
+ warmEntityCache(missing)
+ }
+ }
+
+ // Optimistically flip the cached state so the render reflects the tap before the
+ // WebSocket subscription confirms. Required because `requestUpdate()` does not
+ // reliably trigger the Wear tile framework to re-render.
+ if (clicked.isNotEmpty()) {
+ entityStates.computeIfPresent(clicked) { _, current -> applyOptimisticClick(current) }
+ }
+
+ // Freeze state at the moment the layout is built; the matching resource bundle must
+ // use the same snapshot so every resource ID the layout references is produced.
+ val snapshot = TileSnapshot.from(entityStates, entityIds)
+ val resourcesVersion = "$TAG$tileId.${System.currentTimeMillis()}"
+ snapshotStash.put(resourcesVersion, snapshot)
Tile.Builder()
- .setResourcesVersion(entities.toString())
+ .setResourcesVersion(resourcesVersion)
.setTileTimeline(
- if (serverManager.isRegistered()) {
- timeline(tileId)
+ if (isRegistered) {
+ val showLabels = wearPrefsRepository.getShowShortcutText()
+ Timeline.fromLayoutElement(layout(entities, showLabels, snapshot))
} else {
loggedOutTimeline(
this@ShortcutsTile,
@@ -106,63 +143,50 @@ class ShortcutsTile : TileService() {
val density = requestParams.deviceConfiguration.screenDensity
val iconSizePx = (iconSize * density).roundToInt()
val entities = getEntities(requestParams.tileId)
+ // Reuse the snapshot frozen in onTileRequest so the resource IDs here match what
+ // the layout referenced. Falls back to current cache if the stash has been cleared.
+ val snapshot = snapshotStash.get(requestParams.version)
+ ?: TileSnapshot.from(entityStates, entities.map { it.entityId })
Resources.Builder()
- .setVersion(entities.toString())
+ .setVersion(requestParams.version)
.apply {
- entities.map { entity ->
- // Find icon and create Bitmap
- val iconIIcon = getIcon(
- entity.icon,
- entity.domain,
- this@ShortcutsTile,
+ entities.forEach { entity ->
+ val cachedEntity = snapshot.entityOf(entity.entityId)
+ val iconIIcon = if (cachedEntity != null) {
+ cachedEntity.getIcon(this@ShortcutsTile)
+ } else {
+ getIcon(entity.icon, entity.domain, this@ShortcutsTile)
+ }
+ addIdToImageMapping(
+ entity.resourceIdIn(snapshot),
+ buildIconResource(iconIIcon, iconSize, iconSizePx),
)
- val iconBitmap = IconicsDrawable(this@ShortcutsTile, iconIIcon).apply {
- colorInt = Color.WHITE
- sizeDp = iconSize.roundToInt()
- backgroundColor = IconicsColor.colorRes(R.color.colorOverlay)
- }.toBitmap(iconSizePx, iconSizePx, Bitmap.Config.RGB_565)
-
- // Make array of bitmap
- val bitmapData = ByteBuffer.allocate(iconBitmap.byteCount).apply {
- iconBitmap.copyPixelsToBuffer(this)
- }.array()
-
- // link the entity id to the bitmap data array
- entity.entityId to ResourceBuilders.ImageResource.Builder()
- .setInlineResource(
- ResourceBuilders.InlineImageResource.Builder()
- .setData(bitmapData)
- .setWidthPx(iconSizePx)
- .setHeightPx(iconSizePx)
- .setFormat(ResourceBuilders.IMAGE_FORMAT_RGB_565)
- .build(),
- )
- .build()
- }.forEach { (id, imageResource) ->
- addIdToImageMapping(id, imageResource)
}
}
.build()
}
+ override fun onTileEnterEvent(requestParams: EventBuilders.TileEnterEvent) {
+ serviceScope.launch {
+ val entities = getEntities(requestParams.tileId)
+ // onTileRequest warms the cache on cold start; enter is only for subscription
+ // lifecycle. Always restart so the subscription reflects the current entity list.
+ stopEntitySubscription()
+ startEntitySubscription(
+ serverManager = serverManager,
+ entityIds = entities.map { it.entityId },
+ context = this@ShortcutsTile,
+ )
+ }
+ }
+
+ override fun onTileLeaveEvent(requestParams: EventBuilders.TileLeaveEvent) {
+ stopEntitySubscription()
+ }
+
override fun onTileAddEvent(requestParams: EventBuilders.TileAddEvent): Unit = runBlocking {
withContext(Dispatchers.IO) {
- /**
- * When the app is updated from an older version (which only supported a single Shortcut Tile),
- * and the user is adding a new Shortcuts Tile, we can't tell for sure if it's the 1st or 2nd Tile.
- * Even though we may have the shortcut list stored in the prefs, it doesn't guarantee that
- * the tile was actually added to the Tiles carousel.
- * The [WearPrefsRepositoryImpl::getTileShortcutsAndSaveTileId] method will handle both of the following cases:
- * 1. There was no Tile added, but there were shortcuts stored in the prefs.
- * In this case, the stored shortcuts will be associated to the new tileId.
- * 2. There was a single Tile added, and there were shortcuts stored in the prefs.
- * If there was a Tile update since updating the app, the tileId will be already
- * associated to the shortcuts, because it also calls [getTileShortcutsAndSaveTileId].
- * If there was no Tile update yet, the new Tile will "steal" the shortcuts from the existing Tile,
- * and the old Tile will behave as it is the new Tile. This is needed because
- * we don't know if it's the 1st or 2nd Tile.
- */
wearPrefsRepository.getTileShortcutsAndSaveTileId(requestParams.tileId)
}
}
@@ -171,11 +195,13 @@ class ShortcutsTile : TileService() {
withContext(Dispatchers.IO) {
wearPrefsRepository.removeTileShortcuts(requestParams.tileId)
}
+ stopEntitySubscription()
+ entityStates.clear()
+ snapshotStash.clear()
}
override fun onDestroy() {
super.onDestroy()
- // Cleans up the coroutine
serviceJob.cancel()
}
@@ -183,110 +209,201 @@ class ShortcutsTile : TileService() {
return wearPrefsRepository.getTileShortcutsAndSaveTileId(tileId).map { SimplifiedEntity(it) }
}
- private suspend fun timeline(tileId: Int): Timeline {
- val entities = getEntities(tileId)
- val showLabels = wearPrefsRepository.getShowShortcutText()
-
- return Timeline.fromLayoutElement(layout(entities, showLabels))
+ /**
+ * Fetches state for [entityIds] in parallel and writes successful results into the cache.
+ * Bounded by [CACHE_WARM_TIMEOUT_MS] so slow networks can't exceed the Wear tile render
+ * timeout — entities whose fetch hasn't landed in time simply stay missing from the cache
+ * and render with their domain-default icon.
+ */
+ private suspend fun warmEntityCache(entityIds: List) {
+ val repo = serverManager.integrationRepository()
+ try {
+ withTimeoutOrNull(CACHE_WARM_TIMEOUT_MS) {
+ coroutineScope {
+ entityIds.map { id ->
+ async {
+ runCatching { repo.getEntity(id) }.getOrNull()?.let { entity ->
+ entityStates[entity.entityId] = entity
+ }
+ }
+ }.awaitAll()
+ }
+ }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ Timber.w(e, "Failed to warm entity cache")
+ }
}
- fun layout(entities: List, showLabels: Boolean): LayoutElement = Column.Builder().apply {
- if (entities.isEmpty()) {
- addContent(
- LayoutElementBuilders.Text.Builder()
- .setText(getString(commonR.string.shortcuts_tile_empty))
+ private fun buildIconResource(
+ icon: com.mikepenz.iconics.typeface.IIcon,
+ iconSizeDp: Float,
+ iconSizePx: Int,
+ ): ResourceBuilders.ImageResource {
+ val bitmap = IconicsDrawable(this, icon).apply {
+ colorInt = Color.WHITE
+ sizeDp = iconSizeDp.roundToInt()
+ backgroundColor = IconicsColor.colorRes(R.color.colorOverlay)
+ }.toBitmap(iconSizePx, iconSizePx, Bitmap.Config.RGB_565)
+ val data = ByteBuffer.allocate(bitmap.byteCount).apply {
+ bitmap.copyPixelsToBuffer(this)
+ }.array()
+ return ResourceBuilders.ImageResource.Builder()
+ .setInlineResource(
+ ResourceBuilders.InlineImageResource.Builder()
+ .setData(data)
+ .setWidthPx(iconSizePx)
+ .setHeightPx(iconSizePx)
+ .setFormat(ResourceBuilders.IMAGE_FORMAT_RGB_565)
.build(),
- )
- } else {
- addContent(rowLayout(entities.subList(0, min(2, entities.size)), showLabels))
- if (entities.size > 2) {
- addContent(Spacer.Builder().setHeight(dp(SPACING)).build())
- addContent(rowLayout(entities.subList(2, min(5, entities.size)), showLabels))
- }
- if (entities.size > 5) {
- addContent(Spacer.Builder().setHeight(dp(SPACING)).build())
- addContent(rowLayout(entities.subList(5, min(7, entities.size)), showLabels))
+ ).build()
+ }
+
+ internal fun layout(entities: List, showLabels: Boolean, snapshot: TileSnapshot): LayoutElement =
+ Column.Builder().apply {
+ if (entities.isEmpty()) {
+ addContent(
+ LayoutElementBuilders.Text.Builder()
+ .setText(getString(commonR.string.shortcuts_tile_empty))
+ .build(),
+ )
+ } else {
+ addContent(rowLayout(entities.subList(0, min(2, entities.size)), showLabels, snapshot))
+ if (entities.size > 2) {
+ addContent(Spacer.Builder().setHeight(dp(SPACING)).build())
+ addContent(rowLayout(entities.subList(2, min(5, entities.size)), showLabels, snapshot))
+ }
+ if (entities.size > 5) {
+ addContent(Spacer.Builder().setHeight(dp(SPACING)).build())
+ addContent(rowLayout(entities.subList(5, min(7, entities.size)), showLabels, snapshot))
+ }
}
}
- }
- .build()
+ .build()
- private fun rowLayout(entities: List, showLabels: Boolean): LayoutElement = Row.Builder().apply {
- addContent(iconLayout(entities[0], showLabels))
+ private fun rowLayout(
+ entities: List,
+ showLabels: Boolean,
+ snapshot: TileSnapshot,
+ ): LayoutElement = Row.Builder().apply {
+ addContent(iconLayout(entities[0], showLabels, snapshot))
entities.drop(1).forEach { entity ->
addContent(Spacer.Builder().setWidth(dp(SPACING)).build())
- addContent(iconLayout(entity, showLabels))
+ addContent(iconLayout(entity, showLabels, snapshot))
}
}
.build()
- private fun iconLayout(entity: SimplifiedEntity, showLabels: Boolean): LayoutElement = Box.Builder().apply {
- val iconSize = if (showLabels) ICON_SIZE_SMALL else ICON_SIZE_FULL
- setWidth(dp(CIRCLE_SIZE))
- setHeight(dp(CIRCLE_SIZE))
- setHorizontalAlignment(HORIZONTAL_ALIGN_CENTER)
- setModifiers(
- ModifiersBuilders.Modifiers.Builder()
- // Set circular background
- .setBackground(
- ModifiersBuilders.Background.Builder()
- .setColor(argb(ContextCompat.getColor(baseContext, R.color.colorOverlay)))
- .setCorner(
- ModifiersBuilders.Corner.Builder()
- .setRadius(dp(CIRCLE_SIZE / 2))
- .build(),
- )
- .build(),
- )
- // Make clickable and call activity
- .setClickable(
- ModifiersBuilders.Clickable.Builder()
- .setId(entity.entityId)
- .setOnClick(
- ActionBuilders.LoadAction.Builder().build(),
- )
- .build(),
- )
- .build(),
- )
- addContent(
- // Add icon
- LayoutElementBuilders.Image.Builder()
- .setResourceId(entity.entityId)
- .setWidth(dp(iconSize))
- .setHeight(dp(iconSize))
- .build(),
- )
- if (showLabels) {
- addContent(
- LayoutElementBuilders.Arc.Builder()
- .addContent(
- LayoutElementBuilders.ArcText.Builder()
- .setText(entity.friendlyName)
- .setFontStyle(
- LayoutElementBuilders.FontStyle.Builder()
- .setSize(sp(TEXT_SIZE))
+ private fun iconLayout(entity: SimplifiedEntity, showLabels: Boolean, snapshot: TileSnapshot): LayoutElement =
+ Box.Builder().apply {
+ val iconSize = if (showLabels) ICON_SIZE_SMALL else ICON_SIZE_FULL
+ val resourceId = entity.resourceIdIn(snapshot)
+ setWidth(dp(CIRCLE_SIZE))
+ setHeight(dp(CIRCLE_SIZE))
+ setHorizontalAlignment(HORIZONTAL_ALIGN_CENTER)
+ setModifiers(
+ ModifiersBuilders.Modifiers.Builder()
+ .setBackground(
+ ModifiersBuilders.Background.Builder()
+ .setColor(argb(ContextCompat.getColor(baseContext, R.color.colorOverlay)))
+ .setCorner(
+ ModifiersBuilders.Corner.Builder()
+ .setRadius(dp(CIRCLE_SIZE / 2))
.build(),
)
.build(),
)
- .setModifiers(
- ModifiersBuilders.Modifiers.Builder()
- .setPadding(
- ModifiersBuilders.Padding.Builder()
- .setAll(dp(TEXT_PADDING))
- .build(),
- ).build(),
+ .setClickable(
+ ModifiersBuilders.Clickable.Builder()
+ .setId(entity.entityId)
+ .setOnClick(
+ ActionBuilders.LoadAction.Builder().build(),
+ )
+ .build(),
)
.build(),
)
+ addContent(
+ LayoutElementBuilders.Image.Builder()
+ .setResourceId(resourceId)
+ .setWidth(dp(iconSize))
+ .setHeight(dp(iconSize))
+ .build(),
+ )
+ if (showLabels) {
+ addContent(
+ LayoutElementBuilders.Arc.Builder()
+ .addContent(
+ LayoutElementBuilders.ArcText.Builder()
+ .setText(entity.friendlyName)
+ .setFontStyle(
+ LayoutElementBuilders.FontStyle.Builder()
+ .setSize(sp(TEXT_SIZE))
+ .build(),
+ )
+ .build(),
+ )
+ .setModifiers(
+ ModifiersBuilders.Modifiers.Builder()
+ .setPadding(
+ ModifiersBuilders.Padding.Builder()
+ .setAll(dp(TEXT_PADDING))
+ .build(),
+ ).build(),
+ )
+ .build(),
+ )
+ }
}
- }
- .build()
+ .build()
companion object {
+ private const val TAG = "ShortcutsTile"
+
+ /**
+ * Entity cache shared across service instances. Populated by the WebSocket subscription
+ * while a tile is visible, warmed on cold start by bounded parallel REST fetches, and
+ * updated optimistically on tap. Read-only on the tile render path.
+ */
+ private val entityStates = ConcurrentHashMap()
+
+ /**
+ * Snapshots of [entityStates] keyed by the resources version issued in `onTileRequest`.
+ * Ensures the matching `onTileResourcesRequest` renders bitmaps for the same state the
+ * layout referenced, even if the live cache mutated in between.
+ */
+ private val snapshotStash = SnapshotStash()
+
+ /** WebSocket subscription for entity state changes. Outlives service instances. */
+ private var subscriptionJob: Job? = null
+ private val subscriptionScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+
fun requestUpdate(context: Context) {
getUpdater(context).requestUpdate(ShortcutsTile::class.java)
}
+
+ private fun startEntitySubscription(serverManager: ServerManager, entityIds: List, context: Context) {
+ if (subscriptionJob?.isActive == true) return
+ val appContext = context.applicationContext
+ subscriptionJob = subscriptionScope.launch {
+ try {
+ val flow = serverManager.integrationRepository().getEntityUpdates(entityIds) ?: return@launch
+ flow.collect { entity ->
+ entityStates[entity.entityId] = entity
+ requestUpdate(appContext)
+ }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ Timber.w(e, "Entity subscription failed")
+ }
+ }
+ }
+
+ private fun stopEntitySubscription() {
+ subscriptionJob?.cancel()
+ subscriptionJob = null
+ }
}
}
diff --git a/wear/src/main/kotlin/io/homeassistant/companion/android/tiles/ShortcutsTileState.kt b/wear/src/main/kotlin/io/homeassistant/companion/android/tiles/ShortcutsTileState.kt
new file mode 100644
index 00000000000..1c07451c014
--- /dev/null
+++ b/wear/src/main/kotlin/io/homeassistant/companion/android/tiles/ShortcutsTileState.kt
@@ -0,0 +1,128 @@
+package io.homeassistant.companion.android.tiles
+
+import io.homeassistant.companion.android.common.data.integration.Entity
+import io.homeassistant.companion.android.data.SimplifiedEntity
+
+/**
+ * Pairs of reciprocal states that a toggle tap is expected to swap between. If the entity's
+ * current state matches one side of a pair for its domain, the tap is predicted to produce
+ * the other side.
+ *
+ * Why: `requestUpdate()` after a WebSocket state change does not reliably trigger the Wear
+ * tile framework to re-render, so users see the previous state until an unrelated event
+ * forces a refresh. Predicting the post-click state in `onTileRequest` makes the tap feel
+ * immediate. If the server ends up in a different state (e.g. a lock stuck mid-transition),
+ * the subscription will overwrite the cache and the display converges next refresh.
+ */
+private val TOGGLE_STATE_PAIRS: Map> = mapOf(
+ "switch" to ("on" to "off"),
+ "light" to ("on" to "off"),
+ "input_boolean" to ("on" to "off"),
+ "fan" to ("on" to "off"),
+ "lock" to ("locked" to "unlocked"),
+ "cover" to ("open" to "closed"),
+)
+
+/**
+ * Returns the state an entity is expected to be in after a single click, or null if the
+ * domain isn't a known toggle or the current state isn't a recognized side of its pair.
+ * Conservative on purpose: unknown inputs leave the cache alone.
+ */
+internal fun predictedStateAfterClick(entity: Entity): String? {
+ val pair = TOGGLE_STATE_PAIRS[entity.domain] ?: return null
+ return when (entity.state) {
+ pair.first -> pair.second
+ pair.second -> pair.first
+ else -> null
+ }
+}
+
+/**
+ * Returns a copy of [entity] with its state flipped per [predictedStateAfterClick], or the
+ * input unchanged if no prediction is possible.
+ */
+internal fun applyOptimisticClick(entity: Entity): Entity {
+ val predicted = predictedStateAfterClick(entity) ?: return entity
+ return entity.copy(state = predicted)
+}
+
+/**
+ * Frozen view of entity states used to render a single tile response.
+ *
+ * `onTileRequest` and `onTileResourcesRequest` are separate framework calls that both need
+ * a consistent view of entity state. If each reads the live cache independently, a WebSocket
+ * update in between can cause the layout to reference a resource ID that the resource bundle
+ * never generated — producing stale or missing bitmaps on screen.
+ *
+ * A snapshot is built at `onTileRequest` time, stashed by resources version, and looked up
+ * again when the matching `onTileResourcesRequest` arrives.
+ */
+internal data class TileSnapshot(val entityStates: Map) {
+ /** State string for an entity, or null if the cache has no entry. */
+ fun stateOf(entityId: String): String? = entityStates[entityId]?.state
+
+ fun entityOf(entityId: String): Entity? = entityStates[entityId]
+
+ companion object {
+ /**
+ * Take a snapshot from a live cache, copying values for the given entity IDs.
+ * Missing entries are included as null so the snapshot is complete for the tile.
+ */
+ fun from(liveCache: Map, entityIds: Iterable): TileSnapshot =
+ TileSnapshot(entityIds.associateWith { liveCache[it] })
+ }
+}
+
+/**
+ * Resource ID used in the tile layout. The state suffix ensures Wear OS treats different
+ * states as distinct resources, preventing the bitmap cache from serving a stale icon when
+ * the state changes.
+ */
+internal fun resourceIdForEntity(entityId: String, state: String?): String =
+ if (state.isNullOrEmpty()) entityId else "$entityId@$state"
+
+/** Resource ID for an entity in the given snapshot. */
+internal fun SimplifiedEntity.resourceIdIn(snapshot: TileSnapshot): String =
+ resourceIdForEntity(entityId, snapshot.stateOf(entityId))
+
+/**
+ * The set of resource IDs required by a tile layout for the given entities and snapshot.
+ * Used as the invariant: every ID the layout references must be present in the resource bundle.
+ */
+internal fun requiredResourceIds(entities: List, snapshot: TileSnapshot): Set =
+ entities.map { it.resourceIdIn(snapshot) }.toSet()
+
+/**
+ * Bounded cache of snapshots keyed by resources version string.
+ *
+ * Wear OS may request resources for an older version than the latest tile render, especially
+ * when multiple `requestUpdate()` calls arrive quickly. Keeping the last few snapshots lets
+ * those requests resolve to the correct frozen state instead of the current live cache.
+ */
+internal class SnapshotStash(private val maxEntries: Int = 4) {
+ private val entries = linkedMapOf()
+
+ @Synchronized
+ fun put(version: String, snapshot: TileSnapshot) {
+ entries.remove(version)
+ entries[version] = snapshot
+ while (entries.size > maxEntries) {
+ val oldest = entries.keys.iterator()
+ if (oldest.hasNext()) {
+ oldest.next()
+ oldest.remove()
+ }
+ }
+ }
+
+ @Synchronized
+ fun get(version: String): TileSnapshot? = entries[version]
+
+ @Synchronized
+ fun clear() {
+ entries.clear()
+ }
+
+ @Synchronized
+ fun size(): Int = entries.size
+}
diff --git a/wear/src/test/kotlin/io/homeassistant/companion/android/tiles/ShortcutsTileStateTest.kt b/wear/src/test/kotlin/io/homeassistant/companion/android/tiles/ShortcutsTileStateTest.kt
new file mode 100644
index 00000000000..08e3d2695f4
--- /dev/null
+++ b/wear/src/test/kotlin/io/homeassistant/companion/android/tiles/ShortcutsTileStateTest.kt
@@ -0,0 +1,271 @@
+package io.homeassistant.companion.android.tiles
+
+import io.homeassistant.companion.android.common.data.integration.Entity
+import io.homeassistant.companion.android.data.SimplifiedEntity
+import java.time.LocalDateTime
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertNotEquals
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.Assertions.assertSame
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+class ShortcutsTileStateTest {
+
+ private val epoch = LocalDateTime.of(2026, 1, 1, 0, 0)
+
+ private fun entity(id: String, state: String): Entity = Entity(
+ entityId = id,
+ state = state,
+ attributes = emptyMap(),
+ lastChanged = epoch,
+ lastUpdated = epoch,
+ )
+
+ @Test
+ fun `resourceIdForEntity omits separator when state is null`() {
+ assertEquals("switch.plug_a", resourceIdForEntity("switch.plug_a", null))
+ }
+
+ @Test
+ fun `resourceIdForEntity omits separator when state is empty`() {
+ assertEquals("switch.plug_a", resourceIdForEntity("switch.plug_a", ""))
+ }
+
+ @Test
+ fun `resourceIdForEntity includes state suffix for distinct states`() {
+ val on = resourceIdForEntity("switch.plug_a", "on")
+ val off = resourceIdForEntity("switch.plug_a", "off")
+ assertEquals("switch.plug_a@on", on)
+ assertEquals("switch.plug_a@off", off)
+ assertNotEquals(on, off)
+ }
+
+ @Test
+ fun `snapshot includes every requested entity id`() {
+ val cache = mapOf(
+ "switch.plug_a" to entity("switch.plug_a", "on"),
+ "lock.front" to entity("lock.front", "locked"),
+ )
+ val snapshot = TileSnapshot.from(cache, listOf("switch.plug_a", "lock.front", "light.missing"))
+
+ assertEquals("on", snapshot.stateOf("switch.plug_a"))
+ assertEquals("locked", snapshot.stateOf("lock.front"))
+ assertNull(snapshot.stateOf("light.missing"))
+ }
+
+ @Test
+ fun `snapshot is decoupled from later cache mutations`() {
+ val cache = mutableMapOf("switch.plug_a" to entity("switch.plug_a", "on"))
+ val snapshot = TileSnapshot.from(cache, listOf("switch.plug_a"))
+
+ cache["switch.plug_a"] = entity("switch.plug_a", "off")
+
+ assertEquals("on", snapshot.stateOf("switch.plug_a"))
+ }
+
+ @Test
+ fun `requiredResourceIds matches resource ids produced by iconLayout inputs`() {
+ val entities = listOf(
+ SimplifiedEntity(entityId = "switch.plug_a"),
+ SimplifiedEntity(entityId = "lock.front"),
+ )
+ val snapshot = TileSnapshot.from(
+ mapOf(
+ "switch.plug_a" to entity("switch.plug_a", "on"),
+ "lock.front" to entity("lock.front", "locked"),
+ ),
+ entities.map { it.entityId },
+ )
+
+ val ids = requiredResourceIds(entities, snapshot)
+
+ assertTrue("switch.plug_a@on" in ids)
+ assertTrue("lock.front@locked" in ids)
+ assertEquals(2, ids.size)
+ }
+
+ @Test
+ fun `requiredResourceIds differs when only one entity state changes between snapshots`() {
+ val entities = listOf(SimplifiedEntity(entityId = "switch.plug_a"), SimplifiedEntity(entityId = "lock.front"))
+
+ val s1 = TileSnapshot.from(
+ mapOf(
+ "switch.plug_a" to entity("switch.plug_a", "on"),
+ "lock.front" to entity("lock.front", "locked"),
+ ),
+ entities.map { it.entityId },
+ )
+ val s2 = TileSnapshot.from(
+ mapOf(
+ "switch.plug_a" to entity("switch.plug_a", "off"),
+ "lock.front" to entity("lock.front", "locked"),
+ ),
+ entities.map { it.entityId },
+ )
+
+ assertNotEquals(requiredResourceIds(entities, s1), requiredResourceIds(entities, s2))
+ }
+
+ @Test
+ fun `snapshot stash returns the exact snapshot that was stored`() {
+ val stash = SnapshotStash()
+ val snapshot = TileSnapshot.from(mapOf("a" to entity("a", "1")), listOf("a"))
+ stash.put("v1", snapshot)
+
+ assertSame(snapshot, stash.get("v1"))
+ }
+
+ @Test
+ fun `snapshot stash is resistant to live cache mutation between put and get`() {
+ val stash = SnapshotStash()
+ val cache = mutableMapOf("a" to entity("a", "1"))
+ val snapshot = TileSnapshot.from(cache, listOf("a"))
+ stash.put("v1", snapshot)
+
+ cache["a"] = entity("a", "2")
+ val retrieved = stash.get("v1")
+
+ assertEquals("1", retrieved?.stateOf("a"))
+ }
+
+ @Test
+ fun `snapshot stash evicts oldest entries past the bound`() {
+ val stash = SnapshotStash(maxEntries = 2)
+ stash.put("v1", TileSnapshot.from(emptyMap(), emptyList()))
+ stash.put("v2", TileSnapshot.from(emptyMap(), emptyList()))
+ stash.put("v3", TileSnapshot.from(emptyMap(), emptyList()))
+
+ assertNull(stash.get("v1"))
+ assertEquals(2, stash.size())
+ }
+
+ @Test
+ fun `snapshot stash clear removes all entries`() {
+ val stash = SnapshotStash()
+ stash.put("v1", TileSnapshot.from(emptyMap(), emptyList()))
+ stash.put("v2", TileSnapshot.from(emptyMap(), emptyList()))
+ stash.clear()
+
+ assertNull(stash.get("v1"))
+ assertNull(stash.get("v2"))
+ }
+
+ @Test
+ fun `put with existing version replaces snapshot and keeps size bounded`() {
+ val stash = SnapshotStash(maxEntries = 2)
+ val first = TileSnapshot.from(mapOf("a" to entity("a", "1")), listOf("a"))
+ val second = TileSnapshot.from(mapOf("a" to entity("a", "2")), listOf("a"))
+
+ stash.put("v1", first)
+ stash.put("v1", second)
+
+ assertSame(second, stash.get("v1"))
+ assertEquals(1, stash.size())
+ }
+
+ /**
+ * Mimics the runtime race: tile request builds layout from snapshot S1, WebSocket update
+ * mutates cache to S2 before resources request arrives. Invariant: the layout's required
+ * resource IDs must all be satisfiable from the stashed snapshot, regardless of current cache.
+ */
+ @Test
+ fun `mutation between tile request and resources request does not break invariant`() {
+ val entities = listOf(SimplifiedEntity(entityId = "switch.plug_a"))
+ val liveCache = mutableMapOf("switch.plug_a" to entity("switch.plug_a", "on"))
+ val stash = SnapshotStash()
+
+ val versionA = "v-1"
+ val snapshotA = TileSnapshot.from(liveCache, entities.map { it.entityId })
+ stash.put(versionA, snapshotA)
+ val layoutIds = requiredResourceIds(entities, snapshotA)
+
+ liveCache["switch.plug_a"] = entity("switch.plug_a", "off")
+
+ val retrievedSnapshot = stash.get(versionA)!!
+ val resourceIds = entities.map { it.resourceIdIn(retrievedSnapshot) }.toSet()
+
+ assertEquals(layoutIds, resourceIds)
+ assertTrue("switch.plug_a@on" in resourceIds)
+ }
+
+ @Test
+ fun `predictedStateAfterClick flips switch between on and off`() {
+ assertEquals("off", predictedStateAfterClick(entity("switch.plug_a", "on")))
+ assertEquals("on", predictedStateAfterClick(entity("switch.plug_a", "off")))
+ }
+
+ @Test
+ fun `predictedStateAfterClick flips light between on and off`() {
+ assertEquals("off", predictedStateAfterClick(entity("light.living_room", "on")))
+ assertEquals("on", predictedStateAfterClick(entity("light.living_room", "off")))
+ }
+
+ @Test
+ fun `predictedStateAfterClick flips lock between locked and unlocked`() {
+ assertEquals("unlocked", predictedStateAfterClick(entity("lock.front", "locked")))
+ assertEquals("locked", predictedStateAfterClick(entity("lock.front", "unlocked")))
+ }
+
+ @Test
+ fun `predictedStateAfterClick flips cover between open and closed`() {
+ assertEquals("closed", predictedStateAfterClick(entity("cover.garage", "open")))
+ assertEquals("open", predictedStateAfterClick(entity("cover.garage", "closed")))
+ }
+
+ @Test
+ fun `predictedStateAfterClick flips input_boolean and fan`() {
+ assertEquals("off", predictedStateAfterClick(entity("input_boolean.flag", "on")))
+ assertEquals("on", predictedStateAfterClick(entity("fan.bedroom", "off")))
+ }
+
+ @Test
+ fun `predictedStateAfterClick returns null for unknown domain`() {
+ assertNull(predictedStateAfterClick(entity("sensor.temperature", "on")))
+ assertNull(predictedStateAfterClick(entity("climate.hvac", "heat")))
+ }
+
+ @Test
+ fun `predictedStateAfterClick returns null for unrecognized state`() {
+ assertNull(predictedStateAfterClick(entity("lock.front", "jammed")))
+ assertNull(predictedStateAfterClick(entity("switch.plug_a", "unavailable")))
+ }
+
+ @Test
+ fun `applyOptimisticClick returns new entity with flipped state for toggles`() {
+ val before = entity("switch.plug_a", "on")
+ val after = applyOptimisticClick(before)
+ assertEquals("off", after.state)
+ assertNotEquals(before.state, after.state)
+ }
+
+ @Test
+ fun `applyOptimisticClick returns input unchanged when state is not predictable`() {
+ val before = entity("sensor.temperature", "22.5")
+ val after = applyOptimisticClick(before)
+ assertSame(before, after)
+ }
+
+ @Test
+ fun `applyOptimisticClick returns input unchanged for unrecognized state of known domain`() {
+ val before = entity("lock.front", "jammed")
+ val after = applyOptimisticClick(before)
+ assertSame(before, after)
+ }
+
+ /**
+ * End-to-end contract: after a click, a snapshot built from the optimistically-updated
+ * cache must reference the predicted resource ID — that's what fixes the "display lags
+ * reality by one tap" symptom.
+ */
+ @Test
+ fun `snapshot after optimistic click points to predicted resource id`() {
+ val cache = mutableMapOf("switch.plug_a" to entity("switch.plug_a", "on"))
+ cache["switch.plug_a"] = applyOptimisticClick(cache["switch.plug_a"]!!)
+
+ val snapshot = TileSnapshot.from(cache, listOf("switch.plug_a"))
+ val entities = listOf(SimplifiedEntity(entityId = "switch.plug_a"))
+
+ assertTrue("switch.plug_a@off" in requiredResourceIds(entities, snapshot))
+ }
+}