Skip to content

Commit f4582c4

Browse files
kdavhclaude
authored andcommitted
Add state-aware resource versioning and loading indicator for shortcut tiles
Include entity state in the tile resource version string so Wear OS invalidates cached icon bitmaps when entity state changes. Previously the version only reflected which entities were configured, causing stale icons to persist indefinitely. Also add a loading indicator (progress clock icon) when an entity is tapped. The tile polls for state changes at 1s and 4s after tap, clearing the loading state once the entity state has changed or after a 5s timeout. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9189b98 commit f4582c4

1 file changed

Lines changed: 111 additions & 3 deletions

File tree

wear/src/main/kotlin/io/homeassistant/companion/android/tiles/ShortcutsTile.kt

Lines changed: 111 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
@file:OptIn(ExperimentalTime::class)
2+
13
package io.homeassistant.companion.android.tiles
24

35
import android.content.Context
@@ -29,6 +31,7 @@ import androidx.wear.tiles.TileService
2931
import com.google.common.util.concurrent.ListenableFuture
3032
import com.mikepenz.iconics.IconicsColor
3133
import com.mikepenz.iconics.IconicsDrawable
34+
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial.Icon3
3235
import com.mikepenz.iconics.utils.backgroundColor
3336
import com.mikepenz.iconics.utils.colorInt
3437
import com.mikepenz.iconics.utils.sizeDp
@@ -45,11 +48,17 @@ import javax.inject.Inject
4548
import kotlin.coroutines.cancellation.CancellationException
4649
import kotlin.math.min
4750
import kotlin.math.roundToInt
51+
import kotlin.time.Clock
52+
import kotlin.time.Duration.Companion.seconds
53+
import kotlin.time.ExperimentalTime
54+
import kotlin.time.Instant
4855
import kotlinx.coroutines.CoroutineScope
4956
import kotlinx.coroutines.Dispatchers
5057
import kotlinx.coroutines.Job
5158
import kotlinx.coroutines.async
59+
import kotlinx.coroutines.delay
5260
import kotlinx.coroutines.guava.future
61+
import kotlinx.coroutines.launch
5362
import kotlinx.coroutines.runBlocking
5463
import kotlinx.coroutines.withContext
5564
import timber.log.Timber
@@ -61,6 +70,8 @@ private const val ICON_SIZE_SMALL = 40f * 0.7071f // square that fits in 48dp ci
6170
private const val SPACING = 8f
6271
private const val TEXT_SIZE = 8f
6372
private const val TEXT_PADDING = 2f
73+
private val LOADING_TIMEOUT = 5.seconds
74+
private const val LOADING_RESOURCE_SUFFIX = "_loading"
6475

6576
@AndroidEntryPoint
6677
class ShortcutsTile : TileService() {
@@ -73,6 +84,13 @@ class ShortcutsTile : TileService() {
7384
@Inject
7485
lateinit var wearPrefsRepository: WearPrefsRepository
7586

87+
@Inject
88+
lateinit var clock: Clock
89+
90+
private var pendingEntityId: String? = null
91+
private var pendingOriginalState: String? = null
92+
private var pendingTimestamp: Instant = Instant.DISTANT_PAST
93+
7694
override fun onTileRequest(requestParams: TileRequest): ListenableFuture<Tile> = serviceScope.future {
7795
val state = requestParams.currentState
7896
if (state.lastClickableId.isNotEmpty()) {
@@ -82,13 +100,74 @@ class ShortcutsTile : TileService() {
82100
intent.setPackage(packageName)
83101
sendBroadcast(intent)
84102
}
103+
104+
// Enter loading state: store original state so we detect when it changes
105+
pendingEntityId = state.lastClickableId
106+
pendingTimestamp = clock.now()
107+
pendingOriginalState = if (serverManager.isRegistered()) {
108+
try {
109+
serverManager.integrationRepository().getEntity(state.lastClickableId)?.state
110+
} catch (e: CancellationException) {
111+
throw e
112+
} catch (e: Exception) {
113+
Timber.w(e, "Failed to fetch original state for ${state.lastClickableId}")
114+
null
115+
}
116+
} else {
117+
null
118+
}
119+
120+
// Schedule polling re-renders to pick up state change
121+
serviceScope.launch {
122+
delay(1.seconds)
123+
requestUpdate(this@ShortcutsTile)
124+
delay(3.seconds)
125+
requestUpdate(this@ShortcutsTile)
126+
}
85127
}
86128

87129
val tileId = requestParams.tileId
88130
val entities = getEntities(tileId)
89131

132+
// Fetch entity states for resource version — ensures cache invalidation on state change
133+
val entityStatesMap = if (serverManager.isRegistered()) {
134+
entities.map { entity ->
135+
async {
136+
try {
137+
val e = serverManager.integrationRepository().getEntity(entity.entityId)
138+
entity.entityId to (e?.state ?: "unknown")
139+
} catch (e: CancellationException) {
140+
throw e
141+
} catch (e: Exception) {
142+
Timber.w(e, "Failed to fetch entity ${entity.entityId} state for tile version")
143+
entity.entityId to "unknown"
144+
}
145+
}
146+
}.map { it.await() }.toMap()
147+
} else {
148+
emptyMap()
149+
}
150+
151+
// Clear loading state if entity state has changed or timeout exceeded
152+
if (pendingEntityId != null) {
153+
val elapsed = clock.now() - pendingTimestamp
154+
val currentState = entityStatesMap[pendingEntityId]
155+
if (elapsed > LOADING_TIMEOUT ||
156+
(pendingOriginalState != null && currentState != null && currentState != pendingOriginalState)
157+
) {
158+
pendingEntityId = null
159+
pendingOriginalState = null
160+
}
161+
}
162+
163+
val entityStatesVersion = entityStatesMap.entries
164+
.sortedBy { it.key }
165+
.joinToString(",") { "${it.key}=${it.value}" }
166+
val loadingSuffix = pendingEntityId?.let { "|loading:$it" }.orEmpty()
167+
val resourcesVersion = "$entities|$entityStatesVersion$loadingSuffix"
168+
90169
Tile.Builder()
91-
.setResourcesVersion(entities.toString())
170+
.setResourcesVersion(resourcesVersion)
92171
.setTileTimeline(
93172
if (serverManager.isRegistered()) {
94173
timeline(tileId)
@@ -130,7 +209,7 @@ class ShortcutsTile : TileService() {
130209
}
131210

132211
Resources.Builder()
133-
.setVersion(entities.toString())
212+
.setVersion(requestParams.version)
134213
.apply {
135214
entities.map { entity ->
136215
// Find icon: try state-aware icon from full entity, fall back to domain icon
@@ -169,6 +248,30 @@ class ShortcutsTile : TileService() {
169248
}.forEach { (id, imageResource) ->
170249
addIdToImageMapping(id, imageResource)
171250
}
251+
252+
// Generate loading icon for the pending entity
253+
pendingEntityId?.let { loadingId ->
254+
val loadingBitmap = IconicsDrawable(this@ShortcutsTile, Icon3.cmd_progress_clock).apply {
255+
colorInt = Color.WHITE
256+
sizeDp = iconSize.roundToInt()
257+
backgroundColor = IconicsColor.colorRes(R.color.colorOverlay)
258+
}.toBitmap(iconSizePx, iconSizePx, Bitmap.Config.RGB_565)
259+
val loadingData = ByteBuffer.allocate(loadingBitmap.byteCount).apply {
260+
loadingBitmap.copyPixelsToBuffer(this)
261+
}.array()
262+
addIdToImageMapping(
263+
loadingId + LOADING_RESOURCE_SUFFIX,
264+
ResourceBuilders.ImageResource.Builder()
265+
.setInlineResource(
266+
ResourceBuilders.InlineImageResource.Builder()
267+
.setData(loadingData)
268+
.setWidthPx(iconSizePx)
269+
.setHeightPx(iconSizePx)
270+
.setFormat(ResourceBuilders.IMAGE_FORMAT_RGB_565)
271+
.build(),
272+
).build(),
273+
)
274+
}
172275
}
173276
.build()
174277
}
@@ -249,6 +352,11 @@ class ShortcutsTile : TileService() {
249352

250353
private fun iconLayout(entity: SimplifiedEntity, showLabels: Boolean): LayoutElement = Box.Builder().apply {
251354
val iconSize = if (showLabels) ICON_SIZE_SMALL else ICON_SIZE_FULL
355+
val resourceId = if (entity.entityId == pendingEntityId) {
356+
entity.entityId + LOADING_RESOURCE_SUFFIX
357+
} else {
358+
entity.entityId
359+
}
252360
setWidth(dp(CIRCLE_SIZE))
253361
setHeight(dp(CIRCLE_SIZE))
254362
setHorizontalAlignment(HORIZONTAL_ALIGN_CENTER)
@@ -279,7 +387,7 @@ class ShortcutsTile : TileService() {
279387
addContent(
280388
// Add icon
281389
LayoutElementBuilders.Image.Builder()
282-
.setResourceId(entity.entityId)
390+
.setResourceId(resourceId)
283391
.setWidth(dp(iconSize))
284392
.setHeight(dp(iconSize))
285393
.build(),

0 commit comments

Comments
 (0)